From 4f2436ef63477cd5b7a4dd3444185e72ffe3ed9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 14:53:38 +0000 Subject: [PATCH 01/50] fix: a decision's caption change is set in place, not refused as a path change (mendixlabs/mxcli#1610) Under mdl 1, create or modify with only an @caption added to or reworded on an existing if was refused with 'changes where its paths end or meet'. The caption is a property of the decision, like its condition: it is now set in place through SetCondition, and the decision keeps its $ID and both paths. Refs ako/mxcli#1538, ako/mxcli#1399. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- ...caption-change-refused-as-path-change.json | 1 + .../1610-decision-caption-change-splices.mdl | 32 ++++ mdl/executor/cmd_alter_flow.go | 9 ++ mdl/executor/cmd_flow_modify.go | 52 ++++++- ...low_splice_decision_caption_pedapp_test.go | 145 ++++++++++++++++++ 5 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-decision-caption-change-refused-as-path-change.json create mode 100644 mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl create mode 100644 mdl/executor/flow_splice_decision_caption_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-decision-caption-change-refused-as-path-change.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-decision-caption-change-refused-as-path-change.json new file mode 100644 index 0000000000..e4de4e0ec6 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-decision-caption-change-refused-as-path-change.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-22","symptom":"mendixlabs/mxcli#1610 (ako/mxcli#1538, item 1 of saingge's comment on mendixlabs/mxcli#1517): under `mdl 1;` a re-run `create or modify microflow` whose only change is an `@caption` added to (or reworded on) an existing `if` is refused: 'the If at (360, 200) changes where its paths end or meet — a branch's return, or its else, is added or taken away … [MDL-V1-REBUILD]'. Nothing about the paths changed","cause":"patchDiff.gap pairs a declared `if` with the stored one by sameIfShell (whole shell equal, annotations included) or ifConditionChanged (condition differs, rest of the shell equal). A caption-only change fails both, so it fell through to the catch-all that refuses any same-condition `if` as a path-shape change — the message names the last check reached, not the property that differed","file":"mdl/executor/cmd_flow_modify.go (ifConditionChanged, decisionCaptionChanged, withoutCaption, conditionEdit), mdl/executor/cmd_alter_flow.go (splitCondition)","fix":"ifConditionChanged also accepts the same `if` with another caption (compared with the caption taken out of both shells). conditionEdit states a changed caption on the operation's `if`, and splitCondition writes it through the existing in-place SetCondition: the stated caption, or the condition when the caption is taken off (what the builder writes for a decision with no @caption)","test":"mdl/executor/flow_splice_decision_caption_pedapp_test.go TestCreateOrModify_DecisionCaptionIsSetInPlace (caption added, reworded, changed with the condition, taken off; decision keeps its $ID, nothing moves, re-run Unchanged; control: a branch's return taken away is still refused and writes nothing). Before the fix every subtest failed with 'changes where its paths end or meet' (caption + condition together failed with 'cannot replace the ExclusiveSplit')","insight":"When a refusal names a reason the user can see is false, look for a catch-all: the gap() refusals are a sequence of shape tests and the last one words every leftover difference as its own. The fix was to widen the edit that already existed (SetCondition sets Caption too), not to add an operation. A guard with a caption (`@caption …` on `if … then return; end if;`) goes through canonicalFlow first and is fixed by the same change","refs":["mendixlabs/mxcli#1610","ako/mxcli#1538","mendixlabs/mxcli#1517","ako/mxcli#1399"]} diff --git a/mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl b/mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl new file mode 100644 index 0000000000..86b3f8f825 --- /dev/null +++ b/mdl-examples/bug-tests/1610-decision-caption-change-splices.mdl @@ -0,0 +1,32 @@ +-- ============================================================================ +-- mendixlabs/mxcli#1610 (ako/mxcli#1538) — an @caption on an if is refused +-- ============================================================================ +-- +-- Under `mdl 1;`, re-running `create or modify microflow` with only an +-- `@caption` added to an existing `if` was refused: +-- +-- exec refuses with MDL-V1-REBUILD: 'the If at (360, 200) changes where its +-- paths end or meet — a branch's return, or its else, is added or taken +-- away'. Nothing about the paths changed. +-- +-- A decision's caption is set in place, as a changed condition is: the +-- decision keeps its $ID, its flows and both of its paths. +-- +-- To reproduce: exec this file against a project, then add +-- `@caption 'Big?'` on the line before `if` and exec it again. The second run +-- reports `Modified microflow: BugTest1610.F (spliced: 1 replaced)`, and a +-- third run writes nothing. Taking the caption off again captions the +-- decision with its condition, as a new one would be. + +mdl 1; + +create module BugTest1610; + +create or modify microflow BugTest1610.F ($N: Integer) returns String +begin + if $N > 1 then + return 'big'; + else + return 'small'; + end if; +end; diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index 365e18f446..6b52feed29 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -529,6 +529,15 @@ func (a *alterFlowContext) splitCondition(ctx *ExecContext, op *ast.AlterFlowOpe if caption == old.Expression { caption = expr } + // A caption the statement changes (mendixlabs/mxcli#1610): the stated + // one, or — taken off — the condition, as the builder captions a + // decision with no @caption. + if ann := ifs.Annotations; ann != nil { + caption = expr + if ann.CaptionSet { + caption = ann.Caption + } + } return expr, caption, nil } diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index e23c0989b1..88a62280d7 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -1054,23 +1054,60 @@ func sameIfShell(declared, stored ast.MicroflowStatement) (*ast.IfStmt, *ast.IfS } // ifConditionChanged reports whether two statements are the same `if` but for -// its condition — the same annotations, the same else — differing besides at -// most inside its branches and in where the split is drawn (ako/mxcli#888). +// its condition or its caption — the same notes, the same else — differing +// besides at most inside its branches and in where the split is drawn +// (ako/mxcli#888). Both are properties of the decision, set on it in place; a +// caption change was refused as a change of where the paths end or meet +// (mendixlabs/mxcli#1610). func ifConditionChanged(declared, stored ast.MicroflowStatement) (*ast.IfStmt, *ast.IfStmt, bool) { d, ok1 := declared.(*ast.IfStmt) s, ok2 := stored.(*ast.IfStmt) - if !ok1 || !ok2 || declaredMatches(d.Condition, s.Condition) { + if !ok1 || !ok2 { + return nil, nil, false + } + if declaredMatches(d.Condition, s.Condition) && !decisionCaptionChanged(d, s) { return nil, nil, false } dShell, sShell := *d, *s dShell.ThenBody, dShell.ElseBody, sShell.ThenBody, sShell.ElseBody = nil, nil, nil, nil dShell.Condition = s.Condition + dShell.Annotations, sShell.Annotations = withoutCaption(d.Annotations), withoutCaption(s.Annotations) if !sameExceptPositions(&dShell, &sShell) { return nil, nil, false } return d, s, true } +// decisionCaptionChanged reports whether the declared `if` states another +// caption than the stored one: one added, reworded or taken off. +func decisionCaptionChanged(declared, stored *ast.IfStmt) bool { + dc, ds := decisionCaption(declared) + sc, ss := decisionCaption(stored) + return dc != sc || ds != ss +} + +// decisionCaption is the @caption an `if` states, and whether it states one. +func decisionCaption(st *ast.IfStmt) (string, bool) { + if st.Annotations == nil || !st.Annotations.CaptionSet { + return "", false + } + return st.Annotations.Caption, true +} + +// withoutCaption is a copy of ann with no @caption, nil when nothing else is +// stated, so two shells that differ only in their caption compare equal. +func withoutCaption(ann *ast.ActivityAnnotations) *ast.ActivityAnnotations { + if ann == nil { + return nil + } + c := *ann + c.Caption, c.CaptionSet = "", false + if declaredMatches(&c, &ast.ActivityAnnotations{}) { + return nil + } + return &c +} + // conditionEdit diffs an `if` whose condition changes against the stored // one: the decision is the same node, so the new condition is set on it in // place — its $ID, its flows and both of its paths stay — and its branches @@ -1084,7 +1121,14 @@ func (pd *patchDiff) conditionEdit(declared, stored *ast.IfStmt) error { if _, ok := c.Object.(*microflows.ExclusiveSplit); !ok { return cannotSplice("the stored %s is not drawn as a decision", describeAt(stored)) } - pd.add(ast.AlterFlowReplace, c, []ast.MicroflowStatement{&ast.IfStmt{Condition: declared.Condition}}) + // A caption the script changes is stated on the operation's `if`; + // otherwise the stored caption is kept, or follows the condition. + edit := &ast.IfStmt{Condition: declared.Condition} + if decisionCaptionChanged(declared, stored) { + caption, set := decisionCaption(declared) + edit.Annotations = &ast.ActivityAnnotations{Caption: caption, CaptionSet: set} + } + pd.add(ast.AlterFlowReplace, c, []ast.MicroflowStatement{edit}) pd.ops[len(pd.ops)-1].SetCondition = true if err := pd.movedNode(declared, stored); err != nil { return err diff --git a/mdl/executor/flow_splice_decision_caption_pedapp_test.go b/mdl/executor/flow_splice_decision_caption_pedapp_test.go new file mode 100644 index 0000000000..e82934df8e --- /dev/null +++ b/mdl/executor/flow_splice_decision_caption_pedapp_test.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1610 (ako/mxcli#1538), and item 1 of saingge's comment on +// mendixlabs/mxcli#1517: under `mdl 1`, re-running `create or modify` with +// only an `@caption` added to an existing `if` was refused — +// +// exec refuses with MDL-V1-REBUILD: 'the If at (360, 200) changes where its +// paths end or meet — a branch's return, or its else, is added or taken +// away'. Nothing about the paths changed. +// +// A decision's caption is a property of the decision, like its condition: it +// is set in place, as a changed condition is (ako/mxcli#888), and the decision +// keeps its $ID, its flows and both of its paths. + +const captionFlow = `mdl 1; +create or modify microflow MyFirstModule.CaptionIf ($N: Integer) returns String +begin + log info node 'X' 'start'; + if $N > 1 then + return 'big'; + else + return 'small'; + end if; +end;` + +// captionedFlow is captionFlow with caption stated on the decision ("" for +// none). +func captionedFlow(caption, cond string) string { + src := captionFlow + if cond != "" { + src = strings.Replace(src, "if $N > 1 then", "if "+cond+" then", 1) + } + if caption != "" { + src = strings.Replace(src, " if ", " @caption '"+caption+"'\n if ", 1) + } + return src +} + +// captionSplit returns the stored decision of CaptionIf. +func captionSplit(t *testing.T, exec *Executor) *microflows.ExclusiveSplit { + t.Helper() + mf := loopEditFlowOf(t, exec, "CaptionIf") + for _, obj := range mf.ObjectCollection.Objects { + if s, ok := obj.(*microflows.ExclusiveSplit); ok { + return s + } + } + t.Fatal("CaptionIf has no decision") + return nil +} + +func TestCreateOrModify_DecisionCaptionIsSetInPlace(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, captionFlow); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + before := flowObjectIDs(loopEditFlowOf(t, exec, "CaptionIf").ObjectCollection) + splitID := captionSplit(t, exec).ID + + step := func(t *testing.T, src, wantCaption, wantExpr string) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + for _, v := range exec.CheckFlowVerdicts(prog) { + t.Errorf("check -p predicts a refusal: %s", v.Message) + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("refused: %v", err) + } + if !strings.Contains(out.String(), "Modified microflow: MyFirstModule.CaptionIf (spliced") { + t.Errorf("want the change spliced, got:\n%s", out.String()) + } + split := captionSplit(t, exec) + if split.ID != splitID { + t.Errorf("the decision was rebuilt: $ID %s, was %s", split.ID, splitID) + } + if split.Caption != wantCaption { + t.Errorf("caption = %q, want %q", split.Caption, wantCaption) + } + if c, ok := split.SplitCondition.(*microflows.ExpressionSplitCondition); !ok || c.Expression != wantExpr { + t.Errorf("condition = %#v, want %q", split.SplitCondition, wantExpr) + } + // Nothing else is rebuilt or moved. + after := flowObjectIDs(loopEditFlowOf(t, exec, "CaptionIf").ObjectCollection) + if len(after) != len(before) { + t.Errorf("object count %d, was %d", len(after), len(before)) + } + for id, p := range before { + if q, ok := after[id]; !ok { + t.Errorf("object %s is gone", id) + } else if p != q { + t.Errorf("object %s moved from %v to %v", id, p, q) + } + } + // The twice-exec rule: the same statement again writes nothing. + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("re-run: %v", err) + } + if !strings.Contains(out.String(), "Unchanged microflow: MyFirstModule.CaptionIf") { + t.Errorf("re-run is not Unchanged:\n%s", out.String()) + } + } + + t.Run("caption added", func(t *testing.T) { + step(t, captionedFlow("Big?", ""), "Big?", "$N > 1") + }) + t.Run("caption reworded", func(t *testing.T) { + step(t, captionedFlow("Large?", ""), "Large?", "$N > 1") + }) + t.Run("caption and condition changed together", func(t *testing.T) { + step(t, captionedFlow("Huge?", "$N > 10"), "Huge?", "$N > 10") + }) + t.Run("caption taken off reads as the condition", func(t *testing.T) { + step(t, captionedFlow("", "$N > 10"), "$N > 10", "$N > 10") + }) + + // Control: a path-shape change is still refused, with the reason, and + // writes nothing. + t.Run("control: a branch's return taken away is still refused", func(t *testing.T) { + split := captionSplit(t, exec) + src := strings.Replace(captionedFlow("Big?", "$N > 10"), " return 'big';\n", " log info node 'X' 'big';\n", 1) + src = strings.Replace(src, " end if;\nend;", " end if;\n return 'big';\nend;", 1) + err := agreeExec(t, exec, src) + if err == nil || !strings.Contains(err.Error(), "cannot be spliced") { + t.Fatalf("want a splice refusal, got %v", err) + } + if got := captionSplit(t, exec); got.ID != split.ID || got.Caption != split.Caption { + t.Errorf("the refused statement wrote: %s %q", got.ID, got.Caption) + } + }) +} From 94884b3d5936f4e1b613b4c11ca7e086381abf76 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:26:08 +0000 Subject: [PATCH 02/50] fix: splice a change to an activity with a custom error handler (mendixlabs/mxcli#1489) Under mdl 1, create or modify and alter both refused any change to an activity with an on-error handler, and each advised the other. The activity replaced by one handling errors the same way is now swapped in place, keeping the stored handler, its error flow and the merge it rejoins at; create or modify diffs the handler body like an if branch, and alter recognises a restated handler. Remaining refusals name drop-and-create, not the other mode. Refs ako/mxcli#1345, ako/mxcli#1399. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- ...ler-activity-replace-circular-refusal.json | 1 + .../1489-error-handler-activity-splices.mdl | 36 +++ mdl/ast/ast_alter_flow.go | 8 + mdl/backend/mfmutator/splice.go | 143 ++++++++-- mdl/backend/mfmutator/splice_handler_test.go | 133 ++++++++++ mdl/backend/mfmutator/splice_test.go | 12 +- mdl/executor/cmd_alter_flow.go | 12 + mdl/executor/cmd_flow_modify.go | 9 + mdl/executor/flow_splice_error_handler.go | 181 +++++++++++++ .../flow_splice_error_handler_pedapp_test.go | 245 ++++++++++++++++++ 10 files changed, 751 insertions(+), 29 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-22-error-handler-activity-replace-circular-refusal.json create mode 100644 mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl create mode 100644 mdl/backend/mfmutator/splice_handler_test.go create mode 100644 mdl/executor/flow_splice_error_handler.go create mode 100644 mdl/executor/flow_splice_error_handler_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-error-handler-activity-replace-circular-refusal.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-error-handler-activity-replace-circular-refusal.json new file mode 100644 index 0000000000..6ea77875ea --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-error-handler-activity-replace-circular-refusal.json @@ -0,0 +1 @@ +{"area":"mdl/backend","date":"2026-10-22","symptom":"mendixlabs/mxcli#1489 (ako/mxcli#1345): under `mdl 1;` an activity with `on error [without rollback] begin … end error` could not be changed in place. `create or modify` with one call argument changed (or only the handler's log text) was refused and advised `alter`; the advised `alter … replace` was refused with 'cannot replace the ActionActivity at (360, 200): it has an error handler, which would be left with no activity; rewrite the flow with create or modify instead'","cause":"mfmutator removable() refused any drop or replace of an activity with an outgoing error flow, whatever the replacement; create or modify reached the same refusal because patchDiff treated the handler body as part of the activity (a change inside the handler became a replace of the call), and lcsStatements did not pair such an activity, so a neighbouring change merged with it","file":"mdl/backend/mfmutator/splice.go (replaceKeepingHandler, swapInPlace, serializeInPlace, removable), mdl/executor/flow_splice_error_handler.go (sameHandlerShell, handlerEdit, keepStoredHandler, storedStatementAt), mdl/executor/cmd_flow_modify.go (gap, lcsStatements), mdl/executor/cmd_alter_flow.go (applyTo), mdl/ast/ast_alter_flow.go (KeepHandler)","fix":"Replace of a top-level activity with a custom handler by ONE activity whose action has the same ErrorHandlingType is an in-place swap (shared with replaceInLoop): same collection slot, stored position and size, every flow in/out — the error flow included — repointed; the handler's activities and rejoin merge untouched. create or modify pairs same-kind activities with the same handler kind, replaces the activity with KeepHandler when it changed, and diffs the handler body like an if branch. alter recognises a restated handler by comparing with the stored statement from the full-layout describe (sameIgnoringLayout) and strips the fragment to its entry activity; another handler is refused with advice to address the handler's activities by handle. Remaining refusals name drop-and-create, never create or modify","test":"mfmutator splice_handler_test.go TestSplice_ReplaceKeepsTheErrorHandler (byte-exact expected unit), TestSplice_ReplaceOfAHandledActivityRefusals; executor flow_splice_error_handler_pedapp_test.go TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace (T1, T3, both, T4 returning handler; ids/positions kept, re-run Unchanged, control: handler kind change refused), TestAlter_ReplaceOfActivityWithErrorHandler (T2; refusals not circular). Source reverted: every subtest fails with 'rewrite the flow with create or modify instead'","insight":"Two refusals that each advise the other mode are a sign the shared primitive refuses too broadly: the mutator refused on the presence of an error flow instead of asking whether the replacement keeps it valid (one activity, same custom ErrorHandlingType). The handler body is just more top-level nodes — diffing it like an if branch was enough for T3. The fake serializer in splice_test.go had no Action/ErrorHandlingType, so it had to grow one before the mutator test could express the case","refs":["mendixlabs/mxcli#1489","ako/mxcli#1345","mendixlabs/mxcli#1517","ako/mxcli#1399"]} diff --git a/mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl b/mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl new file mode 100644 index 0000000000..26da19f0c0 --- /dev/null +++ b/mdl-examples/bug-tests/1489-error-handler-activity-splices.mdl @@ -0,0 +1,36 @@ +-- ============================================================================ +-- mendixlabs/mxcli#1489 (ako/mxcli#1345) — an activity with a custom error +-- handler cannot be changed in place +-- ============================================================================ +-- +-- Under `mdl 1;`, re-running `create or modify` with one call argument changed +-- was refused, advising `alter`; the advised `alter … replace` was refused +-- too, advising create or modify: +-- +-- cannot replace the ActionActivity at (360, 200): it has an error handler, +-- which would be left with no activity; rewrite the flow with create or +-- modify instead +-- +-- To reproduce: exec this file, then change `P = $P` to `P = 'x'` (or the +-- handler's log text) and exec it again. The second run reports +-- `Modified microflow: BugTest1489.Flow (spliced: 1 replaced)`; the handler and +-- the merge it rejoins at keep their $IDs and positions. A third run writes +-- nothing. Changing the handler's kind is still refused. + +mdl 1; + +create module BugTest1489; + +create or modify microflow BugTest1489.Sub ($P: String) +begin + return; +end; + +create or modify microflow BugTest1489.Flow ($P: String) +begin + call microflow BugTest1489.Sub(P = $P) on error without rollback begin + log warning node 'BugTest1489' 'failed'; + end error; + log info node 'BugTest1489' 'after'; + return; +end; diff --git a/mdl/ast/ast_alter_flow.go b/mdl/ast/ast_alter_flow.go index ce94232b0d..86327d98fa 100644 --- a/mdl/ast/ast_alter_flow.go +++ b/mdl/ast/ast_alter_flow.go @@ -63,4 +63,12 @@ type AlterFlowOperation struct { // `create or modify`, for an `if` that differs from the stored one in its // condition; the branches are diffed by operations of their own. SetCondition bool + // KeepHandler, on a replace of an activity with a custom error handler, + // says Body's one statement states the stored handler unchanged: the + // activity alone is replaced, in place, and the stored handler — its + // activities, its error flow and the merge it rejoins at — stays + // (mendixlabs/mxcli#1489). Set by `create or modify`, which diffs the + // handler's body by operations of its own; an `alter` replace that restates + // the stored handler is recognised when it is applied. + KeepHandler bool } diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index b00376e009..727cf78eea 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -24,10 +24,11 @@ package mfmutator // // What the splice cannot do safely it refuses, with the reason: an insert // after a decision (which branch?), before an activity several flows enter -// (which path?), a drop or replace of an activity with an error handler (the -// handler would be orphaned), and — for now — anything inside a loop body -// but an activity replaced by one of the same kind, in place, since a body -// node's coordinates are relative to the loop box (ako/mxcli#1106). +// (which path?), a drop of an activity with an error handler, or its replace +// by anything but one activity handling errors the same way (the handler would +// be orphaned; mendixlabs/mxcli#1489), and — for now — anything inside a loop +// body but an activity replaced by one of the same kind, in place, since a +// body node's coordinates are relative to the loop box (ako/mxcli#1106). import ( "bytes" @@ -400,6 +401,9 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro if err != nil { return err } + if _, errs := g.outgoing(x.id); len(errs) > 0 && x.typ == "Microflows$ActionActivity" { + return m.replaceKeepingHandler(g, x, frag) + } out, err := removable(g, x, "replace") if err != nil { return err @@ -465,8 +469,7 @@ func (m *Mutator) Replace(target model.ID, frag *backend.MicroflowFragment) erro // coordinates are relative to the loop box, so anything that adds, removes or // redraws a node there would have to re-lay the body out; that is refused. func (m *Mutator) replaceInLoop(g *graph, x *node, frag *backend.MicroflowFragment) error { - if frag == nil || len(frag.Objects) != 1 || len(frag.Flows) > 0 || len(frag.AnnotationFlows) > 0 || - frag.ExitCase != nil || frag.Entry != frag.Objects[0].GetID() || frag.Exit != frag.Entry { + if !singleActivity(frag) { return fmt.Errorf("%s is inside a loop body, where the splice replaces an activity by one activity "+ "only (nothing in the body is added, removed or redrawn); replace the whole loop instead", describeNode(x)) } @@ -486,23 +489,85 @@ func (m *Mutator) replaceInLoop(g *graph, x *node, frag *backend.MicroflowFragme if loop == nil { return fmt.Errorf("%s is in a loop that is not in the flow", describeNode(x)) } - oc := dDoc(loop.doc, "ObjectCollection") + d, err := m.serializeInPlace(x, frag) + if err != nil { + return err + } + if t := dString(d, "$Type"); t != x.typ { + return fmt.Errorf("%s is inside a loop body, where the splice replaces an activity by one of the same "+ + "kind only, not by a %s; replace the whole loop instead", describeNode(x), t) + } + return m.swapInPlace(g, x, dDoc(loop.doc, "ObjectCollection"), d, frag.Entry) +} + +// replaceKeepingHandler replaces a top-level activity that has a custom error +// handler by one activity handling errors the same way, in place +// (mendixlabs/mxcli#1489): the new activity takes the stored one's place, is +// drawn where it was at the size it was, and every flow that entered or left +// it — its error flow included — now enters or leaves the new one. The +// handler's own activities, and the merge its path rejoins at, are not +// touched: changing one of them is a change of its own, addressed by its own +// handle. Anything else — more than one activity, or one that does not raise +// to a custom handler of the same kind — would leave the stored handler with +// no activity, and is refused. +func (m *Mutator) replaceKeepingHandler(g *graph, x *node, frag *backend.MicroflowFragment) error { + if !singleActivity(frag) { + return fmt.Errorf("cannot replace %s: it has an error handler, which the splice keeps only when the activity "+ + "is replaced by one activity handling errors the same way (the handler's own activities are changed by "+ + "their own handles); to change or take out the handler itself, drop the microflow and create it in the "+ + "same script", describeNode(x)) + } + stored := storedErrorHandling(x.doc) + got := microflows.ObjectErrorHandlingType(frag.Objects[0]) + switch { + case !microflows.IsCustomErrorHandling(got): + return fmt.Errorf("cannot replace %s: it has an error handler (%s), and the replacement states none, so the "+ + "handler would be left with no activity; state the handler on the replacement as `describe` prints it", + describeNode(x), errorHandlingClause(stored)) + case string(got) != stored: + return fmt.Errorf("cannot replace %s: its error handler is `%s`, and the replacement's is `%s`; the splice keeps "+ + "the stored handler, so state it as `describe` prints it", describeNode(x), errorHandlingClause(stored), + errorHandlingClause(string(got))) + } + d, err := m.serializeInPlace(x, frag) + if err != nil { + return err + } + if t := dString(d, "$Type"); t != x.typ { + return fmt.Errorf("cannot replace %s by a %s: it has an error handler, which the splice keeps only on an "+ + "activity", describeNode(x), t) + } + return m.swapInPlace(g, x, dDoc(m.doc, "ObjectCollection"), d, frag.Entry) +} + +// singleActivity reports whether frag is one object and nothing else: no +// flows, no notes, no case on its exit. +func singleActivity(frag *backend.MicroflowFragment) bool { + return frag != nil && len(frag.Objects) == 1 && len(frag.Flows) == 0 && len(frag.AnnotationFlows) == 0 && + frag.ExitCase == nil && frag.Entry == frag.Objects[0].GetID() && frag.Exit == frag.Entry +} + +// serializeInPlace encodes frag's one object as the replacement of x: drawn +// where x is, at x's size (a Studio Pro activity may have been resized). +func (m *Mutator) serializeInPlace(x *node, frag *backend.MicroflowFragment) (bson.D, error) { obj := frag.Objects[0] obj.SetPosition(model.Point{X: x.pos.X, Y: x.pos.Y}) d, err := m.deps.SerializeObject(obj) if err != nil { - return fmt.Errorf("serialize %T: %w", obj, err) + return nil, fmt.Errorf("serialize %T: %w", obj, err) } if d == nil { - return fmt.Errorf("cannot write a %T into a flow", obj) - } - if t := dString(d, "$Type"); t != x.typ { - return fmt.Errorf("%s is inside a loop body, where the splice replaces an activity by one of the same "+ - "kind only, not by a %s; replace the whole loop instead", describeNode(x), t) + return nil, fmt.Errorf("cannot write a %T into a flow", obj) } - // Drawn as the stored one is: a Studio Pro activity may have been resized. dSet(d, "RelativeMiddlePoint", x.pos.String()) dSet(d, "Size", x.size.String()) + return d, nil +} + +// swapInPlace puts d where x is in the collection oc, and points every flow +// that entered or left x — normal, error or annotation — at d's $ID, id. +// Nothing else moves. +func (m *Mutator) swapInPlace(g *graph, x *node, oc bson.D, d bson.D, id model.ID) error { objs := arrayElements(dGet(oc, "Objects")) found := false for i, el := range objs { @@ -512,25 +577,45 @@ func (m *Mutator) replaceInLoop(g *graph, x *node, frag *backend.MicroflowFragme } } if !found || !setArray(oc, "Objects", objs) { - return fmt.Errorf("%s is not in its loop's collection", describeNode(x)) + return fmt.Errorf("%s is not in its collection", describeNode(x)) } m.markRemoved(x.doc) - for _, in := range g.incoming(x.id) { - setPointer(in.doc, "DestinationPointer", frag.Entry) - } - for _, out := range normal { - setPointer(out.doc, "OriginPointer", frag.Exit) - } - for _, af := range g.annotationFlows(x.id) { - key := "DestinationPointer" - if af.origin == x.id { - key = "OriginPointer" + for _, f := range g.flows { + switch { + case f.origin == x.id: + setPointer(f.doc, "OriginPointer", id) + case f.dest == x.id: + setPointer(f.doc, "DestinationPointer", id) } - setPointer(af.doc, key, frag.Entry) } return nil } +// storedErrorHandling is the error handling a stored activity's action has. +func storedErrorHandling(d bson.D) string { + if a := dDoc(d, "Action"); a != nil { + if eh := dString(a, "ErrorHandlingType"); eh != "" { + return eh + } + } + return dString(d, "ErrorHandlingType") +} + +// errorHandlingClause is how MDL spells an error handling type. +func errorHandlingClause(eh string) string { + switch microflows.ErrorHandlingType(eh) { + case microflows.ErrorHandlingTypeCustom: + return "on error begin … end error" + case microflows.ErrorHandlingTypeCustomWithoutRollback: + return "on error without rollback begin … end error" + case microflows.ErrorHandlingTypeContinue: + return "on error continue" + case "", microflows.ErrorHandlingTypeRollback, microflows.ErrorHandlingTypeAbort: + return "no on error clause" + } + return eh +} + // setCase gives a stored flow the case value cv, in the form the engine // writes it: the case properties of a flow serialized with cv replace the // stored ones, and the flow keeps its $ID and everything else. The stored @@ -732,8 +817,12 @@ func removable(g *graph, x *node, verb string) (flowRef, error) { } normal, errs := g.outgoing(x.id) if len(errs) > 0 { + // create or modify reaches this same refusal, so naming it would send + // the reader in a circle (mendixlabs/mxcli#1489): what takes a + // handler out is the deliberate rebuild. return flowRef{}, fmt.Errorf("cannot %s %s: it has an error handler, which would be left with no activity; "+ - "rewrite the flow with create or modify instead", verb, describeNode(x)) + "the splice takes no handler out — to rebuild the flow deliberately, drop the microflow and create it "+ + "in the same script", verb, describeNode(x)) } if len(normal) != 1 { return flowRef{}, fmt.Errorf("cannot %s %s: it has %d outgoing flows, not one", verb, describeNode(x), len(normal)) diff --git a/mdl/backend/mfmutator/splice_handler_test.go b/mdl/backend/mfmutator/splice_handler_test.go new file mode 100644 index 0000000000..c4dbbee4c7 --- /dev/null +++ b/mdl/backend/mfmutator/splice_handler_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "bytes" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1489 (ako/mxcli#1345): an activity with a custom error +// handler could not be replaced at all — "cannot replace the ActionActivity at +// (360, 200): it has an error handler, which would be left with no activity; +// rewrite the flow with create or modify instead" — even by the same kind of +// activity handling errors the same way. Replaced by one activity that has a +// custom handler of the same kind, it is swapped in place: the stored handler, +// its error flow and the merge it rejoins at all stay, and point at the new +// activity. + +// withEH gives a stored activity document the error handling its action has. +func withEH(d bson.D, eh microflows.ErrorHandlingType) bson.D { + return append(d, bson.E{Key: "Action", Value: bson.D{ + {Key: "$Type", Value: "Microflows$MicroflowCallAction"}, + {Key: "ErrorHandlingType", Value: string(eh)}, + }}) +} + +// oneActivityEH is oneActivity whose action handles errors with eh. +func oneActivityEH(eh microflows.ErrorHandlingType) *backend.MicroflowFragment { + frag := oneActivity() + frag.Objects[0].(*microflows.ActionActivity).Action = µflows.MicroflowCallAction{ErrorHandlingType: eh} + return frag +} + +// handlerFlow is start -> a -> merge -> end, with a's error handler h +// rejoining at the merge. +func handlerFlow() ([]bson.D, []bson.D) { + a := withEH(obj("a", "Microflows$ActionActivity", 250, 200), microflows.ErrorHandlingTypeCustomWithoutRollback) + dSet(a, "Size", "160;60") // resized in Studio Pro: kept + return []bson.D{ + obj("start", "Microflows$StartEvent", 100, 200), + a, + obj("h", "Microflows$ActionActivity", 250, 330), + obj("m", "Microflows$ExclusiveMerge", 400, 200), + obj("end", "Microflows$EndEvent", 550, 200), + }, []bson.D{ + flow("f1", "start", "a", 1, 3, false), + flow("f2", "a", "m", 1, 3, false), + flow("err", "a", "h", 2, 0, true), + flow("fh", "h", "m", 1, 2, false), + flow("f3", "m", "end", 1, 3, false), + } +} + +func TestSplice_ReplaceKeepsTheErrorHandler(t *testing.T) { + objs, flows := handlerFlow() + m, deps := newMutator(t, unit(objs, flows)) + frag := oneActivityEH(microflows.ErrorHandlingTypeCustomWithoutRollback) + if err := m.Replace(model.ID(uid("a")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + // What the replace should leave: the stored unit with a's document + // swapped for the new one, drawn where a was at a's size, and the three + // flows through a — in, out, and the error flow — pointing at it. + newID := bson.Binary{Data: types.UUIDToBlob(string(frag.Entry))} + wantObjs, wantFlows := handlerFlow() + wantObjs[1] = withEH(bson.D{ + {Key: "$ID", Value: newID}, + {Key: "$Type", Value: "Microflows$ActionActivity"}, + {Key: "RelativeMiddlePoint", Value: "250;200"}, + {Key: "Size", Value: "160;60"}, + }, microflows.ErrorHandlingTypeCustomWithoutRollback) + dSet(wantFlows[0], "DestinationPointer", newID) + dSet(wantFlows[1], "OriginPointer", newID) + dSet(wantFlows[2], "OriginPointer", newID) + raw, _ := bson.Marshal(unit(wantObjs, wantFlows)) + if !bytes.Equal(deps.saved, raw) { + var got bson.D + _ = bson.Unmarshal(deps.saved, &got) + t.Errorf("the replace changed more than the activity and the flows through it:\n got %v\nwant %v", got, unit(wantObjs, wantFlows)) + } +} + +func TestSplice_ReplaceOfAHandledActivityRefusals(t *testing.T) { + for _, tc := range []struct { + name string + frag func() *backend.MicroflowFragment + want string + }{ + // Without a custom handler the stored one would hang off an + // activity that does not raise to it. + {"no handler", oneActivity, "states none"}, + {"another kind of handler", func() *backend.MicroflowFragment { + return oneActivityEH(microflows.ErrorHandlingTypeCustom) + }, "the replacement's is `on error begin … end error`"}, + {"two activities for one", func() *backend.MicroflowFragment { + frag := oneActivityEH(microflows.ErrorHandlingTypeCustomWithoutRollback) + second := oneActivity() + frag.Objects = append(frag.Objects, second.Objects...) + frag.Exit = second.Exit + return frag + }, "by one activity"}, + } { + t.Run(tc.name, func(t *testing.T) { + objs, flows := handlerFlow() + m, _ := newMutator(t, unit(objs, flows)) + before, _ := bson.Marshal(m.doc) + err := m.Replace(model.ID(uid("a")), tc.frag()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want an error containing %q, got %v", tc.want, err) + } + // The advice must not send the reader to create or modify, + // which refuses the same change (the circular advice of #1489). + if strings.Contains(err.Error(), "create or modify") { + t.Errorf("the refusal advises create or modify: %v", err) + } + after, _ := bson.Marshal(m.doc) + if !bytes.Equal(before, after) { + t.Error("a refused replace changed the document") + } + }) + } +} diff --git a/mdl/backend/mfmutator/splice_test.go b/mdl/backend/mfmutator/splice_test.go index b5dcccfca6..0528d5661c 100644 --- a/mdl/backend/mfmutator/splice_test.go +++ b/mdl/backend/mfmutator/splice_test.go @@ -96,12 +96,20 @@ func (d *fakeDeps) SerializeObject(o microflows.MicroflowObject) (bson.D, error) } p := o.GetPosition() sz := objectSize(o) - return bson.D{ + doc := bson.D{ {Key: "$ID", Value: bson.Binary{Data: types.UUIDToBlob(string(o.GetID()))}}, {Key: "$Type", Value: typ}, {Key: "RelativeMiddlePoint", Value: fmt.Sprintf("%d;%d", p.X, p.Y)}, {Key: "Size", Value: fmt.Sprintf("%d;%d", sz.X, sz.Y)}, - }, nil + } + // The error handling is stored on the action, as the codec writes it. + if eh := microflows.ObjectErrorHandlingType(o); eh != "" { + doc = append(doc, bson.E{Key: "Action", Value: bson.D{ + {Key: "$Type", Value: "Microflows$MicroflowCallAction"}, + {Key: "ErrorHandlingType", Value: string(eh)}, + }}) + } + return doc, nil } func (d *fakeDeps) SerializeSequenceFlow(f *microflows.SequenceFlow) (bson.D, error) { diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index 6b52feed29..ae34838a6f 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -159,6 +159,13 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato if err != nil { return fail(err) } + if op.Op == ast.AlterFlowReplace { + // An activity with a custom error handler, replaced by one that + // states the same handler, keeps the stored one (mendixlabs/mxcli#1489). + if frag, err = a.keepStoredHandler(ctx, op, target, frag); err != nil { + return fail(err) + } + } if loop != nil && (len(frag.Objects) != 1 || len(frag.Flows) > 0 || len(frag.AnnotationFlows) > 0) { return inLoop() } @@ -236,6 +243,11 @@ type alterFlowContext struct { // the members a full build of the same statement writes (ako/mxcli#885): // the stored flow alone does not say what every variable holds. declaredVarTypes map[string]string + + // storedDesc is the stored flow as describe prints it with full layout, + // read once when an operation needs the stored statement behind a target + // (storedStatementAt). + storedDesc ast.Statement } // enclosingLoop returns the top-level loop whose body holds the stored diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index 88a62280d7..3da296f0b4 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -920,6 +920,9 @@ func (pd *patchDiff) gap(ins []ast.MicroflowStatement, stored []ast.MicroflowSta } return pd.statements(d.ElseBody, s.ElseBody) } + if sameHandlerShell(ins[0], del[0]) { + return pd.handlerEdit(ins[0], del[0]) + } if sameLoopShell(ins[0], del[0]) { return pd.loopBody(ins[0], del[0]) } @@ -1507,6 +1510,12 @@ func lcsStatements(declared, stored []ast.MicroflowStatement) [][2]int { w[i][j] = 1 case sameBlockShell(declared[i], stored[j]): w[i][j] = 1 + case sameHandlerShell(declared[i], stored[j]): + // An activity with the same custom error handler kind is that + // activity, changed itself or inside its handler + // (mendixlabs/mxcli#1489): a change beside it must not merge + // with it into a replace, which would take the handler out. + w[i][j] = 1 case editableLoopBody(declared[i], stored[j]): // A loop edited in place inside is that loop: a change beside // it must not merge with it into a replace (ako/mxcli#1106). diff --git a/mdl/executor/flow_splice_error_handler.go b/mdl/executor/flow_splice_error_handler.go new file mode 100644 index 0000000000..b76bd30d40 --- /dev/null +++ b/mdl/executor/flow_splice_error_handler.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "reflect" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// An activity with a custom error handler (`on error [without rollback] begin +// … end error`) is drawn as the activity, an error flow out of it, the +// handler's activities, and — unless the handler returns — a merge where the +// handler's path rejoins the flow. The splice used to refuse any replace of +// such an activity, since the handler would be left with no activity, and +// `create or modify` treated the handler's body as part of the activity, so a +// change of either was refused and each mode advised the other +// (mendixlabs/mxcli#1489). +// +// Both are now spliced. The activity replaced by one stating the same handler +// is swapped in place, and the stored handler stays (mfmutator, +// replaceKeepingHandler); the handler's body is diffed like a branch of an +// `if`, its activities being top-level nodes of the stored graph. A change of +// the handler's kind is still refused. + +// errorHandling is a statement's `on error` clause, nil when it has none. +func errorHandling(st ast.MicroflowStatement) *ast.ErrorHandlingClause { + v := reflectElem(st) + if !v.IsValid() { + return nil + } + f := v.FieldByName("ErrorHandling") + if !f.IsValid() { + return nil + } + eh, _ := f.Interface().(*ast.ErrorHandlingClause) + return eh +} + +// withHandlerBody is a copy of st whose `on error` clause holds body. +func withHandlerBody(st ast.MicroflowStatement, body []ast.MicroflowStatement) ast.MicroflowStatement { + v := reflect.ValueOf(st) + if v.Kind() != reflect.Pointer || v.IsNil() { + return st + } + c := reflect.New(v.Elem().Type()) + c.Elem().Set(v.Elem()) + f := c.Elem().FieldByName("ErrorHandling") + eh, _ := f.Interface().(*ast.ErrorHandlingClause) + if eh == nil { + return st + } + clause := *eh + clause.Body = body + f.Set(reflect.ValueOf(&clause)) + out, _ := c.Interface().(ast.MicroflowStatement) + return out +} + +// sameHandlerShell reports whether two statements are the same kind of +// activity with a custom error handler of the same kind, differing in the +// activity, inside the handler, or both. +func sameHandlerShell(declared, stored ast.MicroflowStatement) bool { + if statementKind(declared) != statementKind(stored) || !customErrorHandler(declared) || !customErrorHandler(stored) { + return false + } + return errorHandling(declared).Type == errorHandling(stored).Type +} + +// handlerEdit diffs an activity with a custom error handler against the +// stored one (sameHandlerShell): a changed activity is replaced in place, +// keeping the stored handler, and the handler's body is diffed as a +// statement list of its own. +func (pd *patchDiff) handlerEdit(declared, stored ast.MicroflowStatement) error { + sBody := errorHandling(stored).Body + shell := withHandlerBody(declared, sBody) + if !declaredMatches(shell, stored) { + if sameExceptPositions(shell, stored) { + if err := pd.moved(shell, stored); err != nil { + return err + } + } else { + c, err := pd.loc.locate(stored) + if err != nil { + return err + } + body, replaceNotes, err := keepStoredNotes(stored, []ast.MicroflowStatement{shell}) + if err != nil { + return err + } + if replaceNotes { + return cannotSplice("the notes on the %s change with the activity; the splice keeps an error handler "+ + "only on an activity replaced by itself, so change the notes and the activity in two runs", describeAt(stored)) + } + pd.add(ast.AlterFlowReplace, c, body) + pd.ops[len(pd.ops)-1].KeepHandler = true + } + } + return pd.statements(errorHandling(declared).Body, sBody) +} + +// keepStoredHandler returns the fragment a replace of target is spliced with +// when target has a custom error handler and the replacement is one statement +// stating the same handler: the replacement's activity alone, which the +// mutator swaps in place, keeping the stored handler. Any other fragment is +// returned as built — the mutator refuses it, since the stored handler would +// be left with no activity — except one stating another handler, refused here +// with what works instead. +func (a *alterFlowContext) keepStoredHandler(ctx *ExecContext, op *ast.AlterFlowOperation, target mfmutator.Candidate, + frag *backend.MicroflowFragment) (*backend.MicroflowFragment, error) { + if len(op.Body) != 1 || !customErrorHandler(op.Body[0]) || + !microflows.IsCustomErrorHandling(microflows.ObjectErrorHandlingType(target.Object)) { + return frag, nil + } + if !op.KeepHandler { + stored, err := a.storedStatementAt(ctx, target) + if err != nil { + return nil, err + } + if !customErrorHandler(stored) || !sameIgnoringLayout(errorHandling(op.Body[0]), errorHandling(stored)) { + return nil, fmt.Errorf("the replacement states another error handler than the stored one; the splice "+ + "keeps the stored handler, so state it as `describe` prints it, and change the handler's activities "+ + "with operations of their own, by the handles `describe %s %s with handles` prints", + a.stmt.Kind(), a.stmt.Name) + } + } + for _, obj := range frag.Objects { + if obj.GetID() == frag.Entry { + return &backend.MicroflowFragment{Objects: []microflows.MicroflowObject{obj}, Entry: frag.Entry, Exit: frag.Entry}, nil + } + } + return nil, fmt.Errorf("the replacement's activity is not in its fragment") +} + +// storedStatementAt returns the stored statement drawn as target: the one +// whose @position, in the flow's full-layout description, is where target is +// drawn. A statement inside a loop body is not looked for; its coordinates are +// relative to the loop. +func (a *alterFlowContext) storedStatementAt(ctx *ExecContext, target mfmutator.Candidate) (ast.MicroflowStatement, error) { + if a.storedDesc == nil { + st, err := describedFlowStmt(ctx, &flowDecl{nanoflow: a.stmt.Nanoflow, name: a.stmt.Name}, a) + if err != nil { + return nil, fmt.Errorf("the stored flow's description cannot be read: %v", err) + } + a.storedDesc = st + } + p := target.Object.GetPosition() + if found := statementDrawnAt(storedBody(a.storedDesc), p); found != nil { + return found, nil + } + return nil, fmt.Errorf("no stored statement is drawn at (%d, %d), where %s is", p.X, p.Y, target.Statement) +} + +// statementDrawnAt finds the statement whose @position is p, in stmts, the +// branches of an `if` and the bodies of error handlers. +func statementDrawnAt(stmts []ast.MicroflowStatement, p model.Point) ast.MicroflowStatement { + for _, st := range stmts { + if ann := statementAnnotations(st); ann != nil && ann.Position != nil && + ann.Position.X == p.X && ann.Position.Y == p.Y { + return st + } + var inner [][]ast.MicroflowStatement + if ifs, ok := st.(*ast.IfStmt); ok { + inner = append(inner, ifs.ThenBody, ifs.ElseBody) + } + if eh := errorHandling(st); eh != nil { + inner = append(inner, eh.Body) + } + for _, body := range inner { + if found := statementDrawnAt(body, p); found != nil { + return found + } + } + } + return nil +} diff --git a/mdl/executor/flow_splice_error_handler_pedapp_test.go b/mdl/executor/flow_splice_error_handler_pedapp_test.go new file mode 100644 index 0000000000..ae81c1d53f --- /dev/null +++ b/mdl/executor/flow_splice_error_handler_pedapp_test.go @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1489 (ako/mxcli#1345): under `mdl 1`, an activity with a +// custom error handler could not be changed in place. `create or modify` with +// one call argument changed was refused, advising `alter`; the advised +// `alter … replace` was refused too, advising create or modify: +// +// cannot replace the ActionActivity at (360, 200): it has an error handler, +// which would be left with no activity; rewrite the flow with create or +// modify instead +// +// and a change inside the handler, the call itself unchanged, was refused with +// the same message. The activity is now swapped in place when its replacement +// states the same handler, the stored handler and the merge it rejoins at +// staying as drawn; a change inside the handler is a change of the handler's +// own activity. + +const handlerFlowSrc = `mdl 1; +create or modify microflow MyFirstModule.HSub ($P: String) +begin + return; +end; +create or modify microflow MyFirstModule.HFlow ($P: String) +begin + call microflow MyFirstModule.HSub(P = $P) on error without rollback begin + log warning node 'Repro' 'failed'; + end error; + log info node 'Repro' 'after'; + return; +end;` + +// handlerRetSrc is the same with a handler that ends the flow (T4). +const handlerRetSrc = `mdl 1; +create or modify microflow MyFirstModule.HRet ($P: String) +begin + call microflow MyFirstModule.HSub(P = $P) on error without rollback begin + log warning node 'Repro' 'failed'; + return; + end error; + log info node 'Repro' 'after'; + return; +end;` + +// handlerObjects returns the flow's objects by $ID, with where each is drawn, +// and the call activity's $ID. +func handlerObjects(t *testing.T, exec *Executor, name string) (map[model.ID]model.Point, model.ID) { + t.Helper() + mf := loopEditFlowOf(t, exec, name) + var call model.ID + for _, obj := range mf.ObjectCollection.Objects { + if a, ok := obj.(*microflows.ActionActivity); ok { + if _, ok := a.Action.(*microflows.MicroflowCallAction); ok { + call = a.ID + } + } + } + return flowObjectIDs(mf.ObjectCollection), call +} + +// spliceStep runs src — check first, then exec — and asserts it is spliced +// with exactly `changed` objects replaced and nothing else rebuilt or moved, +// that describe shows want, and that a second run is Unchanged. +func spliceStep(t *testing.T, exec *Executor, out *bytes.Buffer, name, src string, changed int, want ...string) { + t.Helper() + before, _ := handlerObjects(t, exec, name) + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + for _, v := range exec.CheckFlowVerdicts(prog) { + t.Errorf("check -p predicts a refusal: %s", v.Message) + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("refused: %v", err) + } + if !strings.Contains(out.String(), "MyFirstModule."+name+" (spliced") { + t.Errorf("want the change spliced, got:\n%s", out.String()) + } + after, _ := handlerObjects(t, exec, name) + var gone int + for id, p := range before { + q, ok := after[id] + if !ok { + gone++ + continue + } + if p != q { + t.Errorf("object %s moved from %v to %v", id, p, q) + } + } + if gone != changed || len(after) != len(before) { + t.Errorf("%d objects replaced (%d -> %d objects), want %d replaced in place", gone, len(before), len(after), changed) + } + desc := describeFlow(t, exec, name) + for _, w := range want { + if !strings.Contains(desc, w) { + t.Errorf("describe lacks %q:\n%s", w, desc) + } + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("re-run: %v", err) + } + if !strings.Contains(out.String(), "Unchanged microflow: MyFirstModule."+name) { + t.Errorf("re-run is not Unchanged:\n%s", out.String()) + } +} + +func TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, handlerFlowSrc); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + if err := agreeExec(t, exec, handlerRetSrc); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + flow := func(src string) string { + // The HFlow statement alone, so the HSub statement is not re-run. + return "mdl 1;\n" + src[strings.Index(src, "create or modify microflow MyFirstModule.HFlow"):] + } + + t.Run("T1 call argument changed, handler unchanged", func(t *testing.T) { + _, call := handlerObjects(t, exec, "HFlow") + spliceStep(t, exec, out, "HFlow", flow(strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1)), 1, + "HSub(P = 'x') on error without rollback begin", "log warning node 'Repro' 'failed';") + if _, now := handlerObjects(t, exec, "HFlow"); now == call { + t.Error("the call kept its $ID: the change was not written") + } + }) + t.Run("T3 only the handler's log changed", func(t *testing.T) { + _, call := handlerObjects(t, exec, "HFlow") + src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1) + src = strings.Replace(src, "'failed';", "'failed again';", 1) + spliceStep(t, exec, out, "HFlow", flow(src), 1, "'failed again'", "HSub(P = 'x') on error") + if _, now := handlerObjects(t, exec, "HFlow"); now != call { + t.Error("the call was replaced for a change inside its handler") + } + }) + t.Run("call and handler changed together", func(t *testing.T) { + src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'y') on", 1) + src = strings.Replace(src, "'failed';", "'failed twice';", 1) + spliceStep(t, exec, out, "HFlow", flow(src), 2, "HSub(P = 'y') on error", "'failed twice'") + }) + t.Run("T4 handler that returns", func(t *testing.T) { + spliceStep(t, exec, out, "HRet", strings.Replace(handlerRetSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1), 1, + "HSub(P = 'x') on error without rollback begin") + }) + + // Control: the handler's kind is part of the activity's shape; another + // kind is still refused, with advice that is not create or modify. + t.Run("control: another kind of handler is refused", func(t *testing.T) { + before, _ := handlerObjects(t, exec, "HFlow") + src := flow(strings.Replace(handlerFlowSrc, "on error without rollback begin", "on error begin", 1)) + err := agreeExec(t, exec, src) + if err == nil { + t.Fatal("want a refusal") + } + if after, _ := handlerObjects(t, exec, "HFlow"); len(after) != len(before) { + t.Error("the refused statement wrote") + } + }) +} + +func TestAlter_ReplaceOfActivityWithErrorHandler(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, handlerFlowSrc); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + alter := func(body string) string { + return "mdl 1;\nalter microflow MyFirstModule.HFlow {\n replace call microflow MyFirstModule.HSub(*) with begin\n" + + body + "\n end;\n};" + } + unchangedSince := func(t *testing.T, before map[model.ID]model.Point) { + t.Helper() + after, _ := handlerObjects(t, exec, "HFlow") + if len(after) != len(before) { + t.Fatalf("the refused alter wrote") + } + for id := range before { + if _, ok := after[id]; !ok { + t.Fatalf("the refused alter wrote") + } + } + } + + // The refusals first: none may advise create or modify, which refuses + // the same change. + for _, tc := range []struct{ name, body, want string }{ + {"no handler stated", " call microflow MyFirstModule.HSub(P = 'x');", "states none"}, + {"another handler stated", + " call microflow MyFirstModule.HSub(P = 'x') on error without rollback begin\n" + + " log warning node 'Repro' 'something else';\n end error;", + "another error handler"}, + } { + t.Run("refused: "+tc.name, func(t *testing.T) { + before, _ := handlerObjects(t, exec, "HFlow") + src := alter(tc.body) + checkSays(t, exec, src, tc.want) + err := agreeExec(t, exec, src) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want a refusal with %q, got %v", tc.want, err) + } + if strings.Contains(err.Error(), "create or modify") { + t.Errorf("the refusal advises create or modify: %v", err) + } + unchangedSince(t, before) + }) + } + + t.Run("T2 the same handler stated", func(t *testing.T) { + before, call := handlerObjects(t, exec, "HFlow") + src := alter(" call microflow MyFirstModule.HSub(P = 'x') on error without rollback begin\n" + + " log warning node 'Repro' 'failed';\n end error;") + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("refused: %v", err) + } + after, now := handlerObjects(t, exec, "HFlow") + if now == call || len(after) != len(before) { + t.Errorf("want the call alone replaced: %d -> %d objects", len(before), len(after)) + } + for id, p := range before { + if q, ok := after[id]; ok && p != q { + t.Errorf("object %s moved from %v to %v", id, p, q) + } + } + desc := describeFlow(t, exec, "HFlow") + if !strings.Contains(desc, "HSub(P = 'x') on error without rollback begin") || + !strings.Contains(desc, "log warning node 'Repro' 'failed';") { + t.Errorf("describe after the alter:\n%s", desc) + } + }) +} From b9bedb209dbfb503ff07a5d81f21457a76aeed68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:05:41 +0000 Subject: [PATCH 03/50] fix: a note restated before @position says it reads as a free note (mendixlabs/mxcli#1517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An @annotation before a statement's @position is a free note, which is where describe prints one. Restating an activity's note in that order was refused with 'note "…" is not in the stored flow', which was false. The refusal now names the order, the activity the stored note is attached to, and the spelling that keeps it attached; the write-microflows skill states the rule. Refs ako/mxcli#1399. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- ...te-before-position-reads-as-free-note.json | 1 + .../skills/mendix/write-microflows/SKILL.md | 7 ++- mdl/executor/cmd_flow_modify.go | 51 ++++++++++++++++ .../flow_splice_note_order_pedapp_test.go | 59 +++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-before-position-reads-as-free-note.json create mode 100644 mdl/executor/flow_splice_note_order_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-before-position-reads-as-free-note.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-before-position-reads-as-free-note.json new file mode 100644 index 0000000000..38c46d74a1 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-before-position-reads-as-free-note.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-22","symptom":"Item 2 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399): with a stored note on the first `log`, restating the flow with `@annotation(text: 'hello note', position: (200, 100), size: (150, 60))` written before `@position(300, 200)` is refused: 'the free annotations change (note \"hello note\" is not in the stored flow); the splice edits activities only'. With @position first, check passes","cause":"Not an ordering bug: extractMicroflowAnnotations reads an @annotation before any activity annotation (@position, @caption, @color, @anchor, @excluded) as a FREE note, because describe prints free notes there (prependFreeAnnotationLines). The refusal was the defect — it claimed the note was not in the stored flow when it was, attached to the statement it was written above","file":"mdl/executor/cmd_flow_modify.go (diffFlowBody, misplacedNote), .claude/skills/mendix/write-microflows/SKILL.md","fix":"When an added free note's text is a note the stored flow attaches to an activity, the refusal says the note is written before @position, which makes it a free note, names the activity it is attached to, and says to write it after @position. The skill states the ordering rule","test":"mdl/executor/flow_splice_note_order_pedapp_test.go TestCreateOrModify_NoteBeforePositionNamesTheOrder (check and exec both name the order; control: a genuinely new free note keeps the plain refusal without the hint). Before the fix the test failed with the reported 'is not in the stored flow'","insight":"Before changing parse semantics to fix a 'wrong reading', check what describe prints: the order-dependent reading IS describe's syntax for free notes, so changing it would break every describe→exec round trip of a flow with a free note. The cheap, correct fix is a refusal that resolves the ambiguity against the stored flow and names the spelling that works","refs":["mendixlabs/mxcli#1517","ako/mxcli#1399"]} diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index af58d4d850..176f68dcaa 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -26,7 +26,7 @@ description: "Microflow syntax reference in MDL — every activity type, control | 577-593 | Validation Checklist | | 594-605 | Tips for Success | | 606-612 | Related Documentation | -| 613-727 | Quick Reference | +| 613-732 | Quick Reference | # Mendix Microflow Skill @@ -673,6 +673,11 @@ and the note goes 100px above the activity at 200×50, stacking 60px per extra note; DESCRIBE omits them again whenever they match, so an ordinary note keeps the short `@annotation 'text'` form. +**Order matters: an `@annotation` written *before* a statement's `@position` +(or `@caption`, `@color`, `@anchor`, `@excluded`) is a free note** — on the +canvas, attached to nothing — which is where DESCRIBE prints one. Write a note +that belongs to the activity *after* those annotations, as DESCRIBE does. + ### Page Navigation Pattern ```mdl show page Module.Page(Param = $value); diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index 3da296f0b4..000bc4211f 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -751,6 +751,9 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement declared, declaredFree := withoutFreeNotes(declared) stored, storedFree := withoutFreeNotes(stored) if !declaredMatches(declaredFree, storedFree) { + if hint := misplacedNote(declaredFree, storedFree, stored); hint != "" { + return nil, nil, nil, cannotSplice("%s", hint) + } return nil, nil, nil, cannotSplice("the free annotations change (%s); the splice edits activities only", freeNoteChange(declaredFree, storedFree)) } @@ -1423,6 +1426,54 @@ func freeNoteChange(declared, stored []ast.MicroflowAnnotation) string { // withoutFreeNotes returns the statements with their free annotations taken // off (as copies; the script's own statements are not modified, since the // rebuild may still need them), and the free annotations in order. +// misplacedNote explains a free note the script adds that is a note the +// stored flow attaches to an activity: an @annotation written before a +// statement's @position (or @caption, @color, @anchor, @excluded) is a free +// note, attached to nothing, because that is where describe prints one — so a +// note restated above its activity's @position reads as a new note on the +// canvas and the activity's own note as taken off (mendixlabs/mxcli#1517). +// stored is the stored body with its free notes still on it; "" when no added +// free note is attached in the stored flow. +func misplacedNote(declared, stored []ast.MicroflowAnnotation, storedBody []ast.MicroflowStatement) string { + isFree := map[string]bool{} + for _, n := range stored { + isFree[n.Text] = true + } + attached := map[string]ast.MicroflowStatement{} + var walk func([]ast.MicroflowStatement) + walk = func(stmts []ast.MicroflowStatement) { + for _, st := range stmts { + if ann := statementAnnotations(st); ann != nil { + for _, n := range ann.Notes { + if _, seen := attached[n.Text]; !seen && n.Text != "" { + attached[n.Text] = st + } + } + } + if ifs, ok := st.(*ast.IfStmt); ok { + walk(ifs.ThenBody) + walk(ifs.ElseBody) + } + if eh := errorHandling(st); eh != nil { + walk(eh.Body) + } + walk(loopStatements(st)) + } + } + walk(storedBody) + for _, n := range declared { + if isFree[n.Text] { + continue + } + if st, ok := attached[n.Text]; ok { + return fmt.Sprintf("note %s is written before @position on its statement, which makes it a free note on the "+ + "canvas, attached to nothing (where describe prints one); the stored note is attached to the %s — to keep "+ + "it attached, write it after @position", strconv.Quote(n.Text), describeAt(st)) + } + } + return "" +} + func withoutFreeNotes(stmts []ast.MicroflowStatement) ([]ast.MicroflowStatement, []ast.MicroflowAnnotation) { var free []ast.MicroflowAnnotation out := make([]ast.MicroflowStatement, len(stmts)) diff --git a/mdl/executor/flow_splice_note_order_pedapp_test.go b/mdl/executor/flow_splice_note_order_pedapp_test.go new file mode 100644 index 0000000000..4104240561 --- /dev/null +++ b/mdl/executor/flow_splice_note_order_pedapp_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// Item 2 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399): +// with a stored note on the first `log`, restating the flow with +// `@annotation(text: 'hello note', …)` written before `@position(300, 200)` +// was refused with +// +// the free annotations change (note "hello note" is not in the stored +// flow); the splice edits activities only. +// +// An @annotation before a statement's @position is a free note — attached to +// nothing — which is where describe prints one, so the order is the syntax and +// cannot change. What was wrong is that the refusal did not say so: the note +// IS in the stored flow, attached to the statement it is written above. The +// refusal now names the ordering and how to keep the note attached. + +const noteOrderFlow = `mdl 1; +create or modify microflow MyFirstModule.NoteOrder () +begin + @position(300, 200) + @annotation(text: 'hello note', position: (200, 100), size: (150, 60)) + log info node 'X' 'a'; + log info node 'X' 'b'; +end;` + +func TestCreateOrModify_NoteBeforePositionNamesTheOrder(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, noteOrderFlow); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + reordered := strings.Replace(noteOrderFlow, + " @position(300, 200)\n @annotation(text: 'hello note', position: (200, 100), size: (150, 60))\n", + " @annotation(text: 'hello note', position: (200, 100), size: (150, 60))\n @position(300, 200)\n", 1) + const want = "write it after @position" + checkSays(t, exec, reordered, want) + err := agreeExec(t, exec, reordered) + if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "before @position") { + t.Fatalf("want the refusal to name the note's order, got %v", err) + } + + // Control: a note that is new to the flow gets the plain refusal, with no + // claim about an attached note it would be. + added := strings.Replace(noteOrderFlow, " @position(300, 200)\n", + " @annotation(text: 'brand new', position: (100, 50), size: (150, 60))\n @position(300, 200)\n", 1) + err = agreeExec(t, exec, added) + if err == nil || !strings.Contains(err.Error(), `note "brand new" is not in the stored flow`) { + t.Fatalf("want the free-note refusal, got %v", err) + } + if strings.Contains(err.Error(), want) { + t.Errorf("a genuinely new free note is reported as a misplaced attached one: %v", err) + } +} From 7758de08fe1d0f52164eeb34cd18594cac3bff31 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:10:26 +0000 Subject: [PATCH 04/50] fix: keep the notes a statement states on an activity with an error handler (mendixlabs/mxcli#1517) The in-place replace of an activity with a custom error handler kept only the activity from the fragment, so a note added to it was silently not written, and a reworded note was refused. The activity's own notes now come with it, drawn relative to the stored position, and stored notes are replaced. Refs ako/mxcli#1399, ako/mxcli#1345. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- ...ndled-activity-replace-drops-new-note.json | 1 + mdl/backend/mfmutator/splice.go | 61 ++++++++++++++++++- mdl/executor/flow_splice_error_handler.go | 29 +++++++-- .../flow_splice_error_handler_pedapp_test.go | 39 +++++++++--- 4 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-22-handled-activity-replace-drops-new-note.json diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-handled-activity-replace-drops-new-note.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-handled-activity-replace-drops-new-note.json new file mode 100644 index 0000000000..7642a36c66 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-handled-activity-replace-drops-new-note.json @@ -0,0 +1 @@ +{"area":"mdl/backend","date":"2026-10-22","symptom":"After the mendixlabs/mxcli#1489 splice (activity with a custom error handler swapped in place), `create or modify` adding `@annotation 'calls the sub'` to such a call reported 'Modified … (spliced)' but the note was not written; rewording the call's note was refused","cause":"keepStoredHandler stripped the replacement fragment to its entry activity alone so the stored handler would be kept — which also stripped the note objects and lines the builder drew for the statement's own @annotation. handlerEdit refused replaceNotes outright for the same reason","file":"mdl/executor/flow_splice_error_handler.go (keepStoredHandler, handlerEdit), mdl/backend/mfmutator/splice.go (replaceKeepingHandler, withoutNotes)","fix":"keepStoredHandler keeps the entry plus the notes linked to it and their lines; replaceKeepingHandler splits them off (withoutNotes), swaps the activity in place, translates unpinned notes by the activity's move from builder to stored position, and appends them. handlerEdit passes ReplaceNotes, so the stored notes are removed first and the stated ones written","test":"mdl/executor/flow_splice_error_handler_pedapp_test.go subtests 'note added to the call' (1 replaced + 1 added, describe shows it, re-run Unchanged) and 'note on the call reworded'. With the two source files reverted both fail: describe lacks the note","insight":"A fragment trimmed to 'just the activity' must say what else the statement owns: notes are separate nodes with their own lines, so any fragment surgery that keeps one object drops them silently while the run reports success. The canon write elision then hid it further — the swapped activity equal to the stored one carried the stored $ID, so the run looked like nothing happened rather than like a loss","refs":["mendixlabs/mxcli#1489","mendixlabs/mxcli#1517","ako/mxcli#1399"]} diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index 727cf78eea..f914c6699a 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -511,6 +511,9 @@ func (m *Mutator) replaceInLoop(g *graph, x *node, frag *backend.MicroflowFragme // to a custom handler of the same kind — would leave the stored handler with // no activity, and is refused. func (m *Mutator) replaceKeepingHandler(g *graph, x *node, frag *backend.MicroflowFragment) error { + // The notes the replacement states come with it (a create or modify + // states every activity's notes; the stored ones were removed first). + frag, notes := withoutNotes(frag) if !singleActivity(frag) { return fmt.Errorf("cannot replace %s: it has an error handler, which the splice keeps only when the activity "+ "is replaced by one activity handling errors the same way (the handler's own activities are changed by "+ @@ -537,7 +540,63 @@ func (m *Mutator) replaceKeepingHandler(g *graph, x *node, frag *backend.Microfl return fmt.Errorf("cannot replace %s by a %s: it has an error handler, which the splice keeps only on an "+ "activity", describeNode(x), t) } - return m.swapInPlace(g, x, dDoc(m.doc, "ObjectCollection"), d, frag.Entry) + if err := m.swapInPlace(g, x, dDoc(m.doc, "ObjectCollection"), d, frag.Entry); err != nil { + return err + } + if notes == nil { + return nil + } + // A note drawn relative to the activity moves with it, from where the + // builder drew the activity to where the stored one is; one whose canvas + // position the script states stays there (mendixlabs/mxcli#1598). + for _, n := range notes.frag.Objects { + if !notes.frag.Pinned[n.GetID()] { + p := n.GetPosition() + n.SetPosition(model.Point{X: p.X + x.pos.X - notes.entryAt.X, Y: p.Y + x.pos.Y - notes.entryAt.Y}) + } + } + return m.addFragment(notes.frag) +} + +// fragmentNotes are the notes a one-activity fragment attaches to its +// activity: the note objects and their lines, as a fragment of their own. +type fragmentNotes struct { + frag *backend.MicroflowFragment + // entryAt is where the builder drew the activity the notes are placed + // relative to. + entryAt model.Point +} + +// withoutNotes splits frag into the fragment without its notes and the notes, +// when every object but the entry is a note and every note line runs between +// a note and the entry. Any other fragment is returned whole, with no notes. +func withoutNotes(frag *backend.MicroflowFragment) (*backend.MicroflowFragment, *fragmentNotes) { + if frag == nil || len(frag.AnnotationFlows) == 0 { + return frag, nil + } + rest := *frag + rest.Objects, rest.AnnotationFlows = nil, nil + notes := &backend.MicroflowFragment{Pinned: frag.Pinned} + var entryAt model.Point + isNote := map[model.ID]bool{} + for _, obj := range frag.Objects { + if _, ok := obj.(*microflows.Annotation); ok { + notes.Objects = append(notes.Objects, obj) + isNote[obj.GetID()] = true + continue + } + if obj.GetID() == frag.Entry { + entryAt = obj.GetPosition() + } + rest.Objects = append(rest.Objects, obj) + } + for _, af := range frag.AnnotationFlows { + if !(isNote[af.OriginID] && af.DestinationID == frag.Entry) && !(isNote[af.DestinationID] && af.OriginID == frag.Entry) { + return frag, nil + } + notes.AnnotationFlows = append(notes.AnnotationFlows, af) + } + return &rest, &fragmentNotes{frag: notes, entryAt: entryAt} } // singleActivity reports whether frag is one object and nothing else: no diff --git a/mdl/executor/flow_splice_error_handler.go b/mdl/executor/flow_splice_error_handler.go index b76bd30d40..2226bdd0a8 100644 --- a/mdl/executor/flow_splice_error_handler.go +++ b/mdl/executor/flow_splice_error_handler.go @@ -93,12 +93,9 @@ func (pd *patchDiff) handlerEdit(declared, stored ast.MicroflowStatement) error if err != nil { return err } - if replaceNotes { - return cannotSplice("the notes on the %s change with the activity; the splice keeps an error handler "+ - "only on an activity replaced by itself, so change the notes and the activity in two runs", describeAt(stored)) - } pd.add(ast.AlterFlowReplace, c, body) pd.ops[len(pd.ops)-1].KeepHandler = true + pd.ops[len(pd.ops)-1].ReplaceNotes = replaceNotes } } return pd.statements(errorHandling(declared).Body, sBody) @@ -129,12 +126,32 @@ func (a *alterFlowContext) keepStoredHandler(ctx *ExecContext, op *ast.AlterFlow a.stmt.Kind(), a.stmt.Name) } } + // The activity and the notes the statement attaches to it; the handler's + // objects and flows are the stored ones'. + kept := &backend.MicroflowFragment{Entry: frag.Entry, Exit: frag.Entry, Pinned: frag.Pinned} + notes := map[model.ID]bool{} + for _, af := range frag.AnnotationFlows { + switch frag.Entry { + case af.DestinationID: + notes[af.OriginID] = true + case af.OriginID: + notes[af.DestinationID] = true + default: + continue + } + kept.AnnotationFlows = append(kept.AnnotationFlows, af) + } for _, obj := range frag.Objects { if obj.GetID() == frag.Entry { - return &backend.MicroflowFragment{Objects: []microflows.MicroflowObject{obj}, Entry: frag.Entry, Exit: frag.Entry}, nil + kept.Objects = append([]microflows.MicroflowObject{obj}, kept.Objects...) + } else if _, isNote := obj.(*microflows.Annotation); isNote && notes[obj.GetID()] { + kept.Objects = append(kept.Objects, obj) } } - return nil, fmt.Errorf("the replacement's activity is not in its fragment") + if len(kept.Objects) == 0 || kept.Objects[0].GetID() != frag.Entry { + return nil, fmt.Errorf("the replacement's activity is not in its fragment") + } + return kept, nil } // storedStatementAt returns the stored statement drawn as target: the one diff --git a/mdl/executor/flow_splice_error_handler_pedapp_test.go b/mdl/executor/flow_splice_error_handler_pedapp_test.go index ae81c1d53f..794668a5ae 100644 --- a/mdl/executor/flow_splice_error_handler_pedapp_test.go +++ b/mdl/executor/flow_splice_error_handler_pedapp_test.go @@ -70,9 +70,10 @@ func handlerObjects(t *testing.T, exec *Executor, name string) (map[model.ID]mod } // spliceStep runs src — check first, then exec — and asserts it is spliced -// with exactly `changed` objects replaced and nothing else rebuilt or moved, -// that describe shows want, and that a second run is Unchanged. -func spliceStep(t *testing.T, exec *Executor, out *bytes.Buffer, name, src string, changed int, want ...string) { +// with exactly `changed` objects replaced and `added` new ones, nothing else +// rebuilt or moved, that describe shows want, and that a second run is +// Unchanged. +func spliceStep(t *testing.T, exec *Executor, out *bytes.Buffer, name, src string, changed, added int, want ...string) { t.Helper() before, _ := handlerObjects(t, exec, name) prog, errs := visitor.Build(src) @@ -101,8 +102,8 @@ func spliceStep(t *testing.T, exec *Executor, out *bytes.Buffer, name, src strin t.Errorf("object %s moved from %v to %v", id, p, q) } } - if gone != changed || len(after) != len(before) { - t.Errorf("%d objects replaced (%d -> %d objects), want %d replaced in place", gone, len(before), len(after), changed) + if gone != changed || len(after) != len(before)+added { + t.Errorf("%d objects replaced (%d -> %d objects), want %d replaced and %d added", gone, len(before), len(after), changed, added) } desc := describeFlow(t, exec, name) for _, w := range want { @@ -134,7 +135,7 @@ func TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace(t *testing.T) { t.Run("T1 call argument changed, handler unchanged", func(t *testing.T) { _, call := handlerObjects(t, exec, "HFlow") - spliceStep(t, exec, out, "HFlow", flow(strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1)), 1, + spliceStep(t, exec, out, "HFlow", flow(strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1)), 1, 0, "HSub(P = 'x') on error without rollback begin", "log warning node 'Repro' 'failed';") if _, now := handlerObjects(t, exec, "HFlow"); now == call { t.Error("the call kept its $ID: the change was not written") @@ -144,7 +145,7 @@ func TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace(t *testing.T) { _, call := handlerObjects(t, exec, "HFlow") src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1) src = strings.Replace(src, "'failed';", "'failed again';", 1) - spliceStep(t, exec, out, "HFlow", flow(src), 1, "'failed again'", "HSub(P = 'x') on error") + spliceStep(t, exec, out, "HFlow", flow(src), 1, 0, "'failed again'", "HSub(P = 'x') on error") if _, now := handlerObjects(t, exec, "HFlow"); now != call { t.Error("the call was replaced for a change inside its handler") } @@ -152,10 +153,30 @@ func TestCreateOrModify_ActivityWithErrorHandlerIsSplicedInPlace(t *testing.T) { t.Run("call and handler changed together", func(t *testing.T) { src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'y') on", 1) src = strings.Replace(src, "'failed';", "'failed twice';", 1) - spliceStep(t, exec, out, "HFlow", flow(src), 2, "HSub(P = 'y') on error", "'failed twice'") + spliceStep(t, exec, out, "HFlow", flow(src), 2, 0, "HSub(P = 'y') on error", "'failed twice'") + }) + // A note on the activity is part of it: added or reworded with the + // activity unchanged otherwise, it is written, not dropped with the + // handler's objects the fragment leaves out. + t.Run("note added to the call", func(t *testing.T) { + src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'y') on", 1) + src = strings.Replace(src, "'failed';", "'failed twice';", 1) + src = strings.Replace(src, " call microflow MyFirstModule.HSub(", " @annotation 'calls the sub'\n call microflow MyFirstModule.HSub(", 1) + spliceStep(t, exec, out, "HFlow", flow(src), 1, 1, "@annotation 'calls the sub'", "HSub(P = 'y') on error") + }) + t.Run("note on the call reworded", func(t *testing.T) { + src := strings.Replace(handlerFlowSrc, "HSub(P = $P) on", "HSub(P = 'y') on", 1) + src = strings.Replace(src, "'failed';", "'failed twice';", 1) + src = strings.Replace(src, " call microflow MyFirstModule.HSub(", " @annotation 'calls the sub, once'\n call microflow MyFirstModule.HSub(", 1) + // The stored note goes out with the activity, the reworded one comes + // in: two objects replaced. + spliceStep(t, exec, out, "HFlow", flow(src), 2, 0, "@annotation 'calls the sub, once'") + if desc := describeFlow(t, exec, "HFlow"); strings.Contains(desc, "'calls the sub'") { + t.Errorf("the old note is still there:\n%s", desc) + } }) t.Run("T4 handler that returns", func(t *testing.T) { - spliceStep(t, exec, out, "HRet", strings.Replace(handlerRetSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1), 1, + spliceStep(t, exec, out, "HRet", strings.Replace(handlerRetSrc, "HSub(P = $P) on", "HSub(P = 'x') on", 1), 1, 0, "HSub(P = 'x') on error without rollback begin") }) From 6507dadd725fa74e71d70e3b65be429023d8856c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:17:51 +0000 Subject: [PATCH 05/50] fix: git note --write loses notes when stdout's reader exits early `mxcli git note -p App.mpr --write | head -1` wrote no note and said nothing (mendixlabs/mxcli#1490, ako/mxcli#1346). annotateCommits printed each note before writing it, and a Go program writing to a stdout whose reader has gone is ended by SIGPIPE, so the first print to the closed pipe stopped the write for that commit and every later one. With --write, every note is now written before anything is printed; the report is held and flushed afterwards. A preview has no side effect and still streams. Writing each note just before its own print (the issue's first suggestion) is not enough: with two commits the second print still kills the process before the second write. The test covers two commits for that reason. Controls, TestGitNote_WriteSurvivesAClosedStdout (child runs the real main() with stdout a pipe whose read end is closed; the child ending on SIGPIPE is asserted in every arm): original order: HEAD~1 lost, HEAD lost write-then-print per commit: HEAD~1 written, HEAD lost this fix: both written Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYmgi2rk8ZbVZ9uEGxR3Nq --- ...-git-note-write-lost-to-closed-stdout.json | 1 + cmd/mxcli/cmd_git.go | 26 ++++- cmd/mxcli/gitnote_sigpipe_test.go | 95 +++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-git-note-write-lost-to-closed-stdout.json create mode 100644 cmd/mxcli/gitnote_sigpipe_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-git-note-write-lost-to-closed-stdout.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-git-note-write-lost-to-closed-stdout.json new file mode 100644 index 0000000000..a877f3b325 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-git-note-write-lost-to-closed-stdout.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-22", "symptom": "`mxcli git note -p App.mpr --write | head -1` writes no mx_metadata note and says nothing: the pipeline exits with head's status 0 (141 under pipefail), `git notes --ref=mx_metadata show HEAD` reports no note; `| tail -1` and an unpiped run both write it", "cause": "`annotateCommits` printed each commit's note (one JSON line, ~660 KB on a whole-project commit) before writing it. A Go program writing to a stdout whose reader has gone is killed by SIGPIPE, so the first print to the closed pipe ended the process ahead of `writeNote` — for that commit and every later one", "file": "`cmd/mxcli/cmd_git.go` (`annotateCommits`)", "insight": "Any print that precedes a side effect is a kill point once stdout is a pipe: Go dies on EPIPE to fd 1 unless SIGPIPE is notified or ignored, so ignoring Fprintf's error changes nothing. Do every write first and report afterwards; a preview with no side effect can stream. The issue's own suggestion (write then print, per commit) is not enough with two commits — the second print still kills before the second write, and a two-arm control showed exactly that (HEAD~1 written, HEAD lost). That same arm explains the upstream \"verified fixed on main\" comment: it reported exit 141 on both builds and 1 note for a repository two commits ahead, which is the race landing differently, not a fix — `annotateCommits` was unchanged on that commit. Test with a pipe whose read end is closed before the child starts: `| head -1` with the race taken out, so a small fixture note is enough; assert the child ended on SIGPIPE as the control that the scenario fired", "test": "`cmd/mxcli/gitnote_sigpipe_test.go` (`TestGitNote_WriteSurvivesAClosedStdout`, real main() in a child via TestRunMainHelper)", "refs": ["mendixlabs/mxcli#1490", "#1346"]} diff --git a/cmd/mxcli/cmd_git.go b/cmd/mxcli/cmd_git.go index 1fb66a876b..8393b54045 100644 --- a/cmd/mxcli/cmd_git.go +++ b/cmd/mxcli/cmd_git.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "fmt" "io" "strings" @@ -124,26 +125,43 @@ func gitNoteCommits(root string, args []string) ([]string, error) { } func annotateCommits(out io.Writer, repo *gitNoteRepo, commits []string, branch string, write, force bool) error { + // With --write, every note is written before anything is printed. A print to + // a stdout whose reader has gone (`| head -1`) ends the process with SIGPIPE, + // so a print ahead of a write is where the write silently stops — for that + // commit and every one after it (mendixlabs/mxcli#1490). A preview has + // nothing to lose, so it streams. + report := out + var held bytes.Buffer + if write { + report = &held + } wrote := 0 + var failed error for _, c := range commits { subject, _ := gitOut(repo.Root, "log", "-1", "--format=%s", c) label := fmt.Sprintf("%s %s", c[:min(len(c), 10)], strings.TrimSpace(subject)) if existing := repo.existingNote(c); !force && !isPlaceholderNote(existing) { - fmt.Fprintf(out, "%s\n kept: already has a Studio Pro note (--force replaces it)\n", label) + fmt.Fprintf(report, "%s\n kept: already has a Studio Pro note (--force replaces it)\n", label) continue } note, err := repo.buildNote(c, branch) if err != nil { - return fmt.Errorf("%s: %w", label, err) + failed = fmt.Errorf("%s: %w", label, err) + break } text := note.marshal() - fmt.Fprintf(out, "%s\n %d model change(s)\n %s\n", label, len(note.ModelChanges), text) if write { if err := repo.writeNote(c, text); err != nil { - return fmt.Errorf("%s: write note: %w", label, err) + failed = fmt.Errorf("%s: write note: %w", label, err) + break } wrote++ } + fmt.Fprintf(report, "%s\n %d model change(s)\n %s\n", label, len(note.ModelChanges), text) + } + _, _ = out.Write(held.Bytes()) + if failed != nil { + return failed } switch { case write && wrote > 0: diff --git a/cmd/mxcli/gitnote_sigpipe_test.go b/cmd/mxcli/gitnote_sigpipe_test.go new file mode 100644 index 0000000000..f3d369550c --- /dev/null +++ b/cmd/mxcli/gitnote_sigpipe_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build unix + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// mendixlabs/mxcli#1490: `git note --write | head -1` wrote no note and said +// nothing. A Go program that writes to a stdout whose reader has gone is ended by +// SIGPIPE, and the command printed each note before writing it, so the first +// print was where the write the user asked for stopped. The pipeline reported +// head's status, 0. +// +// The reader here is closed before mxcli starts: `| head -1` with the race taken +// out, since head only loses it when a note is longer than the pipe buffer. Two +// commits, because writing each note just before its own print still loses +// every note after the first. +func TestGitNote_WriteSurvivesAClosedStdout(t *testing.T) { + if testing.Short() { + t.Skip("spawns mxcli against a git repository") + } + requireGit(t) + dir := t.TempDir() + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(dir)) + t.Setenv("GIT_AUTHOR_NAME", "t") + t.Setenv("GIT_AUTHOR_EMAIL", "t@example.com") + t.Setenv("GIT_COMMITTER_NAME", "t") + t.Setenv("GIT_COMMITTER_EMAIL", "t@example.com") + gitIn(t, dir, "init", "-q", "-b", "feature") + mprPath := filepath.Join(dir, "App.mpr") + + units := baseUnits(t) + writeProject(t, dir, "11.13.0", units) + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "base") + for _, title := range []string{"Welcome", "Hello"} { + units[tPage] = tUnit{tFolder, unitDoc(t, "Forms$Page", "Home", bson.E{Key: "Title", Value: title})} + writeProject(t, dir, "11.13.0", units) + gitIn(t, dir, "commit", "-q", "-am", "retitle home: "+title) + } + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + r.Close() + defer w.Close() + + enc, _ := json.Marshal([]string{"git", "note", "-p", mprPath, "HEAD~2..HEAD", "--write"}) + cmd := exec.Command(os.Args[0], "-test.run=^TestRunMainHelper$") + cmd.Dir = dir + cmd.Env = append(os.Environ(), runMainEnv+"="+string(enc), "MXCLI_LOG_DIR="+t.TempDir(), "MXCLI_QUIET=1") + cmd.Stdin = strings.NewReader("") + var stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = w, &stderr + runErr := cmd.Run() + + // The control: the pipe has to break, or this proves nothing about #1490. + // It still does after the fix, on the first print — after the writes. + var ee *exec.ExitError + if !errors.As(runErr, &ee) { + t.Fatalf("mxcli must hit the closed stdout (exit 141, as reported); got %v\nstderr:\n%s", runErr, stderr.String()) + } + if ws, ok := ee.Sys().(syscall.WaitStatus); !ok || !ws.Signaled() || ws.Signal() != syscall.SIGPIPE { + t.Fatalf("mxcli must end on SIGPIPE, as reported; got %v\nstderr:\n%s", runErr, stderr.String()) + } + + for _, rev := range []string{"HEAD~1", "HEAD"} { + if note := gitNoteOrEmpty(dir, rev); note == "" { + t.Errorf("%s: no mx_metadata note — `git note --write` lost it to a closed stdout", rev) + } + } +} + +// gitNoteOrEmpty is rev's mx_metadata note, or "" when it has none. +func gitNoteOrEmpty(dir, rev string) string { + out, err := exec.Command("git", "-C", dir, "notes", "--ref="+mxMetadataRef, "show", rev).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} From 70fd7a046667f002de8000a0cfeb72cac888ee36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:19:44 +0000 Subject: [PATCH 06/50] fix: splice a note added to or reworded on a decision or an end event (mendixlabs/mxcli#1517) Under mdl 1, a note-only change before an if was refused as a change of where its paths end, and one before the final return as an annotation change. A decision and an end event are changed in place, never replaced, and the mutator had no way to attach a note to a node that stays. A new AddNotes op does, and the diff sets their notes in place like their condition or value. Refs ako/mxcli#1399. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- ...note-on-decision-or-end-event-refused.json | 1 + ...notes-on-decision-and-end-event-splice.mdl | 29 ++++ mdl/ast/ast_alter_flow.go | 6 + mdl/backend/mcp/microflow_mutator.go | 5 + mdl/backend/mfmutator/splice.go | 32 ++++ .../mfmutator/splice_notes_add_test.go | 105 ++++++++++++ mdl/backend/mfmutator/splice_test.go | 5 +- mdl/backend/microflow_mutation.go | 7 + mdl/executor/cmd_alter_flow.go | 10 +- mdl/executor/cmd_flow_modify.go | 94 +++++++++-- mdl/executor/flow_splice_node_notes.go | 33 ++++ .../flow_splice_node_notes_pedapp_test.go | 156 ++++++++++++++++++ 12 files changed, 463 insertions(+), 20 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-on-decision-or-end-event-refused.json create mode 100644 mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl create mode 100644 mdl/backend/mfmutator/splice_notes_add_test.go create mode 100644 mdl/executor/flow_splice_node_notes.go create mode 100644 mdl/executor/flow_splice_node_notes_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-on-decision-or-end-event-refused.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-on-decision-or-end-event-refused.json new file mode 100644 index 0000000000..98ecd8cba2 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-note-on-decision-or-end-event-refused.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-22","symptom":"Item 3 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399): under `mdl 1;` a re-run `create or modify` whose only change is a note added or reworded before an `if` is refused as 'the If at (360, 200) changes where its paths end or meet', and before the final `return` as 'the annotations on the Return at (1120, 200) change; the splice changes a return's value only'. A note on a plain activity is accepted","cause":"A note change on an activity is spliced as a replace of the activity (RemoveNotes + Replace with the notes in the fragment). A decision and an end event are never replaced — only edited in place (SetCondition, SetReturnValue) — and the mutator had no op to attach a note to a node that stays, so the diff refused: the if fell through gap()'s catch-all, the return hit endEvent's annotation check","file":"mdl/backend/mfmutator/splice.go (AddNotes), mdl/backend/microflow_mutation.go (interface), mdl/backend/mcp/microflow_mutator.go (refused), mdl/executor/cmd_flow_modify.go (ifConditionChanged, conditionEdit, endEvent, nodeNotes), mdl/executor/flow_splice_node_notes.go (setNodeNotes), mdl/executor/cmd_alter_flow.go (applyTo), mdl/ast/ast_alter_flow.go (SetNotes)","fix":"New mutator op AddNotes(target, frag): frag is a stand-in object plus the notes the builder drew against it; unpinned notes are translated by the stand-in's offset from the target, lines are pointed at the target, nothing else changes. The diff treats a decision's or an end event's notes as a property set in place: a SetNotes op (RemoveNotes, then AddNotes with notes built against a stand-in `log`), alongside SetCondition / the return value. Shared notes (id:) are still refused","test":"mdl/backend/mfmutator/splice_notes_add_test.go (stored objects byte-identical, note moved with the node, pinned note kept, stand-in not written; refusal); mdl/executor/flow_splice_node_notes_pedapp_test.go TestCreateOrModify_NotesOnADecisionOrEndEvent (decision note added/reworded/taken off, end event note; node $ID kept, others not rebuilt, describe prints the short form so the note is at its default place, re-run Unchanged; control: shared note refused). Before the implementation every subtest failed with the two reported messages","insight":"Building notes against a stand-in `log` gives exactly the geometry a full build gives the real node, because defaultAnnotationGeometry clamps the target height up to an activity's — so no per-node-type placement code was needed; the describe-prints-short-form assertion is the check that this holds. The 2026-10-21 note-only-splice finding had already named the missing AddNotes op as the open item","refs":["mendixlabs/mxcli#1517","ako/mxcli#1399"]} diff --git a/mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl b/mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl new file mode 100644 index 0000000000..0e4e40e0a4 --- /dev/null +++ b/mdl-examples/bug-tests/1517-notes-on-decision-and-end-event-splice.mdl @@ -0,0 +1,29 @@ +-- ============================================================================ +-- mendixlabs/mxcli#1517 (ako/mxcli#1399), item 3 — a note on a decision or +-- on the final return cannot be added or reworded +-- ============================================================================ +-- +-- Under `mdl 1;`, re-running `create or modify` with only a note added before +-- an `if` was refused as "the If at (…) changes where its paths end or meet", +-- and one before the final `return` as "the annotations on the Return at (…) +-- change; the splice changes a return's value only". +-- +-- To reproduce: exec this file, then add `@annotation 'guard'` on the line +-- before `if` (or `@annotation 'the end'` before `return 'done';`) and exec it +-- again. The second run reports `Modified microflow: BugTest1517.F (spliced: +-- 1 replaced)`; the decision and the end event keep their $IDs and flows, and +-- the note is drawn where a full build would draw it. A third run writes +-- nothing. A note shared with another activity (`id:`) is still refused. + +mdl 1; + +create module BugTest1517; + +create or modify microflow BugTest1517.F ($L: String) returns String +begin + if $L = empty then + return 'e'; + end if; + log info node 'BugTest1517' 'plain'; + return 'done'; +end; diff --git a/mdl/ast/ast_alter_flow.go b/mdl/ast/ast_alter_flow.go index 86327d98fa..20eae660c4 100644 --- a/mdl/ast/ast_alter_flow.go +++ b/mdl/ast/ast_alter_flow.go @@ -71,4 +71,10 @@ type AlterFlowOperation struct { // handler's body by operations of its own; an `alter` replace that restates // the stored handler is recognised when it is applied. KeepHandler bool + // SetNotes, on a replace of a decision or an end event, says Body is one + // statement whose notes are the node's new notes: the stored notes are + // taken off and these attached, and the node itself stays — it is changed + // in place, never replaced (mendixlabs/mxcli#1517). Set only by + // `create or modify`. + SetNotes bool } diff --git a/mdl/backend/mcp/microflow_mutator.go b/mdl/backend/mcp/microflow_mutator.go index d8cf0b029a..126018d8f2 100644 --- a/mdl/backend/mcp/microflow_mutator.go +++ b/mdl/backend/mcp/microflow_mutator.go @@ -83,6 +83,11 @@ func (m *mcpFlowMutator) RemoveNotes(model.ID) error { return fmt.Errorf("removing notes is not supported by the MCP backend yet: Studio Pro does not roll back a removal when an update fails; run without --mcp to alter %s in the .mpr", m.qn) } +// AddNotes is refused like RemoveNotes, which a changed note always comes with. +func (m *mcpFlowMutator) AddNotes(model.ID, *backend.MicroflowFragment) error { + return fmt.Errorf("changing notes is not supported by the MCP backend yet; run without --mcp to alter %s in the .mpr", m.qn) +} + // SetReturnValue is refused like a replace: the patch Save sends carries // positions, pointers and added or removed elements only, so a changed value // would not reach Studio Pro and the edit would be lost without a word. diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index f914c6699a..67bbc9b679 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -745,6 +745,38 @@ func (m *Mutator) RemoveNotes(target model.ID) error { return nil } +// AddNotes attaches the notes frag holds to target, in place: target, its +// flows and every other element stay. frag is one stand-in object — what the +// builder drew the notes against — and the notes and their lines to it; each +// unpinned note is moved by the stand-in's offset from target, a pinned one +// stays where the script put it, and each line is pointed at target. +func (m *Mutator) AddNotes(target model.ID, frag *backend.MicroflowFragment) error { + g := m.graph() + x, err := g.node(target) + if err != nil { + return err + } + rest, notes := withoutNotes(frag) + if notes == nil || len(rest.Objects) != 1 || rest.Objects[0].GetID() != frag.Entry || len(rest.Flows) > 0 { + return fmt.Errorf("the notes for %s are not a set of notes attached to one object", describeNode(x)) + } + for _, n := range notes.frag.Objects { + if !notes.frag.Pinned[n.GetID()] { + p := n.GetPosition() + n.SetPosition(model.Point{X: p.X + x.pos.X - notes.entryAt.X, Y: p.Y + x.pos.Y - notes.entryAt.Y}) + } + } + for _, af := range notes.frag.AnnotationFlows { + if af.OriginID == frag.Entry { + af.OriginID = model.ID(x.id) + } + if af.DestinationID == frag.Entry { + af.DestinationID = model.ID(x.id) + } + } + return m.addFragment(notes.frag) +} + // Drop removes target and joins the flows that entered it to the object it // led to. Annotation lines attached to it go with it; the annotations stay. func (m *Mutator) Drop(target model.ID) error { diff --git a/mdl/backend/mfmutator/splice_notes_add_test.go b/mdl/backend/mfmutator/splice_notes_add_test.go new file mode 100644 index 0000000000..4340cdd935 --- /dev/null +++ b/mdl/backend/mfmutator/splice_notes_add_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mfmutator + +import ( + "bytes" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1517: a note added to a decision or an end event — nodes +// the splice changes in place, never replaces — was refused. AddNotes attaches +// the notes the builder drew against a stand-in to the stored node, and +// changes nothing else. + +// notesFor is a stand-in at (360, 200) with one note drawn 100px above it, and +// a second whose canvas position is stated (pinned). +func notesFor() (*backend.MicroflowFragment, model.ID, model.ID) { + frag := oneActivity() + n1, n2 := model.ID(types.GenerateID()), model.ID(types.GenerateID()) + note := func(id model.ID, x, y int) *microflows.Annotation { + return µflows.Annotation{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: id}, Position: model.Point{X: x, Y: y}, + Size: model.Size{Width: 200, Height: 50}}, Caption: "note"} + } + frag.Objects = append(frag.Objects, note(n1, 360, 100), note(n2, 40, 40)) + frag.AnnotationFlows = []*microflows.AnnotationFlow{ + {BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, OriginID: n1, DestinationID: frag.Entry}, + {BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, OriginID: n2, DestinationID: frag.Entry}, + } + frag.Pinned = map[model.ID]bool{n2: true} + return frag, n1, n2 +} + +func TestSplice_AddNotesAttachesToTheStoredNode(t *testing.T) { + m, deps := newMutator(t, unit(line(), lineFlows())) + frag, n1, n2 := notesFor() + if err := m.AddNotes(model.ID(uid("end")), frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + var got bson.D + if err := bson.Unmarshal(deps.saved, &got); err != nil { + t.Fatal(err) + } + // The stored objects and flows are untouched and come first; the notes + // and their lines are appended. + objs := arrayElements(dGet(dDoc(got, "ObjectCollection"), "Objects")) + if len(objs) != len(line())+2 { + t.Fatalf("want the two notes appended, got %d objects", len(objs)) + } + for i, want := range line() { + w, _ := bson.Marshal(want) + g, _ := bson.Marshal(objs[i]) + if !bytes.Equal(w, g) { + t.Errorf("stored object %d changed", i) + } + } + pos := map[string]string{} + for _, el := range objs[len(line()):] { + d := el.(bson.D) + pos[binaryID(dGet(d, "$ID"))] = dString(d, "RelativeMiddlePoint") + } + // The end event is at (600, 200): the stand-in's note 100px above it moves + // with it; the pinned one stays. + if pos[string(n1)] != "600;100" || pos[string(n2)] != "40;40" { + t.Errorf("note positions %v, want n1 at 600;100 and n2 at 40;40", pos) + } + if bytes.Contains(deps.saved, types.UUIDToBlob(string(frag.Entry))) { + t.Error("the stand-in, or a line to it, was written") + } + lines := 0 + for _, el := range arrayElements(dGet(got, "Flows")) { + d := el.(bson.D) + if binaryID(dGet(d, "DestinationPointer")) == uid("end") && binaryID(dGet(d, "OriginPointer")) != uid("b") { + lines++ + } + } + if lines != 2 { + t.Errorf("want two note lines into the end event, got %d", lines) + } +} + +func TestSplice_AddNotesRefusesAnActivity(t *testing.T) { + m, _ := newMutator(t, unit(line(), lineFlows())) + frag := oneActivity() + frag.Objects = append(frag.Objects, oneActivity().Objects...) + before, _ := bson.Marshal(m.doc) + err := m.AddNotes(model.ID(uid("end")), frag) + if err == nil || !strings.Contains(err.Error(), "not a set of notes") { + t.Fatalf("want a refusal, got %v", err) + } + if after, _ := bson.Marshal(m.doc); !bytes.Equal(before, after) { + t.Error("a refused AddNotes changed the document") + } +} diff --git a/mdl/backend/mfmutator/splice_test.go b/mdl/backend/mfmutator/splice_test.go index 0528d5661c..b8438f425e 100644 --- a/mdl/backend/mfmutator/splice_test.go +++ b/mdl/backend/mfmutator/splice_test.go @@ -91,8 +91,11 @@ type fakeDeps struct{ saved []byte } func (d *fakeDeps) SerializeObject(o microflows.MicroflowObject) (bson.D, error) { typ := "Microflows$ActionActivity" - if _, ok := o.(*microflows.ExclusiveMerge); ok { + switch o.(type) { + case *microflows.ExclusiveMerge: typ = "Microflows$ExclusiveMerge" + case *microflows.Annotation: + typ = "Microflows$Annotation" } p := o.GetPosition() sz := objectSize(o) diff --git a/mdl/backend/microflow_mutation.go b/mdl/backend/microflow_mutation.go index 4217ff5d09..7f411671ca 100644 --- a/mdl/backend/microflow_mutation.go +++ b/mdl/backend/microflow_mutation.go @@ -56,6 +56,13 @@ type MicroflowMutator interface { // (ako/mxcli#859: a `create or modify` states an activity's notes). A // note also attached to another object is refused. RemoveNotes(target model.ID) error + // AddNotes attaches notes to target, which stays as it is — a decision or + // an end event, whose notes change without the node being replaced + // (mendixlabs/mxcli#1517). frag holds one stand-in object, frag.Entry, and + // the notes the builder attached to it: each note is drawn where it is + // relative to the stand-in, but relative to target, and its line runs to + // target. + AddNotes(target model.ID, frag *MicroflowFragment) error // SetReturnValue sets the expression the end event target returns, in // place; "" is no value. SetReturnValue(target model.ID, value string) error diff --git a/mdl/executor/cmd_alter_flow.go b/mdl/executor/cmd_alter_flow.go index ae34838a6f..9826d254f4 100644 --- a/mdl/executor/cmd_alter_flow.go +++ b/mdl/executor/cmd_alter_flow.go @@ -106,7 +106,7 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato "but to replace one activity by one of the same kind. %s", target.Statement, loop.Statement, replaceLoopAdvice(s.Kind(), s.Name.String(), loop.Statement))) } - if loop != nil && (op.Op != ast.AlterFlowReplace || op.SetCondition || op.ReplaceNotes || len(op.Body) != 1) { + if loop != nil && (op.Op != ast.AlterFlowReplace || op.SetCondition || op.SetNotes || op.ReplaceNotes || len(op.Body) != 1) { return inLoop() } if op.ReplaceNotes { @@ -129,6 +129,14 @@ func (a *alterFlowContext) applyTo(ctx *ExecContext, mut backend.MicroflowMutato a.noteRemoved(target, nil) continue } + if op.SetNotes { + // A decision's or an end event's notes, set in place + // (mendixlabs/mxcli#1517). + if err := a.setNodeNotes(ctx, mut, op, target); err != nil { + return fail(err) + } + continue + } if op.SetCondition { // An `if` whose condition changes is the stored decision with a // new condition, set in place (ako/mxcli#888). diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index 000bc4211f..d97ca88456 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -1028,17 +1028,69 @@ func (pd *patchDiff) endEvent(declared, stored *ast.ReturnStmt) error { if _, ok := c.Object.(*microflows.EndEvent); !ok { return cannotSplice("the stored %s is not drawn as an end event", describeAt(stored)) } - shell := *declared + // The notes are set on the end event in place, like its value + // (mendixlabs/mxcli#1517); anything else about it is its geometry. + shell, sShell := *declared, *stored shell.Value = stored.Value - if !sameIgnoringLayout(&shell, stored) { - return cannotSplice("the annotations on the %s change; the splice changes a return's value only", describeAt(stored)) + shell.Annotations, sShell.Annotations = withoutNotes(declared.Annotations), withoutNotes(stored.Annotations) + if !sameIgnoringLayout(&shell, &sShell) { + return cannotSplice("the annotations on the %s change; the splice changes a return's value and notes only", describeAt(stored)) } - if !declaredMatches(&shell, stored) { - if err := pd.moved(&shell, stored); err != nil { + if !declaredMatches(&shell, &sShell) { + if err := pd.moved(&shell, &sShell); err != nil { return err } } - pd.add(ast.AlterFlowReplace, c, []ast.MicroflowStatement{&ast.ReturnStmt{Value: declared.Value}}) + if !declaredMatches(declared.Value, stored.Value) { + pd.add(ast.AlterFlowReplace, c, []ast.MicroflowStatement{&ast.ReturnStmt{Value: declared.Value}}) + } + return pd.nodeNotes(c, declared, stored) +} + +// nodeNotesChanged reports whether the declared statement states other notes +// than the stored one: one added, reworded or taken off. +func nodeNotesChanged(declared, stored ast.MicroflowStatement) bool { + return !declaredMatches(statementNotes(declared), statementNotes(stored)) +} + +// statementNotes are the notes attached to a statement. +func statementNotes(st ast.MicroflowStatement) []ast.MicroflowAnnotation { + if ann := statementAnnotations(st); ann != nil { + return ann.Notes + } + return nil +} + +// withoutNotes is a copy of ann with no notes, nil when nothing else is +// stated. +func withoutNotes(ann *ast.ActivityAnnotations) *ast.ActivityAnnotations { + if ann == nil { + return nil + } + c := *ann + c.Notes = nil + if declaredMatches(&c, &ast.ActivityAnnotations{}) { + return nil + } + return &c +} + +// nodeNotes adds the operation that gives the stored node c — a decision or +// an end event, changed in place — the declared statement's notes, when they +// differ from the stored ones. A note shared with another activity is refused: +// changing it here would change it there too. +func (pd *patchDiff) nodeNotes(c mfmutator.Candidate, declared, stored ast.MicroflowStatement) error { + if !nodeNotesChanged(declared, stored) { + return nil + } + for _, n := range append(append([]ast.MicroflowAnnotation(nil), statementNotes(stored)...), statementNotes(declared)...) { + if n.Label != "" { + return cannotSplice("the annotations on the %s change, and one of them is shared with another activity "+ + "(id: %s); the splice changes the notes of one node only", describeAt(stored), n.Label) + } + } + pd.add(ast.AlterFlowReplace, c, []ast.MicroflowStatement{declared}) + pd.ops[len(pd.ops)-1].SetNotes = true return nil } @@ -1071,7 +1123,7 @@ func ifConditionChanged(declared, stored ast.MicroflowStatement) (*ast.IfStmt, * if !ok1 || !ok2 { return nil, nil, false } - if declaredMatches(d.Condition, s.Condition) && !decisionCaptionChanged(d, s) { + if declaredMatches(d.Condition, s.Condition) && !decisionCaptionChanged(d, s) && !nodeNotesChanged(d, s) { return nil, nil, false } dShell, sShell := *d, *s @@ -1100,14 +1152,15 @@ func decisionCaption(st *ast.IfStmt) (string, bool) { return st.Annotations.Caption, true } -// withoutCaption is a copy of ann with no @caption, nil when nothing else is -// stated, so two shells that differ only in their caption compare equal. +// withoutCaption is a copy of ann with no @caption and no notes, nil when +// nothing else is stated, so two shells that differ only in those compare +// equal: both are set on the stored decision in place. func withoutCaption(ann *ast.ActivityAnnotations) *ast.ActivityAnnotations { if ann == nil { return nil } c := *ann - c.Caption, c.CaptionSet = "", false + c.Caption, c.CaptionSet, c.Notes = "", false, nil if declaredMatches(&c, &ast.ActivityAnnotations{}) { return nil } @@ -1127,15 +1180,20 @@ func (pd *patchDiff) conditionEdit(declared, stored *ast.IfStmt) error { if _, ok := c.Object.(*microflows.ExclusiveSplit); !ok { return cannotSplice("the stored %s is not drawn as a decision", describeAt(stored)) } - // A caption the script changes is stated on the operation's `if`; - // otherwise the stored caption is kept, or follows the condition. - edit := &ast.IfStmt{Condition: declared.Condition} - if decisionCaptionChanged(declared, stored) { - caption, set := decisionCaption(declared) - edit.Annotations = &ast.ActivityAnnotations{Caption: caption, CaptionSet: set} + if !declaredMatches(declared.Condition, stored.Condition) || decisionCaptionChanged(declared, stored) { + // A caption the script changes is stated on the operation's `if`; + // otherwise the stored caption is kept, or follows the condition. + edit := &ast.IfStmt{Condition: declared.Condition} + if decisionCaptionChanged(declared, stored) { + caption, set := decisionCaption(declared) + edit.Annotations = &ast.ActivityAnnotations{Caption: caption, CaptionSet: set} + } + pd.add(ast.AlterFlowReplace, c, []ast.MicroflowStatement{edit}) + pd.ops[len(pd.ops)-1].SetCondition = true + } + if err := pd.nodeNotes(c, declared, stored); err != nil { + return err } - pd.add(ast.AlterFlowReplace, c, []ast.MicroflowStatement{edit}) - pd.ops[len(pd.ops)-1].SetCondition = true if err := pd.movedNode(declared, stored); err != nil { return err } diff --git a/mdl/executor/flow_splice_node_notes.go b/mdl/executor/flow_splice_node_notes.go new file mode 100644 index 0000000000..c129b0bdc7 --- /dev/null +++ b/mdl/executor/flow_splice_node_notes.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mfmutator" +) + +// setNodeNotes gives the stored decision or end event target the notes op's +// statement states (mendixlabs/mxcli#1517): the stored notes are taken off, +// and the stated ones are built against a stand-in activity — so they are +// drawn as a full build would draw them for the node — and attached to target. +func (a *alterFlowContext) setNodeNotes(ctx *ExecContext, mut backend.MicroflowMutator, op *ast.AlterFlowOperation, target mfmutator.Candidate) error { + if err := mut.RemoveNotes(target.ID); err != nil { + return err + } + notes := statementNotes(op.Body[0]) + if len(notes) == 0 { + return nil + } + standIn := &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Value: "", Kind: ast.LiteralString}, + Annotations: &ast.ActivityAnnotations{Notes: notes}, + } + frag, err := a.buildFragment(ctx, []ast.MicroflowStatement{standIn}) + if err != nil { + return err + } + return mut.AddNotes(target.ID, frag) +} diff --git a/mdl/executor/flow_splice_node_notes_pedapp_test.go b/mdl/executor/flow_splice_node_notes_pedapp_test.go new file mode 100644 index 0000000000..921b8e703a --- /dev/null +++ b/mdl/executor/flow_splice_node_notes_pedapp_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// Item 3 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399): +// adding or rewording a note was refused when the note is on a decision — +// +// the If at (360, 200) changes where its paths end or meet … +// +// — or on the final `return` — +// +// the annotations on the Return at (1120, 200) change; the splice changes a +// return's value only. +// +// A decision and an end event are changed in place, never replaced, so their +// notes are now changed in place too: the stored notes are taken off and the +// stated ones attached, and the node keeps its $ID and its flows. + +const nodeNotesFlow = `mdl 1; +create or modify microflow MyFirstModule.NodeNotes ($L: String) returns String +begin + if $L = empty then + return 'e'; + end if; + log info node 'X' 'plain'; + return 'done'; +end;` + +// nodeNotes returns the notes attached to the node of type T in NodeNotes, +// and that node's $ID, and the IDs of every other non-note object. +func nodeNotes[T microflows.MicroflowObject](t *testing.T, exec *Executor, match func(T) bool) ([]string, model.ID, map[model.ID]bool) { + t.Helper() + mf := loopEditFlowOf(t, exec, "NodeNotes") + var target model.ID + others := map[model.ID]bool{} + captions := map[model.ID]string{} + for _, obj := range mf.ObjectCollection.Objects { + if n, ok := obj.(*microflows.Annotation); ok { + captions[n.ID] = n.Caption + continue + } + if o, ok := obj.(T); ok && match(o) { + target = obj.GetID() + continue + } + others[obj.GetID()] = true + } + var notes []string + for _, af := range mf.ObjectCollection.AnnotationFlows { + if af.DestinationID == target { + notes = append(notes, captions[af.OriginID]) + } else if af.OriginID == target { + notes = append(notes, captions[af.DestinationID]) + } + } + return notes, target, others +} + +func TestCreateOrModify_NotesOnADecisionOrEndEvent(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, nodeNotesFlow); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + isSplit := func(*microflows.ExclusiveSplit) bool { return true } + isDone := func(e *microflows.EndEvent) bool { return e.ReturnValue == "'done'" } + + step := func(t *testing.T, src string) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + for _, v := range exec.CheckFlowVerdicts(prog) { + t.Errorf("check -p predicts a refusal: %s", v.Message) + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("refused: %v", err) + } + if !strings.Contains(out.String(), "Modified microflow: MyFirstModule.NodeNotes (spliced") { + t.Errorf("want a splice:\n%s", out.String()) + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("re-run: %v", err) + } + if !strings.Contains(out.String(), "Unchanged microflow: MyFirstModule.NodeNotes") { + t.Errorf("re-run is not Unchanged:\n%s", out.String()) + } + } + withSplitNote := func(note string) string { + return strings.Replace(nodeNotesFlow, " if $L", " @annotation '"+note+"'\n if $L", 1) + } + + for _, tc := range []struct{ name, note string }{{"note added", "guard"}, {"note reworded", "guard clause"}} { + t.Run("decision: "+tc.name, func(t *testing.T) { + _, split, others := nodeNotes(t, exec, isSplit) + step(t, withSplitNote(tc.note)) + notes, now, after := nodeNotes(t, exec, isSplit) + if now != split { + t.Errorf("the decision was replaced") + } + if len(notes) != 1 || notes[0] != tc.note { + t.Errorf("decision notes %v, want [%s]", notes, tc.note) + } + // Drawn where a full build draws it, so describe needs no + // geometry for it. + if desc := describeFlow(t, exec, "NodeNotes"); !strings.Contains(desc, "@annotation '"+tc.note+"'\n") { + t.Errorf("the note is not drawn at its default place:\n%s", desc) + } + for id := range others { + if !after[id] { + t.Errorf("object %s was rebuilt", id) + } + } + }) + } + t.Run("decision: note taken off", func(t *testing.T) { + step(t, nodeNotesFlow) + if notes, _, _ := nodeNotes(t, exec, isSplit); len(notes) != 0 { + t.Errorf("decision notes %v, want none", notes) + } + }) + t.Run("end event: note added", func(t *testing.T) { + _, end, _ := nodeNotes(t, exec, isDone) + src := strings.Replace(nodeNotesFlow, " return 'done';", " @annotation 'the end'\n return 'done';", 1) + step(t, src) + notes, now, _ := nodeNotes(t, exec, isDone) + if now != end || len(notes) != 1 || notes[0] != "the end" { + t.Errorf("end event %s (was %s) notes %v, want it kept with [the end]", now, end, notes) + } + }) + + // Control: a note shared with another activity (an id) is still refused — + // changing it here would change it there too. + t.Run("control: a stored shared note is not changed", func(t *testing.T) { + shared := strings.Replace(nodeNotesFlow, " if $L", " @annotation(id: s1, text: 'shared')\n if $L", 1) + shared = strings.Replace(shared, " log info node 'X' 'plain';", " @annotation(id: s1)\n log info node 'X' 'plain';", 1) + if err := agreeExec(t, exec, "mdl 1;\ndrop microflow MyFirstModule.NodeNotes;\n"+strings.TrimPrefix(shared, "mdl 1;\n")); err != nil { + t.Fatalf("setup: %v", err) + } + reworded := strings.Replace(shared, "text: 'shared'", "text: 'shared, reworded'", 1) + if err := agreeExec(t, exec, reworded); err == nil { + t.Fatal("want a refusal for a reworded shared note") + } + }) +} From 3bbbead74292a972b6823e18dfc93a2926c3e00b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:24:58 +0000 Subject: [PATCH 07/50] fix: docker check says widgets were normalised after update-widgets failed With an mx of the project's minor but another patch, `mx update-widgets` refuses the project ("Project is in different Mendix version", exit status 4) while `mx check` accepts it (mendixlabs/mxcli#1629, ako/mxcli #1561). docker check carried on, as it should, then closed with "Note: widget definitions were normalised on a temporary copy" a few lines after "Warning: update-widgets failed (continuing): exit status 4". The note was keyed on update-widgets being requested, not on it succeeding. It now keys on the outcome. After a failure it says the check ran without normalisation, so a CE0463 may be one normalisation clears, and how to get an mx of the project's exact version. Measured on real Mendix 10.24.18 tooling (mx 10.24.18 against a v1 project relabelled 10.24.13): update-widgets exit 4, mx check 0 errors; the same project unrelabelled exits 0 on both. Before the fix the output contradicted itself; after it, the not-normalised note appears, and a matched project still gets the normalised note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYmgi2rk8ZbVZ9uEGxR3Nq --- ...ersion-floor-and-failed-normalisation.json | 1 + cmd/mxcli/docker/check.go | 14 +++++++- cmd/mxcli/docker/check_readonly_test.go | 32 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-docker-check-version-floor-and-failed-normalisation.json diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-docker-check-version-floor-and-failed-normalisation.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-docker-check-version-floor-and-failed-normalisation.json new file mode 100644 index 0000000000..eb7bd65421 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-docker-check-version-floor-and-failed-normalisation.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-22", "symptom": "`mxcli docker --help` lists \"Mendix 11.6.1 or later\" as a requirement, yet `docker check` works on a Mendix 10.24 app; with an mxbuild of the project's minor but another patch, `update-widgets` fails with exit 4 and the check carries on — then ends with \"Note: widget definitions were normalised on a temporary copy\" a few lines after \"Warning: update-widgets failed (continuing): exit status 4\"", "cause": "The 11.6.1 floor is `docker build`'s (`build.go`, Portable App Distribution) but sat in the command group's Requirements, so it read as every subcommand's; `check` has no gate. `mx update-widgets` refuses a project whose version is not exactly mx's own (\"Project is in different Mendix version - \", exit 4) while `mx check` accepts it, and `Check` printed its normalisation note whenever update-widgets was *requested*, not when it succeeded", "file": "`cmd/mxcli/docker.go` (`dockerCmd.Long`, `dockerCheckCmd.Long`), `cmd/mxcli/docker/check.go` (`Check`)", "insight": "Exit 4 is a version mismatch, not Mendix 10: measured by relabelling a real 10.24.18 v1 project's `_MetaData._ProductVersion` to 10.24.13 — mx 10.24.18's update-widgets exits 4 with that message, its mx check reports 0 errors, and an unrelabelled project exits 0 on both (v2 and v1). `mx update-widgets --help` documents only \"0 if Ok\", so the message is the only diagnosis; `--loose-version-check` would upgrade the copy instead, which changes what is checked. A mismatched mx is reached easily because `ResolveMxForVersion` falls back to PATH and any cached version. A version floor in a command *group*'s help is read as every subcommand's: put it on the subcommand that enforces it, and pin the help to the enforcing line with a test. A closing note that describes a step must key on that step's outcome, not on whether it was requested", "test": "`cmd/mxcli/docker_help_test.go` (`TestDockerHelp_VersionFloorIsBuildsNotChecks`), `cmd/mxcli/docker/check_readonly_test.go` (`TestCheck_FailedUpdateWidgetsIsNotReportedAsNormalised`)", "refs": ["mendixlabs/mxcli#1629", "#1561"]} diff --git a/cmd/mxcli/docker/check.go b/cmd/mxcli/docker/check.go index 968de23c46..cc6d50e95e 100644 --- a/cmd/mxcli/docker/check.go +++ b/cmd/mxcli/docker/check.go @@ -120,11 +120,14 @@ func Check(opts CheckOptions) error { // Normalize pluggable widget definitions so `mx check` does not report // CE0463 ("widget definition changed") for definitions that only need a // resync. On the copy, so neither the model nor its storage format changes. + normalised := false if !opts.SkipUpdateWidgets { fmt.Fprintln(w, "Normalising widget definitions on the temporary copy...") if err := updateWidgetsCmd(mxPath, workMpr, out, errOut); err != nil { out.Flush() fmt.Fprintf(w, "Warning: update-widgets failed (continuing): %v\n", err) + } else { + normalised = true } } @@ -134,13 +137,22 @@ func Check(opts CheckOptions) error { out.Flush() errOut.Flush() - if !opts.SkipUpdateWidgets { + switch { + case normalised: // #568 / #646: the normalisation can hide a CE0463 the stored project // still has, which then fails `run --local` and MxBuild. fmt.Fprintln(w, "Note: widget definitions were normalised on a temporary copy before checking, so a") fmt.Fprintln(w, " CE0463 the stored project still has is not reported here; it fails MxBuild and") fmt.Fprintln(w, " `mxcli run --local`. Use --no-update-widgets to check the project as stored, and") fmt.Fprintln(w, " `mxcli fix widgets` to apply the normalisation to the project.") + case !opts.SkipUpdateWidgets: + // mendixlabs/mxcli#1629: update-widgets refuses a project of another + // Mendix version than mx's own (exit status 4) where mx check accepts it. + fmt.Fprintln(w, "Note: widget definitions were not normalised (update-widgets failed above), so this") + fmt.Fprintln(w, " checked the project as stored, and a CE0463 may be one normalisation would clear.") + fmt.Fprintln(w, " If mx said \"Project is in different Mendix version\", update-widgets needs an mx of") + fmt.Fprintln(w, " the project's exact version, which mx check does not: `mxcli setup mxbuild -p") + fmt.Fprintln(w, " .mpr` fetches it, and --mxbuild-path ~/.mxcli/mxbuild/ selects it.") } if checkErr != nil { return fmt.Errorf("project check failed: %w", checkErr) diff --git a/cmd/mxcli/docker/check_readonly_test.go b/cmd/mxcli/docker/check_readonly_test.go index bb7d0ce91c..cb47625813 100644 --- a/cmd/mxcli/docker/check_readonly_test.go +++ b/cmd/mxcli/docker/check_readonly_test.go @@ -352,3 +352,35 @@ func TestWithProjectCopy_SkipsNamedFoldersButNeverTheProject(t *testing.T) { t.Fatal(err) } } + +// mendixlabs/mxcli#1629: with an mx of the project's minor but another patch, +// `mx update-widgets` refuses the project while `mx check` accepts it. Measured +// with 10.24.18's mx on a project at 10.24.13: update-widgets prints "Project is +// in different Mendix version - 10.24.13.86719" and exits 4; mx check reports 0 +// errors. The check carried on, as it should — and then closed with a note that +// widget definitions "were normalised on a temporary copy", a few lines after +// the warning that they were not. +func TestCheck_FailedUpdateWidgetsIsNotReportedAsNormalised(t *testing.T) { + mprPath := v1Fixture(t) + stubTools(t) + updateWidgetsCmd = func(_, _ string, w, _ io.Writer) error { + fmt.Fprintln(w, "Project is in different Mendix version - 10.24.13.86719") + return fmt.Errorf("exit status 4") + } + t.Setenv("TMPDIR", t.TempDir()) + + var out bytes.Buffer + if err := Check(CheckOptions{ProjectPath: mprPath, Stdout: &out, Stderr: io.Discard}); err != nil { + t.Fatalf("a failed update-widgets must not stop the check: %v\n%s", err, out.String()) + } + got := out.String() + if !strings.Contains(got, "The app contains: 0 errors.") { + t.Fatalf("mx check did not run after update-widgets failed:\n%s", got) + } + if strings.Contains(got, "were normalised") { + t.Errorf("output says widget definitions were normalised after update-widgets failed:\n%s", got) + } + if !strings.Contains(got, "not normalised") { + t.Errorf("output does not say the check ran without normalisation:\n%s", got) + } +} From 711ba2caf495119bf911bbb08f07ffa7d5621d2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:24:58 +0000 Subject: [PATCH 08/50] docs: scope docker's 11.6.1 floor to build; check works on Mendix 10 `mxcli docker --help` listed "Mendix 11.6.1 or later" as a requirement of the whole command group (mendixlabs/mxcli#1629, ako/mxcli#1561). The floor is docker build's: a Portable App Distribution needs 11.6.1, and Build refuses anything older; run and reload go through Build. docker check has no gate and works on Mendix 10 (measured on 10.24.18: "The app contains: 0 errors."). The group help now scopes the floor to build, run and reload. docker check --help says it has no floor, and that a failing update-widgets (exit status 4 on a version mismatch, which mx check tolerates) does not stop the check. TestDockerHelp_VersionFloorIsBuildsNotChecks pins it: any line of the group help naming 11.6.1 must name build. It failed on the old text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYmgi2rk8ZbVZ9uEGxR3Nq --- cmd/mxcli/docker.go | 13 ++++++++++++- cmd/mxcli/docker_help_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 cmd/mxcli/docker_help_test.go diff --git a/cmd/mxcli/docker.go b/cmd/mxcli/docker.go index 549ffb17ad..7ef473e08e 100644 --- a/cmd/mxcli/docker.go +++ b/cmd/mxcli/docker.go @@ -29,7 +29,8 @@ Subcommands: shell Open a shell in the Mendix container Requirements: - - Mendix 11.6.1 or later + - Mendix 11.6.1 or later for build, run and reload, which produce a Portable + App Distribution; check has no floor (Mendix 10 works, tested on 10.24) - MxBuild (auto-downloaded from CDN if not found) - A JDK matching the project's JavaVersion (21 up to Mendix 11.13, 25 from 11.14; devcontainers created by 'mxcli init' ship 21) @@ -190,6 +191,16 @@ fails MxBuild and 'mxcli run --local' on the stored project. Use --no-update-widgets to check the project as stored, and 'mxcli fix widgets' to apply the normalisation to the project. +If update-widgets fails, the check still runs, without the normalisation, and +says so. update-widgets refuses a project whose Mendix version is not exactly +mx's own ("Project is in different Mendix version", exit status 4) where mx +check accepts it, so an mx of the project's minor but another patch checks the +project and only skips the normalisation. 'mxcli setup mxbuild -p app.mpr' +fetches the project's exact version. + +There is no Mendix version floor for check: Mendix 10 works (tested on 10.24). +The 11.6.1 requirement is docker build's, for the Portable App Distribution. + The mx binary is located from the same directory as mxbuild. Examples: diff --git a/cmd/mxcli/docker_help_test.go b/cmd/mxcli/docker_help_test.go new file mode 100644 index 0000000000..b44f727ea2 --- /dev/null +++ b/cmd/mxcli/docker_help_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +// mendixlabs/mxcli#1629: `mxcli docker --help` listed "Mendix 11.6.1 or later" +// as a requirement of the whole command group. The floor is docker build's — +// a Portable App Distribution needs 11.6.1, and Build refuses anything older — +// while docker check has none: measured on a 10.24.18 project, it reports +// "The app contains: 0 errors." An agent takes the help's word for it, so a +// group-wide floor is a check it never tries. +func TestDockerHelp_VersionFloorIsBuildsNotChecks(t *testing.T) { + for _, line := range strings.Split(dockerCmd.Long, "\n") { + if strings.Contains(line, "11.6.1") && !strings.Contains(line, "build") { + t.Errorf("docker --help states the 11.6.1 floor without scoping it to build: %q", line) + } + } + for _, want := range []string{ + "Mendix 10", // check has no version floor + "exit status 4", // what a version-mismatched update-widgets looks like + } { + if !strings.Contains(dockerCheckCmd.Long, want) { + t.Errorf("docker check --help does not mention %q", want) + } + } +} From 054f48a9f46219211061dc3f667ad5214a7381ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:26:11 +0000 Subject: [PATCH 09/50] =?UTF-8?q?docs(proposal):=20a=201,771-call=20build?= =?UTF-8?q?=20resizes=20the=20document=20=E2=80=94=20tool=20output=20is=20?= =?UTF-8?q?21%,=20not=2068%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fourth project, a complete chess-game build measured with diag session-report, is the largest session recorded and it corrects this document rather than confirming it. N x S now holds across three orders of magnitude: 59 calls x 108k = 6M against 6.2M measured, 523 x 435k = 228M against 228M, 1,771 x 372k = 659M against 653.5M. Nothing else here is as well established. The unwelcome part is what that does to the levers. Tool output is 21% of this build's bill against 35% in BENCH-001 run 3, and the remaining ~79% is the conversation itself. The costliest single result is the same shape the benchmark made item 3b out of — a bootstrap skill file read early and re-read ~260 times — and it is 1.69M of 653.5M, 0.26%, where the benchmark put the same shape at 5.3%. A 59-call run is not a scale model of a 1,771-call build: it over-weights every lever aimed at what a tool returns, because in a short session tool results are most of what there is. The subsection that drew the original conclusion now carries a banner saying to read it as benchmark-local, and BENCH-002 at 2x the surface does not fix this. What it promotes is item 3c, new: one session per phase, each resumed from brain brief / brain plan. Splitting N calls across k near-empty phases is N x S / 2k, a factor of k rather than a percentage; the chess session proposed this itself and sized it at ~30%, which is the k = 2 case. Its price is one re-orientation per boundary — so 3b, 4 and 4b are not competing with it, they are what sets its price, and they are recorded as mis-sized token levers that are better justified that way. Two items resized downward: retries are 4.5% here against 13.6% in run 3, and `check` is 9% of invocations, the fourth project in a row to put it nowhere near the ledger's 83%. One shipped lever shows as adopted: `run wait` is the second most frequent command at 95 calls, where the three earlier projects hand-rolled nohup/poll/pkill loops. The wall-time attribution and the licence-session-cap diagnosis for 150 app restarts are the other session's estimates, not reproduced here, and are recorded as a lead to verify rather than as findings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ --- .../PROPOSAL_agent_loop_efficiency.md | 140 +++++++++++++++++- 1 file changed, 138 insertions(+), 2 deletions(-) diff --git a/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md b/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md index 09352d5c36..aeb70256a1 100644 --- a/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md +++ b/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md @@ -26,6 +26,12 @@ bill, with one adverse shape it appears to have induced; the comparison is n = 1 per side and is recorded as unsettled (§"Run 2, measured"); a third run makes the post-change side n = 2 and tight, and shows the skills' share of the bill halved with `syntax` sweeps taking the freed share (§"Run 3"). +Revised 2026-10-22 — a fourth project, a 1,771-call build measured end to end, +confirms `N x S` across three orders of magnitude and resizes the document: tool +output is 21% of a real build's bill against 35% in the benchmark, and the +costliest single skill read is 0.26% of it, so BENCH-001 over-weights every +lever aimed at what a tool returns. The conversation itself is ~79%, which +promotes phase splitting above all of them (§"A fourth project"). ## The report @@ -80,6 +86,11 @@ away, because the way each assumption survived is the reusable part. | mxcli-ledger | — | 5 days | 301 | `check` 250 (83%) | | CapTrackV6 | 11.14.0 | 2 h 45 | 585 | `exec` 180, `-c` 111, `check` 90 | | mxcli-demo-2 | 11.13.0 | 5 h 07 | 408 | `-c` 120, `exec` 85, `check` 63 | +| chess (2026-10-21) | — | 20 h 44 | 931 | `exec` 201, `run wait` 95, `check` 86, `docker check` 84 | + +The fourth row is `diag session-report`, not `loop-report`, and postdates +ako/mxcli#629, so its counts are not inflated by mxcli's own child processes. +It is analysed in §"A fourth project". **Overturned 1: `check` is not the dominant call.** The ledger's 83% was read as the shape of an mxcli loop, and the obvious follow-up — "why so many checks?" @@ -167,6 +178,11 @@ as approximate. ### The dominant cost is reading the skills, not running mxcli +> **Read this subsection as benchmark-local.** Its shares are true of a 59-call +> run and not of a real build: §"A fourth project" measures the same shape at +> 0.26% of a 1,771-call session's bill, because tool output is a minority input +> once a conversation is long. + The top ten results carry ~1.34 M of the 1.7 M re-read cost, and they split: | what | re-read cost | share of 1.7 M | @@ -441,6 +457,125 @@ so the verb counts cannot settle it. `diag loop-report`'s high, the answer is to **state the chain better**, which is item 3, not to wrap it in a command. +## A fourth project, 30× the benchmark — and what scale does to the shares + +A complete chess-game build, measured with `diag session-report` on its own +transcript (2026-10-21/22). It is the largest session measured, and it does not +overturn the direction of this document so much as resize almost every item in +it. + +| | | +|---|---:| +| elapsed / active | 20 h 44 / 9 h 02 | +| model calls | 1,771 (1,179 main, rest across 10 subagents) | +| tool calls | 1,868 (Bash 1,644) | +| mxcli invocations | 931: `exec` 201, `run wait` 95, `check` 86, `docker check` 84, `syntax` 77, `oql` 61 | +| cache reads | 653.5 M | +| cache writes / output / uncached in | 5.8 M / 1.2 M / 3.6 k | +| avg context per call | 372 k (main-thread median 438 k; 325 calls over 600 k) | +| tool output | 958 k, re-read cost **134 M (21% of cache reads)** | +| categories | orientation 52%, writing 17%, applying 7%, validating 7%, verifying 5%, retries 4% | +| failures / retry chains | 125 / 64, 61 resolved | +| compactions | 2 | + +### The N × S law now holds across three orders of magnitude + +| session | calls | avg context | N × S | measured | +|---|---:|---:|---:|---:| +| BENCH-001 run 3 | 59 | 108 k | 6 M | 6.2 M | +| the original report | 523 | 435 k | 228 M | 228 M | +| chess | 1,771 | 372 k | 659 M | 653.5 M | + +Three sessions, 100× apart, each within a few per cent of the product of its two +numbers. Nothing else in this document is as well established as this, and +everything below is a consequence of it. + +### The benchmark systematically overstates every lever aimed at tool output + +This is the correction that matters, and it is unwelcome. + +**Tool output is 21% of the bill here, against 35% in BENCH-001 run 3.** The +remaining ~79% is the conversation itself — the model's own messages and +reasoning, plus the system prompt and CLAUDE.md re-read on every one of 1,771 +calls. Tool results are a *minority* input at scale, and a shrinking one, because +the conversation grows with every call while a tool result is written once. + +Worse for item 3b specifically. The costliest single result in the chess session +is the same shape as BENCH-001's — a bootstrap skill file read early, ~6.5 k +tokens re-read ~260 times. In the benchmark that shape is **5.3% of all cache +reads** and the obvious thing to attack. Here the same shape is **1.69 M of +653.5 M — 0.26%.** Not 0.26% of the top ten; 0.26% of the bill. + +So a 59-call benchmark is not a scale model of a 1,771-call build. It +over-weights exactly the levers this document has spent its last three +revisions on, because in a short session tool results are most of what there is. +That does not make BENCH-001 wrong — the runs measured what they measured — it +makes it the wrong instrument for *sizing* 3b, 4 and 4b, and the numbers in +§"The dominant cost is reading the skills" should be read as benchmark-local +from here on. BENCH-002 at 2× the surface does not fix this; it would take a +brief two orders of magnitude larger, which is a different instrument, not a +bigger brief. + +### What that promotes + +If 79% of the bill is the conversation, then the lever is **not having a 440 k +conversation**, and there is only one way to get one: don't let a single session +carry every phase. + +Splitting a session of N calls across k phases that each start near-empty gives +`k × (N/k) × (S/k)/2 = N × S / 2k` — a **factor-of-k cut**, not a percentage. +The chess session's own write-up proposed this and sized it at ~30%, but that +estimate assumed only that average context fell from ~440 k to 150–250 k, i.e. +k ≈ 2. Four phases is a 4× cut on the same arithmetic. + +Two things keep that from being free, and both are already in this table: + +1. **Every phase boundary pays a re-orientation.** That is exactly what + `brain brief` and `brain plan` are for, and the chess session says it had + them and did not use them this way. The cost of splitting is the cost of + orientation — so items 3b, 4 and 4b are not *competing* with phase splitting, + they are what sets its price. They were mis-sized as token levers; they are + better justified as the thing that makes the big lever cheap. +2. **Orientation is 52% of calls**, up from 37% in run 3. Calls are N, the term + that enters quadratically, so cutting orientation *round-trips* (item 3, item + 4b) reduces the bill in a way that cutting orientation *bytes* does not. + +### Two items resized downward, honestly + +- **Retries are smaller at scale than the benchmark says.** 84 retry calls of + 1,868 is 4.5%, against 13.6% in run 3. Item 4c stays worth doing — each failure + is also text re-read forever — but it is not the 14% lever BENCH-001 implied. +- **`check` is not dominant, for the fourth time.** 86 of 931 invocations, 9%. + The ledger's 83% remains the outlier. + +### One shipped lever shows up as adopted + +`run wait` is the second most frequent mxcli command in the session, 95 calls. +That is item 2e working as intended: those were hand-rolled `nohup`/poll/`pkill` +loops in the three earlier projects. + +### Wall time, and a named root cause this document did not have + +Of ~9 active hours, roughly 6 were spent waiting on commands, not on the model: +app restarts and waits ≥74 min (150 restarts, 189 waits), browser end-to-end +suites ~70 min (101 runs), `docker check` ~25–35 min (84 runs), microflow tests +~34 min (35 runs), widget fixes and marketplace installs ~12 min. + +The chess session attributes most of the restarts to **the development licence's +session cap** — "Sign in failed" until a restart — and to watch-reload glitches. +If that holds, items 2c/2d/2e have been measuring and commanding a restart whose +*cause* is a licence limit, not an mxcli defect. That attribution is the other +session's, not reproduced here, so it is recorded as a lead to verify rather +than as a finding. + +### Caveats on this whole section + +The figures are one session's `diag session-report` plus its own transcript +split, and the time attributions and savings estimates in it are that session's +estimates, not measurements. It is also a different kind of work from a Mendix +app build. What travels is the arithmetic and the shares, both of which agree +with the three earlier projects where they overlap. + ## What is actually asymmetric — and how little of it is irreducible An earlier draft said: "Vercel's agent writes a `.tsx` file and the work is @@ -1014,10 +1149,11 @@ baseline is the first thing to record wherever it does. | 2d | **Count a killed `run` in `diag loop-report` rather than dropping it** — **shipped** | S | was: the restart bill is invisible, `run`'s reported wall time a floor built from 4 of 30 invocations. Now a 30 s `session_alive` heartbeat bounds every unclosed run from below, whatever killed it, and the report separates the measured total, the measured floor and an explicitly-labelled estimate | | 2e | **App lifecycle as commands** — `run --local --detach`, `run status`, `run wait`, `run stop`, `run restart`, taught in the run-local/run-app skills and the generated gate list — **shipped** | M | ~25–28% of all tool calls in measured sessions were hand-rolled `nohup`/poll/`pkill` loops; each becomes one call, and `exec … && run wait` is the per-change chain | | 3 | Publish the canonical `&&` chain in `projectGates` + skills (lever 1) | XS | the 5–8 → 1–2 collapse, with nothing built | -| 3b | **Make consuming the skills cheap** — **first pass shipped 2026-10-10**: the generated CLAUDE.md now routes `syntax` before any skill, and `scripts/skill-index.py` puts a verified line-numbered section index at the head of all 74 (`make check-skill-index` in CI). Not yet done: moving the syntax that skills restate into `syntax` itself — code blocks are only 28% of skill bytes, so that is a smaller prize than it looked | M | the largest measured token item, and it displaces item 4. **Measured, leaning negative**: across runs 2 and 3 the skills' share of the re-read bill halved (68% -> 37%) and `syntax` sweeps took the freed share (23%), leaving the total 26% worse at a reproducible 59-61 calls against one pre-change point at 47. Missing datum is the control: two runs of `9ce90f32`. The identified surgical fix is to narrow the routing line against speculative sweeps, keeping the index (§"Run 3") | +| 3c | **One session per phase, each resumed from `brain brief` / `brain plan`** — taught in the generated CLAUDE.md and the bootstrap skill, not built | S | **the largest lever measured.** 79% of a 1,771-call build's bill is the conversation, not tool output, and splitting N calls across k near-empty phases is `N × S / 2k` — a factor of k, not a percentage. Its price is one re-orientation per boundary, which is what items 3b/4/4b actually buy (§"A fourth project") | +| 3b | **Make consuming the skills cheap** — **first pass shipped 2026-10-10**: the generated CLAUDE.md now routes `syntax` before any skill, and `scripts/skill-index.py` puts a verified line-numbered section index at the head of all 74 (`make check-skill-index` in CI). Not yet done: moving the syntax that skills restate into `syntax` itself — code blocks are only 28% of skill bytes, so that is a smaller prize than it looked | M | the largest measured token item, and it displaces item 4. **Measured, leaning negative**: across runs 2 and 3 the skills' share of the re-read bill halved (68% -> 37%) and `syntax` sweeps took the freed share (23%), leaving the total 26% worse at a reproducible 59-61 calls against one pre-change point at 47. Missing datum is the control: two runs of `9ce90f32`. The identified surgical fix is to narrow the routing line against speculative sweeps, keeping the index (§"Run 3"). **Resized 2026-10-22**: the shape it targets is 5.3% of the benchmark's bill and 0.26% of a real build's, so its value is no longer as a token lever but as what makes a phase boundary cheap (item 3c) | | 4 | Terse/delta output for `exec`, `syntax` and the noisy listings (lever 2) | M | **promoted back to co-equal with 3b**: run 1's ~15% was the one run where that was true. Runs 2-3 put mxcli's own stdout at ~39% of the re-read cost (`syntax` 23%, the two `DESCRIBE`s 16%) against skill reads' 37%, because the agent substitutes between the two sources (§"Item 4 is no longer the junior partner") | | 4b | **`syntax --digest `** — every signature for a doctype, no prose, one call | S | both post-change runs show the agent assembling exactly this by hand (`for t in …; do mxcli syntax $t; done`, 195 k + 105 k in run 3, plus a 90 k `syntax --json` dump). A cheaper sweep, rather than a rule against sweeping | -| 4c | **Remove the recurring retry chains** — the unspliceable `create or modify`, the CE1613 association-attribute page reference, `injecting test microflows`, the `brain init` routing gap | M | 14% of run 3's model calls were retries, all resolved in one, most of them mxcli's fault. The only listed lever that removes *calls* rather than bytes | +| 4c | **Remove the recurring retry chains** — the unspliceable `create or modify`, the CE1613 association-attribute page reference, `injecting test microflows`, the `brain init` routing gap | M | 14% of run 3's model calls were retries, all resolved in one, most of them mxcli's fault. The only listed lever that removes *calls* rather than bytes — though the chess build puts retries at 4.5%, so the benchmark oversizes this one too | | 5 | Tiered verification rule in the skills (lever 3) | S | stops the default path at the cheapest sufficient gate | | 6 | Subagent trigger in the skills (lever 4) | XS | caps the worst tail | | 7 | Workarounds → diagnostics and skills (lever 5) | M, ongoing | compounds across all future sessions | From 686d87a0adad9b0f3ad0e04a1ee934b16f9f04ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:28:32 +0000 Subject: [PATCH 10/50] refactor(executor): extract MDL-SET01's variable typing into flowObjectTypes MDL-SET01 tracked, statement by statement, which entity each object and list variable holds. The next rule needs the same answer for an XPath operand, so the walk moves into one type both can use instead of a second copy. Behaviour is unchanged; the SET01 tests pass as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XD4JpQem7z5pjk2WuYRqf --- mdl/executor/flow_object_types.go | 78 +++++++++++++++++++ mdl/executor/validate_microflow_set_object.go | 47 +---------- 2 files changed, 81 insertions(+), 44 deletions(-) create mode 100644 mdl/executor/flow_object_types.go diff --git a/mdl/executor/flow_object_types.go b/mdl/executor/flow_object_types.go new file mode 100644 index 0000000000..77420be55e --- /dev/null +++ b/mdl/executor/flow_object_types.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import "github.com/mendixlabs/mxcli/mdl/ast" + +// flowObjectTypes is what the text of a flow says each variable holds: the +// entity of every variable known to hold one object, and of every variable +// known to hold a list. It needs no project, so it sees only the producers +// written in the flow — an entity parameter, a create object, a database +// retrieve, a create list, a loop over a typed list, a head, filter, sort or +// tail. An association retrieve is an object or a list by the association's +// type and direction, which needs the project, so it is left unknown rather +// than guessed. +// +// Feed it every statement in order (forEachMicroflowStatement) through +// observe, AFTER judging the statement: a name a statement produces is +// whatever that statement makes it, so the statement itself still sees the +// binding before it. +type flowObjectTypes struct { + objects map[string]string // variable -> entity, for a single object + lists map[string]string // variable -> entity, for a list +} + +func newFlowObjectTypes(params []ast.MicroflowParam) *flowObjectTypes { + t := &flowObjectTypes{objects: map[string]string{}, lists: map[string]string{}} + for _, p := range params { + if p.Type.EntityRef == nil { + continue + } + if p.Type.Kind == ast.TypeListOf { + t.lists[p.Name] = p.Type.EntityRef.String() + } else { + t.objects[p.Name] = p.Type.EntityRef.String() + } + } + return t +} + +// observe records the variables statement s binds. +func (t *flowObjectTypes) observe(s ast.MicroflowStatement) { + for _, p := range statementProducedVars(s) { + delete(t.objects, p.name) + delete(t.lists, p.name) + } + switch st := s.(type) { + case *ast.CreateObjectStmt: + if st.Variable != "" && st.EntityType.Module != "" { + t.objects[st.Variable] = st.EntityType.String() + } + case *ast.RetrieveStmt: + if st.Variable != "" && st.StartVariable == "" && st.Source.Module != "" { + if st.First { + t.objects[st.Variable] = st.Source.String() + } else { + t.lists[st.Variable] = st.Source.String() + } + } + case *ast.CreateListStmt: + if st.Variable != "" && st.EntityType.Module != "" { + t.lists[st.Variable] = st.EntityType.String() + } + case *ast.LoopStmt: + if entity, ok := t.lists[st.ListVariable]; ok && st.LoopVariable != "" { + t.objects[st.LoopVariable] = entity + } + case *ast.ListOperationStmt: + // head is list-only; find is also the String function, so it is left out. + if entity, ok := t.lists[st.InputVariable]; ok && st.OutputVariable != "" { + switch st.Operation { + case ast.ListOpHead: + t.objects[st.OutputVariable] = entity + case ast.ListOpFilter, ast.ListOpSort, ast.ListOpTail: + t.lists[st.OutputVariable] = entity + } + } + } +} diff --git a/mdl/executor/validate_microflow_set_object.go b/mdl/executor/validate_microflow_set_object.go index 7a999e8fba..336bc1e116 100644 --- a/mdl/executor/validate_microflow_set_object.go +++ b/mdl/executor/validate_microflow_set_object.go @@ -33,23 +33,15 @@ const setObjectRule = "MDL-SET01" // refused: `set $L = $M` on one is a Change list Replace, which mxbuild // accepts, and so are `add`/`remove` and a member change `set $P/Attr = …`. func (v *microflowValidator) checkSetOnObjectVariable(params []ast.MicroflowParam, body []ast.MicroflowStatement) { - // objects maps each variable currently known to hold one object to its entity. - objects := map[string]string{} + types := newFlowObjectTypes(params) // isParam marks the object parameters: mxbuild words their CE7247 // differently ("Parameter 'A' cannot be changed."), measured on 11.14.0. isParam := map[string]bool{} for _, p := range params { if p.Type.EntityRef != nil && p.Type.Kind != ast.TypeListOf { - objects[p.Name] = p.Type.EntityRef.String() isParam[p.Name] = true } } - lists := map[string]string{} - for _, p := range params { - if p.Type.EntityRef != nil && p.Type.Kind == ast.TypeListOf { - lists[p.Name] = p.Type.EntityRef.String() - } - } // primitiveParams maps each primitive parameter to its type's name. primitiveParams := map[string]string{} for _, p := range params { @@ -61,7 +53,7 @@ func (v *microflowValidator) checkSetOnObjectVariable(params []ast.MicroflowPara forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { if set, ok := s.(*ast.MfSetStmt); ok && !strings.Contains(set.Target, "/") { name := strings.TrimPrefix(set.Target, "$") - if entity, ok := objects[name]; ok { + if entity, ok := types.objects[name]; ok { rejection := fmt.Sprintf("CE7247 \"Variable '%s' does not have a primitive type\"", name) if isParam[name] { rejection = fmt.Sprintf("CE7247 \"Parameter '%s' cannot be changed\"", name) @@ -83,43 +75,10 @@ func (v *microflowValidator) checkSetOnObjectVariable(params []ast.MicroflowPara // Rebinding first: a name a statement produces is whatever that // statement makes it, and nothing it was before. for _, p := range statementProducedVars(s) { - delete(objects, p.name) - delete(lists, p.name) delete(isParam, p.name) delete(primitiveParams, p.name) } - switch st := s.(type) { - case *ast.CreateObjectStmt: - if st.Variable != "" && st.EntityType.Module != "" { - objects[st.Variable] = st.EntityType.String() - } - case *ast.RetrieveStmt: - if st.Variable != "" && st.StartVariable == "" && st.Source.Module != "" { - if st.First { - objects[st.Variable] = st.Source.String() - } else { - lists[st.Variable] = st.Source.String() - } - } - case *ast.CreateListStmt: - if st.Variable != "" && st.EntityType.Module != "" { - lists[st.Variable] = st.EntityType.String() - } - case *ast.LoopStmt: - if entity, ok := lists[st.ListVariable]; ok && st.LoopVariable != "" { - objects[st.LoopVariable] = entity - } - case *ast.ListOperationStmt: - // head is list-only; find is also the String function, so it is left out. - if entity, ok := lists[st.InputVariable]; ok && st.OutputVariable != "" { - switch st.Operation { - case ast.ListOpHead: - objects[st.OutputVariable] = entity - case ast.ListOpFilter, ast.ListOpSort, ast.ListOpTail: - lists[st.OutputVariable] = entity - } - } - } + types.observe(s) }) } From a33c94dd2d0d2d1509be8d0f5bee5030bff955a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:30:05 +0000 Subject: [PATCH 11/50] =?UTF-8?q?fix(alter=20page):=20set=20(Action:=20?= =?UTF-8?q?=E2=80=A6)=20writes=20a=20container's=20OnClickAction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set (Action: call microflow M.F) on card1` was refused for a container CREATE had just written with an action (ako/mxcli#1503, mendixlabs/mxcli#1592): widget "card1" (Forms$DivContainer) has no Action property — Action can only be set on a widget that performs an on-click action, such as a button or a clickable container The setter looked only for an `Action` key. The metamodel spells the click action per type: Action on the buttons and navigation list items, OnClickAction on Forms$DivContainer, ClickAction on Forms$ListView and both image viewers. SetWidgetAction now writes the key the stored widget holds (types.WidgetClickActionKeys, in priority order; no type declares two) and still refuses a widget with none. The refusal test's fixture was a container without an OnClickAction, a shape no stored model holds; it now uses a dynamic text. Control: the new test fails on main with the reported message. Fixed, on a blank 11.12.5 app the reporter's script applies, describe shows the new microflow, re-executing the describe output reports `Unchanged page`, and `docker check --no-update-widgets` reports 0 errors — for a container that had an action and for one that had none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PALuhnbBpSb16e2a9A44z9 --- ...-22-set-action-on-clickable-container.json | 1 + .claude/skills/mendix/alter-page/SKILL.md | 2 +- docs-site/src/language/alter-page.md | 1 + ...lter-set-action-on-clickable-container.mdl | 36 ++++++++ mdl/backend/mutation.go | 5 +- mdl/backend/pagemutator/mutator.go | 28 ++++-- .../pagemutator/mutator_action_test.go | 91 ++++++++++++++++--- mdl/types/widget_click_action.go | 18 ++++ 8 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-22-set-action-on-clickable-container.json create mode 100644 mdl-examples/bug-tests/1592-alter-set-action-on-clickable-container.mdl create mode 100644 mdl/types/widget_click_action.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-set-action-on-clickable-container.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-set-action-on-clickable-container.json new file mode 100644 index 0000000000..faffdfe294 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-set-action-on-clickable-container.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-22", "symptom": "`alter page … { set (Action: call microflow M.F) on card1; }` on a container created with `Action:` fails at check and exec: widget \"card1\" (Forms$DivContainer) has no Action property — Action can only be set on a widget that performs an on-click action, such as a button or a clickable container. `set (OnClick: …)` / `set (OnClickAction: …)` point back to `set (Action: …)`", "cause": "SetWidgetAction only accepted a widget holding an `Action` key. The metamodel spells the click action per type: Action (buttons, navigation list items), OnClickAction (Forms$DivContainer), ClickAction (Forms$ListView, Forms$StaticImageViewer, Forms$DynamicImageViewer). The refusal's unit test used a DivContainer fixture with no OnClickAction — a shape no stored model holds — so it pinned the bug as the guard", "file": "`mdl/backend/pagemutator/mutator.go` (SetWidgetAction, clickActionKey); `mdl/types/widget_click_action.go` (WidgetClickActionKeys, shared with the catalog's primary-action reader)", "insight": "The catalog already knew the three keys (`widgetActionKeys` in mdl/catalog/builder_pages.go) while the mutator knew one: a second reader of the same model fact, the duplicate-resolver shape. Grep the gen package for every `NewPart(\"…Action\")` to get the real list rather than trusting either copy: no type declares two of them, so the first key the stored widget holds is its click action. Check the fixture before trusting a refusal test — a fixture missing a required property can make a wrong refusal look like a guard. The cheapest proof the write matches CREATE is a re-exec of the describe output reporting `Unchanged page`; `mx check --no-update-widgets` gives 0 errors on 11.12.5, for a container that had an action and for one that had none", "refs": ["mendixlabs/mxcli#1592", "ako/mxcli#1503"]} diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index 250b5f3915..aacc1dad51 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -212,7 +212,7 @@ so a silent write would build cleanly and then fail to open. | Property | Widget Types | Value Type | Example | |----------|-------------|------------|---------| -| `Action` | Widgets with an on-click action (ACTIONBUTTON, LINKBUTTON, clickable containers) | Any `create page` action expression | `set (Action: call microflow M.ACT_Go) on btnSave` | +| `Action` | Widgets with an on-click action: ACTIONBUTTON, LINKBUTTON, CONTAINER, LISTVIEW, STATICIMAGE, DYNAMICIMAGE | Any `create page` action expression | `set (Action: call microflow M.ACT_Go) on card1` | | `''` | Pluggable widgets — any **action-typed** property (File Uploader `createFileAction`, DataGrid 2 `onSelectionChange`, …) | Any `create page` action expression | `set ('createFileAction': call microflow M.ACT_Create) on fileUploader1` — refused, naming the widget's action slots, if the key is not action-typed | | `caption` | ACTIONBUTTON, LINKBUTTON | String | `set (caption: 'Submit') on btnSave` | | `content` | DYNAMICTEXT | String | `set (content: 'New Heading') on txtTitle` | diff --git a/docs-site/src/language/alter-page.md b/docs-site/src/language/alter-page.md index c8b532d608..cd455b239a 100644 --- a/docs-site/src/language/alter-page.md +++ b/docs-site/src/language/alter-page.md @@ -40,6 +40,7 @@ ALTER PAGE Module.EditPage { | Property | Description | Example | |----------|-------------|---------| | `Caption` | Button/link caption | `SET (Caption: 'Submit') ON btnSave` | +| `Action` | On-click action of a button, link button, container, list view or image — any `CREATE PAGE` action expression | `SET (Action: CALL MICROFLOW M.ACT_Open) ON card1` | | `Label` | Input field label | `SET (Label: 'Full Name') ON txtName` | | `RenderMode` | Dynamic text rendering: `Text`, `Paragraph`, `H1`–`H6` | `SET (RenderMode: H2) ON txtTitle` | | `Placeholder` | Input hint text (text box, text area, date picker) | `SET (Placeholder: 'Enter your name') ON txtName` | diff --git a/mdl-examples/bug-tests/1592-alter-set-action-on-clickable-container.mdl b/mdl-examples/bug-tests/1592-alter-set-action-on-clickable-container.mdl new file mode 100644 index 0000000000..d730e2cb12 --- /dev/null +++ b/mdl-examples/bug-tests/1592-alter-set-action-on-clickable-container.mdl @@ -0,0 +1,36 @@ +mdl 1; +-- mendixlabs/mxcli#1592 / ako/mxcli#1503: `set (Action: …) on card1` was +-- refused for a container CREATE had just written with an on-click action: +-- +-- widget "card1" (Forms$DivContainer) has no Action property — Action can +-- only be set on a widget that performs an on-click action, such as a +-- button or a clickable container +-- +-- A container stores its click action as OnClickAction (a list view and the +-- image viewers as ClickAction); the setter looked only for Action. +-- +-- Expected (describe below): card1's action is ACT_OpenNew, plain1 — a +-- container created without one — now has one, and +-- `mxcli docker check --no-update-widgets` reports 0 errors. Re-executing the +-- describe output reports `Unchanged page`: the ALTER writes what CREATE does. + +create module Bug1592; + +create microflow Bug1592.ACT_OpenOld () begin end; +create microflow Bug1592.ACT_OpenNew () begin end; + +create page Bug1592.CardPage (title: 'Cards', layout: Atlas_Core.Atlas_Default) { + container card1 (DesignProperties: ('Card style': on), Action: call microflow Bug1592.ACT_OpenOld) { + dynamictext txt1 (Content: 'Open') + } + container plain1 { + dynamictext txt2 (Content: 'Plain') + } +}; + +alter page Bug1592.CardPage { + set (Action: call microflow Bug1592.ACT_OpenNew) on card1; + set (Action: call microflow Bug1592.ACT_OpenNew) on plain1 +}; + +describe page Bug1592.CardPage; diff --git a/mdl/backend/mutation.go b/mdl/backend/mutation.go index 954b16d22c..be6327d089 100644 --- a/mdl/backend/mutation.go +++ b/mdl/backend/mutation.go @@ -97,8 +97,9 @@ type PageMutator interface { // SetWidgetDataSource sets the DataSource on the named widget. SetWidgetDataSource(widgetRef string, ds pages.DataSource) error - // SetWidgetAction retargets the on-click action of the named widget. - // Refuses a widget that has no Action property. + // SetWidgetAction retargets the on-click action of the named widget, + // under the key its type stores it in (Action, OnClickAction or + // ClickAction — types.WidgetClickActionKeys). Refuses a widget with none. SetWidgetAction(widgetRef string, action pages.ClientAction) error // SetWidgetNamedAction writes an action into a pluggable widget's action diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 8517c02fab..b13f8a3466 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -258,24 +258,40 @@ func (m *Mutator) SetWidgetAction(widgetRef string, action pages.ClientAction) e if result == nil { return m.widgetNotFoundError(widgetRef) } - // Refuse rather than write an Action onto something that has no such - // property: Studio Pro resolves every stored property against the type's + // The key is the one the stored widget holds: a container keeps its click + // action in OnClickAction, a list view or an image in ClickAction + // (ako/mxcli#1503). Refuse rather than write a key the widget does not + // have: Studio Pro resolves every stored property against the type's // property list and throws on one it does not know, while mxbuild's // deserializer tolerates it — so a silent write here builds clean and fails // to open. - if bsonnav.DGet(result.widget, "Action") == nil { - return fmt.Errorf("widget %q (%s) has no Action property — Action can only be set on a widget that "+ - "performs an on-click action, such as a button or a clickable container", + key := clickActionKey(result.widget) + if key == "" { + return fmt.Errorf("widget %q (%s) has no on-click action — Action can only be set on a widget that "+ + "has one: a button, a container, a list view or an image", widgetRef, widgetTypeName(result.widget)) } serialized := m.deps.SerializeClientAction(action) if serialized == nil { return fmt.Errorf("unsupported action type %T", action) } - bsonnav.DSet(result.widget, "Action", serialized) + bsonnav.DSet(result.widget, key, serialized) return nil } +// clickActionKey returns the key under which a stored widget keeps its +// on-click action, or "" when it has none. +func clickActionKey(widget bson.D) string { + for _, key := range types.WidgetClickActionKeys { + for _, e := range widget { + if e.Key == key { + return key + } + } + } + return "" +} + // SetWidgetNamedAction writes an action into a pluggable widget's action slot // addressed by the widget's own property key — the ALTER-level twin of CREATE // PAGE's `createFileAction: microflow M.F` (#956), so one mis-wired slot can be diff --git a/mdl/backend/pagemutator/mutator_action_test.go b/mdl/backend/pagemutator/mutator_action_test.go index 35de3b7a1c..cf2e2672f2 100644 --- a/mdl/backend/pagemutator/mutator_action_test.go +++ b/mdl/backend/pagemutator/mutator_action_test.go @@ -72,33 +72,98 @@ func TestSetWidgetAction_ReplacesAction(t *testing.T) { } } +// TestSetWidgetAction_WritesTheWidgetsOwnClickKey is ako/mxcli#1503 +// (mendixlabs/mxcli#1592): a widget's on-click action is not always stored as +// Action. A container keeps it in OnClickAction; a list view and both image +// viewers in ClickAction. `set (Action: …) on card1` was refused for a +// container CREATE had just written with an action, with a message naming "a +// clickable container" as a valid target. +// +// The write has to land in the key the type declares and add no other: +// Studio Pro throws on a stored property its type does not have, while mxbuild +// tolerates it, so an Action key on a container builds clean and fails to open. +func TestSetWidgetAction_WritesTheWidgetsOwnClickKey(t *testing.T) { + for _, tc := range []struct { + typeName, key string + }{ + {"Forms$ActionButton", "Action"}, + {"Forms$DivContainer", "OnClickAction"}, + {"Forms$ListView", "ClickAction"}, + {"Forms$StaticImageViewer", "ClickAction"}, + {"Forms$DynamicImageViewer", "ClickAction"}, + } { + t.Run(tc.typeName, func(t *testing.T) { + w := bson.D{ + {Key: "$Type", Value: tc.typeName}, + {Key: "Name", Value: "w1"}, + {Key: tc.key, Value: bson.D{ + {Key: "$Type", Value: "Forms$MicroflowAction"}, + {Key: "Microflow", Value: "M.ACT_OpenOld"}, + }}, + {Key: "Class", Value: "card"}, + } + raw := makeRawPage(w) + newAction := bson.D{ + {Key: "$Type", Value: "Forms$MicroflowAction"}, + {Key: "Microflow", Value: "M.ACT_OpenNew"}, + } + m := New(raw, model.ID("unit-1"), &stubActionDeps{serialized: newAction}) + + if err := m.SetWidgetAction("w1", &pages.MicroflowClientAction{MicroflowName: "M.ACT_OpenNew"}); err != nil { + t.Fatalf("SetWidgetAction: %v", err) + } + + got := m.widgetFinder(m.rawData, "w1").widget + if mf := bsonnav.DGet(bsonnav.DGetDoc(got, tc.key), "Microflow"); mf != "M.ACT_OpenNew" { + t.Errorf("%s.Microflow = %v, want M.ACT_OpenNew", tc.key, mf) + } + for _, other := range []string{"Action", "OnClickAction", "ClickAction"} { + if other != tc.key && bsonnav.DGet(got, other) != nil { + t.Errorf("%s written on a %s, whose click action is %s", other, tc.typeName, tc.key) + } + } + if len(got) != len(w) { + t.Errorf("widget has %d keys after the set, want %d — a set adds no property", len(got), len(w)) + } + if c := bsonnav.DGet(got, "Class"); c != "card" { + t.Errorf("Class = %v, want card — a set must not disturb sibling properties", c) + } + }) + } +} + // TestSetWidgetAction_RefusesWidgetWithoutAction is the guard-don't-drop half. // // Studio Pro resolves every stored property against the type's property list and // throws on one it does not know, while mxbuild's deserializer tolerates it — so -// writing an Action onto a container would build clean and then fail to open. -// Refusing is the only safe answer, and the build is not a safety net here. +// writing an Action onto a widget with no click action would build clean and +// then fail to open. Refusing is the only safe answer, and the build is not a +// safety net here. +// +// The fixture used to be a container without an OnClickAction, a shape no model +// holds — every stored container has one — and it pinned the refusal #1503 +// reported. func TestSetWidgetAction_RefusesWidgetWithoutAction(t *testing.T) { - container := bson.D{ - {Key: "$Type", Value: "Forms$DivContainer"}, - {Key: "Name", Value: "c1"}, + text := bson.D{ + {Key: "$Type", Value: "Forms$DynamicText"}, + {Key: "Name", Value: "t1"}, } - raw := makeRawPage(container) - m := New(raw, model.ID("unit-1"), &stubActionDeps{serialized: bson.D{{Key: "$Type", Value: "Forms$MicroflowClientAction"}}}) + raw := makeRawPage(text) + m := New(raw, model.ID("unit-1"), &stubActionDeps{serialized: bson.D{{Key: "$Type", Value: "Forms$MicroflowAction"}}}) - err := m.SetWidgetAction("c1", &pages.MicroflowClientAction{MicroflowName: "M.ACT_Save"}) + err := m.SetWidgetAction("t1", &pages.MicroflowClientAction{MicroflowName: "M.ACT_Save"}) if err == nil { - t.Fatal("expected an error setting Action on a container") + t.Fatal("expected an error setting Action on a dynamic text") } - for _, want := range []string{"c1", "Forms$DivContainer", "no Action property"} { + for _, want := range []string{"t1", "Forms$DynamicText", "no on-click action"} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err.Error(), want) } } // And nothing was written. - widget := m.widgetFinder(m.rawData, "c1") - if bsonnav.DGet(widget.widget, "Action") != nil { - t.Error("Action was written to a widget that has no such property") + widget := m.widgetFinder(m.rawData, "t1") + if len(widget.widget) != len(text) { + t.Errorf("widget has %d keys after a refused set, want %d", len(widget.widget), len(text)) } } diff --git a/mdl/types/widget_click_action.go b/mdl/types/widget_click_action.go new file mode 100644 index 0000000000..368204e31c --- /dev/null +++ b/mdl/types/widget_click_action.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +// WidgetClickActionKeys are, in priority order, the keys under which a built-in +// widget stores its on-click action. The metamodel spells it per type: +// +// - Action — the buttons (Forms$ActionButton and its data view, grid and +// drop-down item variants) and a navigation list item +// - OnClickAction — Forms$DivContainer +// - ClickAction — Forms$ListView, Forms$StaticImageViewer, +// Forms$DynamicImageViewer +// +// No type declares two of them, so the first key a stored widget holds is its +// click action. Two readers need the list and must not disagree: the catalog, +// which reports a widget's primary action, and ALTER PAGE's `set (Action: …)`, +// which wrote only Action and refused every container (ako/mxcli#1503). +var WidgetClickActionKeys = []string{"Action", "OnClickAction", "ClickAction"} From c2b5fa590679b47fc0695529a274109d42f70e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:30:43 +0000 Subject: [PATCH 12/50] refactor(catalog): read a widget's click action from types.WidgetClickActionKeys The catalog's primary-action reader kept its own copy of the three keys a widget stores its on-click action under; ALTER PAGE's setter now reads the same list from mdl/types, so the two cannot drift again (the setter had known only Action, ako/mxcli#1503). The comment's 'layout grids' was wrong: no LayoutGrid type declares OnClickAction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PALuhnbBpSb16e2a9A44z9 --- mdl/catalog/builder_pages.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/mdl/catalog/builder_pages.go b/mdl/catalog/builder_pages.go index 529f78dfc1..4da4213677 100644 --- a/mdl/catalog/builder_pages.go +++ b/mdl/catalog/builder_pages.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/sdk/pages" ) @@ -331,9 +332,8 @@ type rawWidgetInfo struct { Style string DynamicClasses string - // ActionType is the stored $Type of the widget's primary action: Action - // (action button, link button), else OnClickAction (containers, layout - // grids), else ClickAction (list views, images). Raw, e.g. + // ActionType is the stored $Type of the widget's primary action, read from + // the first of types.WidgetClickActionKeys the widget holds. Raw, e.g. // "Forms$DeleteClientAction", "Forms$MicroflowAction", "Forms$NoAction". // HasConfirmation reports a ConfirmationInfo on that action; only // microflow, nanoflow and workflow calls can carry one — a delete action @@ -342,16 +342,12 @@ type rawWidgetInfo struct { HasConfirmation bool } -// widgetActionKeys are, in priority order, the keys under which a widget stores -// its primary client action. -var widgetActionKeys = []string{"Action", "OnClickAction", "ClickAction"} - // widgetPrimaryAction returns the $Type of a widget's primary action and whether // that action asks for confirmation. A microflow call keeps its confirmation in // MicroflowSettings.ConfirmationInfo; nanoflow and workflow calls hold // ConfirmationInfo directly. Unset, the property is stored as null. func widgetPrimaryAction(w map[string]any) (actionType string, hasConfirmation bool) { - for _, key := range widgetActionKeys { + for _, key := range types.WidgetClickActionKeys { action, ok := w[key].(map[string]any) if !ok { continue From 40e45d1df7e661410d3cc5c4badeb539c9fd0bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:32:23 +0000 Subject: [PATCH 13/50] docs(skills): keep write-microflows under the 700-line body limit The free-note ordering rule added in b9bedb20 took the skill body to 705 lines, which TestLargeSkillsWereSplit refuses. The notes section is condensed; the rules it states are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- .../skills/mendix/write-microflows/SKILL.md | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 176f68dcaa..ca793326e9 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -26,7 +26,7 @@ description: "Microflow syntax reference in MDL — every activity type, control | 577-593 | Validation Checklist | | 594-605 | Tips for Success | | 606-612 | Related Documentation | -| 613-732 | Quick Reference | +| 613-725 | Quick Reference | # Mendix Microflow Skill @@ -663,20 +663,13 @@ commit $Order; commit $Invoice; ``` -`id:` is scoped to the flow you are writing and is not stored in the model; it -exists only so a second mention can point at the first. **Without it, two lines -with identical text are two separate notes** — mxcli never merges on text. - -A note's own canvas geometry is `position: (x, y)` and `size: (w, h)`, e.g. -`@annotation(text: 'note', position: (175, -40), size: (260, 70))`. Omit them -and the note goes 100px above the activity at 200×50, stacking 60px per extra -note; DESCRIBE omits them again whenever they match, so an ordinary note keeps -the short `@annotation 'text'` form. - -**Order matters: an `@annotation` written *before* a statement's `@position` -(or `@caption`, `@color`, `@anchor`, `@excluded`) is a free note** — on the -canvas, attached to nothing — which is where DESCRIBE prints one. Write a note -that belongs to the activity *after* those annotations, as DESCRIBE does. +`id:` is flow-scoped and not stored; it lets a second mention point at the +first. **Without it, identical text is two notes** — mxcli never merges on text. +Geometry is `position: (x, y)` and `size: (w, h)`; omitted, the note goes 100px +above the activity at 200×50 (60px per extra), and DESCRIBE omits it again. +**An `@annotation` before a statement's `@position`/`@caption`/`@color`/`@anchor` +is a free note**, attached to nothing (where DESCRIBE prints one): put an +activity's own notes after them. ### Page Navigation Pattern ```mdl From 3ca9a7f12dc78db6ec4c1479945034b2533c256d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:33:47 +0000 Subject: [PATCH 14/50] fix: check predicts a flow statement after an earlier change to the same flow (mendixlabs/mxcli#1517) check -p (and exec's preflight) gave up on a statement about a flow an earlier statement had changed, reporting it 'not predicted', and exec could then refuse it after the earlier statements ran. The writer gains a simulated mode: unit updates are held in memory and read back, never flushed, and any other write is refused at guardWrite. check applies each change it predicts as spliced to the simulation, so the next statement on that flow is predicted against what exec would see. Refs ako/mxcli#1399. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- ...k-gives-up-on-chained-flow-statements.json | 1 + ...check-predicts-chained-flow-statements.mdl | 38 +++++++ mdl/backend/modelsdk/backend.go | 11 ++ mdl/executor/validate_flow_verdicts.go | 85 ++++++++++---- ...idate_flow_verdicts_chained_pedapp_test.go | 104 ++++++++++++++++++ modelsdk/mpr/studiopro_lock.go | 3 + modelsdk/mpr/writer_core.go | 3 + modelsdk/mpr/writer_deferred.go | 4 + modelsdk/mpr/writer_simulated.go | 42 +++++++ modelsdk/mpr/writer_simulated_test.go | 64 +++++++++++ 10 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-gives-up-on-chained-flow-statements.json create mode 100644 mdl-examples/bug-tests/1517-check-predicts-chained-flow-statements.mdl create mode 100644 mdl/executor/validate_flow_verdicts_chained_pedapp_test.go create mode 100644 modelsdk/mpr/writer_simulated.go create mode 100644 modelsdk/mpr/writer_simulated_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-gives-up-on-chained-flow-statements.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-gives-up-on-chained-flow-statements.json new file mode 100644 index 0000000000..eb9251213c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-gives-up-on-chained-flow-statements.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-22","symptom":"Item 4 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399): `check -p` printed 'Check passed!' with a 'flow change(s) not predicted — an earlier statement of the script creates or changes it' note, and exec then refused that statement and rolled the whole script back ('Nothing was written: statement 1 was rolled back'). exec's own preflight has the same blind spot, so the refusal came only at write time","cause":"checkFlowVerdicts predicted every flow statement against the flow as stored; once an earlier statement touched the same flow it could not know what exec would see there, so it marked the flow 'touched' and gave up on every later statement about it","file":"modelsdk/mpr/writer_simulated.go (SimulateWrites, ErrSimulatedWrite), modelsdk/mpr/studiopro_lock.go (guardWrite), modelsdk/mpr/writer_deferred.go (FlushDeferredWrites), mdl/backend/modelsdk/backend.go (SimulateWrites), mdl/executor/validate_flow_verdicts.go (checkFlowVerdicts, flowModifyViolations, alterFlowViolations)","fix":"A simulated write mode on the writer: a deferred run (updates held in memory and served back by the reader's overlay) that is never flushed, whose discard clears the overlays, and in which guardWrite refuses every other write before it touches storage. checkFlowVerdicts opens one for the pass; each create-or-modify or alter predicted as spliced has its plan saved into it, and its flow is not marked touched, so the next statement on that flow is predicted against the simulated result. Folder moves and creates still mark the flow touched","test":"modelsdk/mpr/writer_simulated_test.go (held update read back, delete and flush refused, disk and transaction id untouched, discard restores, writes land afterwards); mdl/executor/validate_flow_verdicts_chained_pedapp_test.go (refusal of statement 2 predicted, alter addressing what statement 1 wrote predicted to apply, every project file byte-identical across check, stored flow unchanged after check, exec refuses what check predicted). Before the change both chained subtests reported the flow unpredicted","insight":"The machinery already existed: the deferred-write run built for access-rule runs (#872) holds updates and serves them back to every read, which is exactly a simulation minus the flush — the only additions are 'never flush' and 'refuse what cannot be held at guardWrite', the choke point every write already passes before touching storage. Two traps when testing: ExecuteProgram has no rollback (that is cmd exec's journal), so an exec-agreement subtest writes statement 1 and must run last; and the simulation's defer has to run before CacheUnitReads' release — it does, being inside checkFlowVerdicts","refs":["mendixlabs/mxcli#1517","ako/mxcli#1399","ako/mxcli#876"]} diff --git a/mdl-examples/bug-tests/1517-check-predicts-chained-flow-statements.mdl b/mdl-examples/bug-tests/1517-check-predicts-chained-flow-statements.mdl new file mode 100644 index 0000000000..6bca5b4eeb --- /dev/null +++ b/mdl-examples/bug-tests/1517-check-predicts-chained-flow-statements.mdl @@ -0,0 +1,38 @@ +-- ============================================================================ +-- mendixlabs/mxcli#1517 (ako/mxcli#1399), item 4 — check does not predict a +-- statement on a flow an earlier statement changed +-- ============================================================================ +-- +-- `check -p` reported "Check passed!" with a note that a flow change was "not +-- predicted", and exec then refused that statement and rolled the whole script +-- back ("Nothing was written: statement 1 was rolled back"). +-- +-- To reproduce: exec the first statement alone against a project, then run +-- `mxcli check -p` on the whole file. The second statement changes the loop +-- body's shape, which the splice refuses whatever the first did; check now +-- reports that refusal at statement 2 (MDL-V1-REBUILD) instead of "not +-- predicted", because it applies statement 1 to an in-memory simulation of +-- the flow — never to the project — before predicting statement 2. + +mdl 1; + +create module BugTest1517Chain; + +create or modify microflow BugTest1517Chain.F ($Items: List of System.User) +begin + log info node 'BugTest1517' 'begin'; + loop $It in $Items + begin + log info node 'BugTest1517' 'in loop'; + end loop; +end; + +create or modify microflow BugTest1517Chain.F ($Items: List of System.User) +begin + log info node 'BugTest1517' 'begin'; + loop $It in $Items + begin + log info node 'BugTest1517' 'in loop'; + log info node 'BugTest1517' 'again'; + end loop; +end; diff --git a/mdl/backend/modelsdk/backend.go b/mdl/backend/modelsdk/backend.go index ad844d0e24..383fc1f0fe 100644 --- a/mdl/backend/modelsdk/backend.go +++ b/mdl/backend/modelsdk/backend.go @@ -164,6 +164,17 @@ func (b *Backend) FlushDeferredWrites() error { return b.writer.FlushDeferredWrites() } +// SimulateWrites holds unit updates in memory, read back but never written, +// until the returned function discards them; any other write is refused +// (mmpr.Writer.SimulateWrites). ok is false — and nothing is simulated — on a +// read-only connection or while a deferred run is open. +func (b *Backend) SimulateWrites() (discard func(), ok bool) { + if b.writer == nil { + return func() {}, false + } + return b.writer.SimulateWrites() +} + // Commit is a no-op for the read-only slice. func (b *Backend) Commit() error { return nil } diff --git a/mdl/executor/validate_flow_verdicts.go b/mdl/executor/validate_flow_verdicts.go index 40f8d32e3b..3feb8b36f4 100644 --- a/mdl/executor/validate_flow_verdicts.go +++ b/mdl/executor/validate_flow_verdicts.go @@ -112,6 +112,22 @@ func checkFlowVerdicts(ctx *ExecContext, prog *ast.Program) FlowVerdictReport { } } } + // Each flow change predicted as spliced is applied to a simulation — held + // in memory, read back, never written — so a later statement on the same + // flow is predicted against what exec will see there, not given up on + // (mendixlabs/mxcli#1517). Discarded when the check ends. + simulating := false + if sim, ok := ctx.Backend.(writeSimulator); ok { + if discard, ok := sim.SimulateWrites(); ok { + defer discard() + simulating = true + } + } + // applied says whether a predicted change reached the simulation, after + // which the flow is predicted as it is there rather than as "touched". + applied := func(vs []linter.Violation, dep string, apply func() bool) bool { + return simulating && apply != nil && len(vs) == 0 && dep == "" && apply() + } undeclare := func(qn ast.QualifiedName) { delete(ctx.scriptFlows, scriptFlowKey(false, qn.String())) delete(ctx.scriptFlows, scriptFlowKey(true, qn.String())) @@ -143,7 +159,9 @@ func checkFlowVerdicts(ctx *ExecContext, prog *ast.Program) FlowVerdictReport { } r.Unpredicted = append(r.Unpredicted, UnpredictedFlow{Kind: kind, Name: qn.String(), Reason: reason}) } - modify := func(d *flowDecl, ruled bool) { + // modify predicts a `create or modify`, and reports whether its change + // reached the simulation. + modify := func(d *flowDecl, ruled bool) bool { why, skip := stale(d.nanoflow, d.name) switch { case skip: @@ -152,42 +170,46 @@ func checkFlowVerdicts(ctx *ExecContext, prog *ast.Program) FlowVerdictReport { unpredicted(d.nanoflow, d.name, "an earlier statement of the script "+why) } case ruled: - vs, dep := flowModifyViolations(ctx, d) + vs, dep, apply := flowModifyViolations(ctx, d) r.Violations = append(r.Violations, vs...) if dep != "" { unpredicted(d.nanoflow, d.name, dep) } + return applied(vs, dep, apply) } + return false } for _, stmt := range prog.Statements { switch s := stmt.(type) { case *ast.CreateMicroflowStmt: - if s.CreateOrModify { - modify(microflowDecl(s), validateMicroflowRules(s) == nil) + if !(s.CreateOrModify && modify(microflowDecl(s), validateMicroflowRules(s) == nil)) { + touched[key(false, s.Name)] = "creates or changes it" } - touched[key(false, s.Name)] = "creates or changes it" declare(false, s.Name, s.ReturnType) case *ast.CreateNanoflowStmt: - if s.CreateOrModify { - modify(nanoflowDecl(s), true) + if !(s.CreateOrModify && modify(nanoflowDecl(s), true)) { + touched[key(true, s.Name)] = "creates or changes it" } - touched[key(true, s.Name)] = "creates or changes it" declare(true, s.Name, s.ReturnType) case *ast.AlterFlowStmt: + simulated := false if why, skip := stale(s.Nanoflow, s.Name); skip { } else if why != "" { if flowStored(ctx, s.Nanoflow, s.Name) { unpredicted(s.Nanoflow, s.Name, "an earlier statement of the script "+why) } } else { - vs, dep := alterFlowViolations(ctx, s) + vs, dep, apply := alterFlowViolations(ctx, s) r.Violations = append(r.Violations, vs...) if dep != "" { unpredicted(s.Nanoflow, s.Name, dep) } + simulated = applied(vs, dep, apply) + } + if !simulated { + touched[key(s.Nanoflow, s.Name)] = "alters it" } - touched[key(s.Nanoflow, s.Name)] = "alters it" case *ast.DropMicroflowStmt: touched[key(false, s.Name)] = touchDropped undeclare(s.Name) @@ -253,21 +275,34 @@ func flowStored(ctx *ExecContext, nanoflow bool, qn ast.QualifiedName) bool { return !errors.As(err, &nf) } +// writeSimulator is the optional backend capability check predicts a script's +// flow changes in order with (modelsdk Backend.SimulateWrites). +type writeSimulator interface { + SimulateWrites() (discard func(), ok bool) +} + // flowModifyViolations is the check-time report of decideFlowModify. dep is // non-empty when the stored flow's verdict cannot be predicted because the -// declared one does not build: it says what the body depends on. -func flowModifyViolations(ctx *ExecContext, d *flowDecl) (_ []linter.Violation, dep string) { +// declared one does not build: it says what the body depends on. apply, when +// not nil, writes the spliced change the way exec would — into the +// simulation check holds open — and reports whether it did; a change that +// also moves the flow to another folder is not applied, as that is not a unit +// update. +func flowModifyViolations(ctx *ExecContext, d *flowDecl) (_ []linter.Violation, dep string, apply func() bool) { if _, _, _, err := d.build(ctx); err != nil { // Not predictable against the stored project (see CheckFlowVerdicts); // a flow that is not stored is a create, with no verdict to predict. if flowStored(ctx, d.nanoflow, d.name) { - return nil, buildDependency(err.Error()) + return nil, buildDependency(err.Error()), nil } - return nil, "" + return nil, "", nil } v := decideFlowModify(ctx, d) if v.why == nil { - return nil, "" + if p := v.plan; p != nil && v.err == nil && !movesFolder(d.folder, p.storedFolder) { + apply = func() bool { return p.mut == nil || p.mut.Save() == nil } + } + return nil, "", apply } loc := linter.Location{Module: d.name.Module, DocumentType: d.kind(), DocumentName: d.name.Name} if v.refused { @@ -276,14 +311,14 @@ func flowModifyViolations(ctx *ExecContext, d *flowDecl) (_ []linter.Violation, Severity: linter.SeverityError, Message: "exec would refuse this statement: " + flowRefusal(d, v.why).Error(), Location: loc, - }}, "" + }}, "", nil } return []linter.Violation{{ RuleID: FlowRebuildRule, Severity: linter.SeverityWarning, Message: flowRebuildWarning(ctx, d, v.why), Location: loc, - }}, "" + }}, "", nil } // buildDependency condenses a build error ("microflow 'M.N' has validation @@ -301,10 +336,12 @@ func buildDependency(msg string) string { } // alterFlowViolations is the check-time report of an alter exec would refuse. -func alterFlowViolations(ctx *ExecContext, s *ast.AlterFlowStmt) (_ []linter.Violation, dep string) { +// apply, when not nil, writes the alter into the simulation check holds open, +// as flowModifyViolations' does. +func alterFlowViolations(ctx *ExecContext, s *ast.AlterFlowStmt) (_ []linter.Violation, dep string, apply func() bool) { a, err := loadAlterFlow(ctx, s) if err != nil { - return nil, "" // no such flow: reference validation reports it + return nil, "", nil // no such flow: reference validation reports it } // A fragment that does not build against the stored project is not // predictable (see CheckFlowVerdicts). Only the builder's own errors say @@ -316,23 +353,23 @@ func alterFlowViolations(ctx *ExecContext, s *ast.AlterFlowStmt) (_ []linter.Vio fb := a.fragmentBuilder(ctx) fb.buildFlowGraph(op.Body, nil) if errs := fb.GetErrors(); len(errs) > 0 { - return nil, buildDependency(strings.Join(errs, "\n")) + return nil, buildDependency(strings.Join(errs, "\n")), nil } } - _, err = a.plan(ctx) + mut, err := a.plan(ctx) if err == nil { - return nil, "" + return nil, "", func() bool { return mut.Save() == nil } } var nf *mdlerrors.NotFoundError if errors.As(err, &nf) { - return nil, "" + return nil, "", nil } return []linter.Violation{{ RuleID: alterFlowRefusedRule, Severity: linter.SeverityError, Message: "exec would refuse this statement and write nothing: " + err.Error(), Location: linter.Location{Module: s.Name.Module, DocumentType: s.Kind(), DocumentName: s.Name.Name}, - }}, "" + }}, "", nil } // FormatUnpredictedFlows is the note check and exec's preflight print for diff --git a/mdl/executor/validate_flow_verdicts_chained_pedapp_test.go b/mdl/executor/validate_flow_verdicts_chained_pedapp_test.go new file mode 100644 index 0000000000..6a949271d8 --- /dev/null +++ b/mdl/executor/validate_flow_verdicts_chained_pedapp_test.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// Item 4 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399): +// `check` reported "Check passed!" with a "not predicted" note for a statement +// on a flow an earlier statement of the script had already changed, and exec +// then refused that statement and rolled the whole script back ("Nothing was +// written: statement 1 was rolled back"). +// +// check predicted each flow statement against the flow as stored, so after an +// earlier change to the same flow it gave up. It now applies each change it +// predicts as spliced to a simulated copy — held in memory, never written — +// so the next statement on that flow is predicted against what exec would see. + +const chainedBase = `mdl 1; +create or modify microflow MyFirstModule.Chained ($Items: List of FeedbackModule.Feedback) +begin + log info node 'X' 'start'; + loop $It in $Items + begin + log info node 'X' 'in loop'; + end loop; + log info node 'X' 'done'; +end;` + +// chainedStep1 changes the flow beside the loop: spliced. +var chainedStep1 = strings.Replace(chainedBase, "'start'", "'begin'", 1) + +func TestCheckFlowVerdicts_ChainedOnTheSameFlow(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + if err := agreeExec(t, exec, chainedBase); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + report := func(t *testing.T, src string) FlowVerdictReport { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + before := projectFiles(t, dir) + r := exec.CheckFlowVerdictsReport(prog) + after := projectFiles(t, dir) + for f, h := range before { + if after[f] != h { + t.Errorf("check wrote %s", f) + } + } + if len(after) != len(before) { + t.Errorf("check changed the project's files: %d -> %d", len(before), len(after)) + } + return r + } + + t.Run("a refusal after an earlier change is predicted", func(t *testing.T) { + // Step 2 changes the loop body's shape: refused, whatever step 1 did. + step2 := strings.Replace(chainedStep1, "'in loop';", "'in loop';\n log info node 'X' 'again';", 1) + r := report(t, chainedStep1+"\n"+strings.TrimPrefix(step2, "mdl 1;\n")) + if len(r.Violations) != 1 || !strings.Contains(r.Violations[0].Message, "changes inside its body") { + t.Errorf("want statement 2's refusal predicted, got violations %v, unpredicted %+v", r.Violations, r.Unpredicted) + } + if len(r.Unpredicted) != 0 { + t.Errorf("reported unpredicted: %+v", r.Unpredicted) + } + }) + t.Run("an alter after an earlier change is predicted", func(t *testing.T) { + // The alter addresses the log step 1 rewrote: against the stored flow + // it would not resolve, against the simulated one it does. + alter := "alter microflow MyFirstModule.Chained {\n replace log node 'X' 'begin' with begin log info node 'X' 'go'; end;\n};" + r := report(t, chainedStep1+"\n"+alter) + if len(r.Violations) != 0 || len(r.Unpredicted) != 0 { + t.Errorf("want the alter predicted to apply, got violations %v, unpredicted %+v", r.Violations, r.Unpredicted) + } + }) + t.Run("control: a refusal of the first statement is still reported", func(t *testing.T) { + step := strings.Replace(chainedBase, "'in loop';", "'in loop';\n log info node 'X' 'again';", 1) + r := report(t, step) + if len(r.Violations) != 1 { + t.Errorf("want the refusal, got %v", r.Violations) + } + }) + // The simulation is discarded: a later check sees the flow as stored. + t.Run("control: the simulated change does not outlive the check", func(t *testing.T) { + if desc := describeFlow(t, exec, "Chained"); !strings.Contains(desc, "'start'") { + t.Errorf("the stored flow reads as changed after check:\n%s", desc) + } + }) + // exec agrees with the first prediction: it refuses statement 2. (Run + // last: ExecuteProgram writes statement 1; the rollback is cmd exec's.) + t.Run("exec refuses what check predicted", func(t *testing.T) { + step2 := strings.Replace(chainedStep1, "'in loop';", "'in loop';\n log info node 'X' 'again';", 1) + err := agreeExec(t, exec, chainedStep1+"\n"+strings.TrimPrefix(step2, "mdl 1;\n")) + if err == nil || !strings.Contains(err.Error(), "changes inside its body") { + t.Errorf("want exec to refuse statement 2, got %v", err) + } + }) +} diff --git a/modelsdk/mpr/studiopro_lock.go b/modelsdk/mpr/studiopro_lock.go index 27a27f37a4..b8f82ceef4 100644 --- a/modelsdk/mpr/studiopro_lock.go +++ b/modelsdk/mpr/studiopro_lock.go @@ -97,6 +97,9 @@ func studioProOpenGuard(mprPath string) error { // here before it changes anything, which is also what lets an open Journal copy // the .mpr before the first change (journal.go). func (w *Writer) guardWrite() error { + if w.simulating { + return ErrSimulatedWrite + } if err := studioProOpenGuard(w.reader.path); err != nil { return err } diff --git a/modelsdk/mpr/writer_core.go b/modelsdk/mpr/writer_core.go index 9b2640b8ef..15cb1d6c54 100644 --- a/modelsdk/mpr/writer_core.go +++ b/modelsdk/mpr/writer_core.go @@ -61,6 +61,9 @@ type Writer struct { // net result (writer_deferred.go); nil when no run is open. deferred map[string]deferredWrite deferredOrder []string + // simulating says the deferred run is a simulation (writer_simulated.go): + // it is discarded, never flushed, and any write it cannot hold is refused. + simulating bool } // removedUnit is everything about a deleted unit that a re-insert has to be diff --git a/modelsdk/mpr/writer_deferred.go b/modelsdk/mpr/writer_deferred.go index 086fefb4c0..4b464330c8 100644 --- a/modelsdk/mpr/writer_deferred.go +++ b/modelsdk/mpr/writer_deferred.go @@ -55,6 +55,10 @@ func (w *Writer) DeferUnitWrites() { // it was first written. The first error is returned; the remaining units are // still attempted, since each is a complete document of its own. func (w *Writer) FlushDeferredWrites() error { + if w.simulating { + // A simulation is discarded by whoever opened it, never written. + return ErrSimulatedWrite + } held, order := w.deferred, w.deferredOrder w.deferred, w.deferredOrder = nil, nil var first error diff --git a/modelsdk/mpr/writer_simulated.go b/modelsdk/mpr/writer_simulated.go new file mode 100644 index 0000000000..68e2b8c13c --- /dev/null +++ b/modelsdk/mpr/writer_simulated.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import "errors" + +// Simulated writes: a run whose unit updates are held in memory and read back, +// exactly as in a deferred run (writer_deferred.go), but are never written. +// +// `check` uses it to predict a script's statements in order (mendixlabs/mxcli#1517): +// a flow change it predicts as accepted is applied to the simulation, so the +// next statement on the same flow is judged against what exec would see there, +// not against the flow as stored. Without it check gave up on that statement +// ("not predicted") and exec could refuse it after the earlier statements had +// run. +// +// Only an update can be simulated: it is all a later read needs to see. Every +// other write — an insert, a delete, a move, a contents-hash repair — passes +// guardWrite before it touches anything, and is refused there while a +// simulation is open, so a simulation cannot reach storage by any path. + +// ErrSimulatedWrite is returned for a write a simulation cannot hold. +var ErrSimulatedWrite = errors.New("this write cannot be simulated: only unit updates are held while writes are simulated, and nothing reaches storage") + +// SimulateWrites opens a simulation and returns the function that ends it, +// discarding everything held. It does not open one — ok is false — while a +// deferred run or another simulation is open, since its held writes would mix +// with real ones. +func (w *Writer) SimulateWrites() (discard func(), ok bool) { + if w.simulating || w.deferred != nil { + return func() {}, false + } + w.simulating = true + w.DeferUnitWrites() + return func() { + for id := range w.deferred { + w.reader.ClearOverlay(id) + } + w.deferred, w.deferredOrder = nil, nil + w.simulating = false + }, true +} diff --git a/modelsdk/mpr/writer_simulated_test.go b/modelsdk/mpr/writer_simulated_test.go new file mode 100644 index 0000000000..c698467354 --- /dev/null +++ b/modelsdk/mpr/writer_simulated_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "errors" + "os" + "testing" +) + +// mendixlabs/mxcli#1517: check applies the flow changes it predicts to a +// simulation, so a later statement on the same flow is predicted against them. +// A simulation holds updates and serves them back, and nothing it is given +// reaches storage — neither the update, nor any write it cannot hold. + +func TestSimulatedWritesAreReadBackAndNeverWritten(t *testing.T) { + stored := ruleDoc(t, true, "33333333-3333-3333-3333-333333333333", "https://a.test") + w, unitPath := newV2WriterForCommitTest(t, deferredUnitID, stored) + seedTransactionTable(t, w, "before-the-run") + seedUnitRow(t, w, deferredUnitID, "22222222-2222-2222-2222-222222222222", "Documents") + + discard, ok := w.SimulateWrites() + if !ok { + t.Fatal("no simulation opened") + } + if _, again := w.SimulateWrites(); again { + t.Error("a second simulation opened inside the first") + } + changed := ruleDoc(t, true, "33333333-3333-3333-3333-333333333333", "https://b.test") + if err := w.UpdateRawUnit(deferredUnitID, changed); err != nil { + t.Fatalf("update: %v", err) + } + if got, _ := w.reader.GetRawUnitBytes(deferredUnitID); string(got) != string(changed) { + t.Error("the simulated update is not read back") + } + // What a simulation cannot hold is refused, before it touches anything. + if err := w.DeleteUnit(deferredUnitID); !errors.Is(err, ErrSimulatedWrite) { + t.Errorf("delete during a simulation: %v, want ErrSimulatedWrite", err) + } + if err := w.FlushDeferredWrites(); !errors.Is(err, ErrSimulatedWrite) { + t.Errorf("flush during a simulation: %v, want ErrSimulatedWrite", err) + } + if onDisk, _ := os.ReadFile(unitPath); string(onDisk) != string(stored) { + t.Error("a simulated write reached disk") + } + + discard() + if got, _ := w.reader.GetRawUnitBytes(deferredUnitID); string(got) != string(stored) { + t.Error("the stored unit is not read back once the simulation is discarded") + } + if onDisk, _ := os.ReadFile(unitPath); string(onDisk) != string(stored) { + t.Error("discarding the simulation wrote") + } + if got := transactionID(t, w); got != "before-the-run" { + t.Errorf("LastTransactionID = %q after a simulation", got) + } + // And the writer writes again once it is over. + if err := w.UpdateRawUnit(deferredUnitID, changed); err != nil { + t.Fatalf("update after the simulation: %v", err) + } + if onDisk, _ := os.ReadFile(unitPath); string(onDisk) != string(changed) { + t.Error("a write after the simulation did not land") + } +} From 34cbdd9a46b014d6c8c5d4d472cfc9fd9da2f283 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:34:56 +0000 Subject: [PATCH 15/50] docs(proposal): the complete chess report corrects this document's own reading of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full `diag session-report` supersedes the partial one recorded earlier (1,784 calls and 655.7M against 1,771 and 653.5M; the shares are unchanged) and shows the whole costliest-results list rather than its first entry. That entry was a skill file, and the previous commit generalised from it the way BENCH-001 had. The list says otherwise. Orientation at build scale is the agent re-reading its own work. Own MDL source in small `sed` ranges is 28% of the ten costliest results and subagent artifacts 30%, against the skills' 22%; the lookup counts say the same at full scale, 559 file reads against 101 doc lookups, 154 of them repeats, and 984 of 1,653 Bash calls are neither mxcli nor git nor build. One 17-line range of a generated file was re-read by 504 later calls. That is a different problem from item 3b with a different fix — not going back at all — so item 3d is added for it. The cost is also a long tail, which inverts the method this document has used throughout: the top ten are 12.2M of 134.6M, 9% of the tool re-read, against 75% in BENCH-001 run 3. At build scale there is no whale to harpoon, so only the structural levers (fewer calls, and not carrying the conversation between phases) touch the bill. Delegation is priced for the first time: 10 subagent calls produced 3 of the 10 costliest results, 3.6M, because a subagent saves its own context and not the caller's unless the handback is small. Item 6 now carries that cost line. Two defects named by the retry list are added to item 4c: `check` rejecting the Live Sync "On tick" property on a widget mxcli itself writes, and `Failed to fetch` on the first assertion after `run restart` — the worst chain in the session at 5 retries, which says whatever `run restart` returns on is not readiness. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MgcYSQrLLbUcnAMaCHpyqQ --- .../PROPOSAL_agent_loop_efficiency.md | 125 +++++++++++++++--- 1 file changed, 109 insertions(+), 16 deletions(-) diff --git a/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md b/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md index aeb70256a1..3ce39ecf01 100644 --- a/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md +++ b/docs/11-proposals/PROPOSAL_agent_loop_efficiency.md @@ -31,7 +31,12 @@ confirms `N x S` across three orders of magnitude and resizes the document: tool output is 21% of a real build's bill against 35% in the benchmark, and the costliest single skill read is 0.26% of it, so BENCH-001 over-weights every lever aimed at what a tool returns. The conversation itself is ~79%, which -promotes phase splitting above all of them (§"A fourth project"). +promotes phase splitting above all of them (§"A fourth project"). The complete report +then corrects this document's own reading of that build: orientation at build +scale is the agent re-reading files it wrote itself (559 file reads against 101 +doc lookups), the cost is a long tail rather than a few whales (the top ten are +9% of the tool re-read against 75% in the benchmark), and delegation's handback +is priced for the first time (§"Correction"). ## The report @@ -86,7 +91,7 @@ away, because the way each assumption survived is the reusable part. | mxcli-ledger | — | 5 days | 301 | `check` 250 (83%) | | CapTrackV6 | 11.14.0 | 2 h 45 | 585 | `exec` 180, `-c` 111, `check` 90 | | mxcli-demo-2 | 11.13.0 | 5 h 07 | 408 | `-c` 120, `exec` 85, `check` 63 | -| chess (2026-10-21) | — | 20 h 44 | 931 | `exec` 201, `run wait` 95, `check` 86, `docker check` 84 | +| chess (2026-10-21) | — | 26 h 04 | 936 | `exec` 201, `run wait` 95, `check` 86, `docker check` 84 | The fourth row is `diag session-report`, not `loop-report`, and postdates ako/mxcli#629, so its counts are not inflated by mxcli's own child processes. @@ -466,17 +471,22 @@ it. | | | |---|---:| -| elapsed / active | 20 h 44 / 9 h 02 | -| model calls | 1,771 (1,179 main, rest across 10 subagents) | -| tool calls | 1,868 (Bash 1,644) | -| mxcli invocations | 931: `exec` 201, `run wait` 95, `check` 86, `docker check` 84, `syntax` 77, `oql` 61 | -| cache reads | 653.5 M | -| cache writes / output / uncached in | 5.8 M / 1.2 M / 3.6 k | -| avg context per call | 372 k (main-thread median 438 k; 325 calls over 600 k) | -| tool output | 958 k, re-read cost **134 M (21% of cache reads)** | -| categories | orientation 52%, writing 17%, applying 7%, validating 7%, verifying 5%, retries 4% | -| failures / retry chains | 125 / 64, 61 resolved | -| compactions | 2 | +| elapsed / active | 26 h 04 / 9 h 07 | +| model calls | 1,784 (main 1,192, rest across 10 subagents) | +| tool calls | 1,878 (Bash 1,653 — of which 984 are neither mxcli, git nor build) | +| mxcli invocations | 936: `exec` 201, `run wait` 95, `check` 86, `docker check` 84, `syntax` 77, `oql` 61, `run restart` 52, `-c describe` 50 | +| cache reads | 655.7 M | +| cache writes / output / uncached in | 6.7 M / 1.2 M / 3.6 k | +| avg context per call | 371 k (main-thread median 438 k; 325 calls over 600 k) | +| tool output | 964 k, re-read cost **134.6 M (21% of cache reads)** | +| categories | orientation 972 (52%), write 310 (17%), validate 127 (7%), apply 131 (7%), verify 102 (5%), retry 84 (4%), diagnosis 62 (3%), delegate 10 (1%) | +| lookups | 559 file reads (154 repeats: `FINDINGS.md` ×23, `summary.txt` ×8, `.gitignore` ×7) against 101 doc lookups | +| failures / retry chains | 125 / 64, 61 resolved (84 retry calls) | +| compactions | 3 | + +(These supersede an earlier partial report of the same session — 1,771 calls, +653.5 M — which was taken before it finished. Every share below is unchanged by +the difference.) ### The N × S law now holds across three orders of magnitude @@ -484,7 +494,7 @@ it. |---|---:|---:|---:|---:| | BENCH-001 run 3 | 59 | 108 k | 6 M | 6.2 M | | the original report | 523 | 435 k | 228 M | 228 M | -| chess | 1,771 | 372 k | 659 M | 653.5 M | +| chess | 1,784 | 371 k | 662 M | 655.7 M | Three sessions, 100× apart, each within a few per cent of the product of its two numbers. Nothing else in this document is as well established as this, and @@ -516,6 +526,88 @@ from here on. BENCH-002 at 2× the surface does not fix this; it would take a brief two orders of magnitude larger, which is a different instrument, not a bigger brief. +### Correction: orientation is the agent re-reading its own work, not reading the skills + +The partial report showed only the costliest result, which was a skill file, and +this section's first draft generalised from it the way the benchmark had. The +complete top ten says something else: + +| what | top-ten re-read | share of top ten | +|---|---:|---:| +| the agent's **own MDL source**, in small `sed` ranges | 3.4 M | 28% | +| **subagent artifacts** read back into the main thread | 3.6 M | 30% | +| a `print.txt` range | 1.4 M | 11% | +| **skills** (`bootstrap-app`, `write-microflows`) | 2.7 M | 22% | +| a `syntax` sweep loop | 1.1 M | 9% | + +The lookup counts say the same thing at full scale: **559 file reads against 101 +doc lookups**, with 154 of the file reads repeats — `FINDINGS.md` 23 times, +`summary.txt` 8, `.gitignore` 7, `CLAUDE.md` 5. And 984 of 1,653 Bash calls are +neither mxcli nor git nor build: they are the `sed`/`cat`/`node` machinery of +looking at files. + +So 52% of calls being orientation does **not** mean the agent is studying the +documentation. It means it is re-reading files it wrote minutes earlier, in +17-to-200-line slices, because it does not remember what it put in them. Two of +the ten are ranges of one file, `86-admin-pages.mdl`; one of those is 17 lines +re-read by 504 later calls. + +That is a different problem from item 3b and it has a different fix. A skill is +read once and is the same for everyone, so indexing it helps everyone a little. +A generated source file is read dozens of times by one session and is unique to +it, so the fix is **not having to go back**: write the file and keep a summary of +what went in it, or ask mxcli what is in the model (`-c describe`, 50 calls +already) instead of re-reading the script that put it there. The `brain` store +is the existing shape of that idea. + +### The cost is a long tail, which is the opposite of the benchmark + +The top ten here are **12.2 M of 134.6 M — 9% of the tool re-read and 1.9% of the +bill.** In BENCH-001 run 3 the top ten were 75% of the re-read. + +Every optimisation this document has proposed so far is of the form "find the +expensive result and make it smaller". At benchmark scale that works because a +handful of results are most of the cost. At build scale there is no whale to +harpoon: the bill is 559 file reads and 1,878 tool calls, none individually +large. Only the two structural levers touch a long tail — fewer calls (N), and +not carrying the conversation between phases (item 3c). + +### Delegation is not free, and the report prices it + +Three of the ten costliest results are subagent output read back into the main +thread — two `tool-results/*.txt` at 13 k and 9.0 k, plus a `PRD.md` read, 3.6 M +together. Lever 4 (keep long investigations out of the main conversation) is +sound, but the handback is a large result that the main thread then re-reads for +the rest of the session. **A subagent saves its own context, not the caller's, +unless what comes back is small.** Ten delegations produced three of the ten +most expensive results; the lever's own cost line belongs next to its benefit. + +### Two mxcli defects the retry list names outright + +The retry chains are specific enough to act on: + +| retries/chains | error | +|---|---| +| 5/1 | `FAIL no console errors (alice): [Client] Failed to fetch` — after `run restart` | +| 4/1 | `assert-text "Round N of N" not found` | +| 3/1 | `ERROR: The project cannot be deployed, because it contains errors` (mxbuild) | +| 3/2 | `local runtime: build failed: An error occurred while compiling Java actions` | +| 3/1 | REST security assertion | +| 2/2 | `narrate.js: overlay does not conform` | + +Two are ours rather than the app author's: + +1. **`mxcli check` reports `"Select the action that refreshes the data (On tick)."` + at Live Sync `'liveSync'`** — a widget mxcli itself writes, missing a property + its own check then rejects. That is a write-path defect, not a user error. +2. **The worst single chain, 5 retries, is `Failed to fetch` immediately after + `run restart`** — the app is not actually ready when `run restart` returns, so + the first browser assertion after it fails. `run wait` exists (95 calls); + whatever `run restart` returns on is not the same readiness. + +Both are item 4c work, and both are better evidence than anything BENCH-001 +produced, because they recurred under real load. + ### What that promotes If 79% of the bill is the conversation, then the lever is **not having a 440 k @@ -1150,12 +1242,13 @@ baseline is the first thing to record wherever it does. | 2e | **App lifecycle as commands** — `run --local --detach`, `run status`, `run wait`, `run stop`, `run restart`, taught in the run-local/run-app skills and the generated gate list — **shipped** | M | ~25–28% of all tool calls in measured sessions were hand-rolled `nohup`/poll/`pkill` loops; each becomes one call, and `exec … && run wait` is the per-change chain | | 3 | Publish the canonical `&&` chain in `projectGates` + skills (lever 1) | XS | the 5–8 → 1–2 collapse, with nothing built | | 3c | **One session per phase, each resumed from `brain brief` / `brain plan`** — taught in the generated CLAUDE.md and the bootstrap skill, not built | S | **the largest lever measured.** 79% of a 1,771-call build's bill is the conversation, not tool output, and splitting N calls across k near-empty phases is `N × S / 2k` — a factor of k, not a percentage. Its price is one re-orientation per boundary, which is what items 3b/4/4b actually buy (§"A fourth project") | +| 3d | **Stop the agent re-reading its own output** — a written-file summary it can consult, and `-c describe` over the model instead of `sed` over the script that built it | M | **the largest orientation shape at build scale**, and not the one 3b addresses: 559 file reads against 101 doc lookups, 154 of them repeats, and own-source ranges are 28% of the ten costliest results against the skills' 22%. One 17-line range was re-read by 504 later calls (§"Correction: orientation is the agent re-reading its own work") | | 3b | **Make consuming the skills cheap** — **first pass shipped 2026-10-10**: the generated CLAUDE.md now routes `syntax` before any skill, and `scripts/skill-index.py` puts a verified line-numbered section index at the head of all 74 (`make check-skill-index` in CI). Not yet done: moving the syntax that skills restate into `syntax` itself — code blocks are only 28% of skill bytes, so that is a smaller prize than it looked | M | the largest measured token item, and it displaces item 4. **Measured, leaning negative**: across runs 2 and 3 the skills' share of the re-read bill halved (68% -> 37%) and `syntax` sweeps took the freed share (23%), leaving the total 26% worse at a reproducible 59-61 calls against one pre-change point at 47. Missing datum is the control: two runs of `9ce90f32`. The identified surgical fix is to narrow the routing line against speculative sweeps, keeping the index (§"Run 3"). **Resized 2026-10-22**: the shape it targets is 5.3% of the benchmark's bill and 0.26% of a real build's, so its value is no longer as a token lever but as what makes a phase boundary cheap (item 3c) | | 4 | Terse/delta output for `exec`, `syntax` and the noisy listings (lever 2) | M | **promoted back to co-equal with 3b**: run 1's ~15% was the one run where that was true. Runs 2-3 put mxcli's own stdout at ~39% of the re-read cost (`syntax` 23%, the two `DESCRIBE`s 16%) against skill reads' 37%, because the agent substitutes between the two sources (§"Item 4 is no longer the junior partner") | | 4b | **`syntax --digest `** — every signature for a doctype, no prose, one call | S | both post-change runs show the agent assembling exactly this by hand (`for t in …; do mxcli syntax $t; done`, 195 k + 105 k in run 3, plus a 90 k `syntax --json` dump). A cheaper sweep, rather than a rule against sweeping | -| 4c | **Remove the recurring retry chains** — the unspliceable `create or modify`, the CE1613 association-attribute page reference, `injecting test microflows`, the `brain init` routing gap | M | 14% of run 3's model calls were retries, all resolved in one, most of them mxcli's fault. The only listed lever that removes *calls* rather than bytes — though the chess build puts retries at 4.5%, so the benchmark oversizes this one too | +| 4c | **Remove the recurring retry chains** — the unspliceable `create or modify`, the CE1613 association-attribute page reference, `injecting test microflows`, the `brain init` routing gap | M | 14% of run 3's model calls were retries, all resolved in one, most of them mxcli's fault. The only listed lever that removes *calls* rather than bytes — though the chess build puts retries at 4.5%, so the benchmark oversizes this one too. It also names two of ours: `check` rejecting the Live Sync `On tick` property on a widget mxcli itself writes, and `Failed to fetch` on the first assertion after `run restart`, the worst chain in the session at 5 retries | | 5 | Tiered verification rule in the skills (lever 3) | S | stops the default path at the cheapest sufficient gate | -| 6 | Subagent trigger in the skills (lever 4) | XS | caps the worst tail | +| 6 | Subagent trigger in the skills (lever 4) | XS | caps the worst tail — but price the handback: 10 delegations produced 3 of the 10 costliest results in the chess build (3.6 M), because a subagent saves its own context, not the caller's, unless what comes back is small | | 7 | Workarounds → diagnostics and skills (lever 5) | M, ongoing | compounds across all future sessions | Item 1 first is deliberate. Items 2, 3, 5 and 6 are all XS-to-S and can ship From 1b1c23be483a549502c7c115a0e182ddcb4f404f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:37:56 +0000 Subject: [PATCH 16/50] fix: alter settings configuration accepts ExtraJvmParameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ALTER SETTINGS CONFIGURATION 'Default' ExtraJvmParameters = '…'` failed with "unknown configuration setting: ExtraJvmParameters", and CREATE CONFIGURATION with "unknown configuration property". The reader already read the field and the settings overlay already wrote it back; only the executor's key switches lacked the case, and DESCRIBE never printed it, so a described project replayed without it. Both statements now accept the key, and DESCRIBE emits it when set. Every supported Mendix version stores the property (no versionInfo entry in the Model SDK; a Studio Pro-authored 11.14 configuration carries it). Fixes mendixlabs/mxcli#131 (ako/mxcli#1225). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- ...gs-configuration-extra-jvm-parameters.json | 1 + .../skills/mendix/project-settings/SKILL.md | 19 ++- .../src/reference/settings/alter-settings.md | 12 +- ...31-alter-settings-extra-jvm-parameters.mdl | 32 +++++ .../modelsdk/settings_write_jvm_test.go | 58 +++++++++ mdl/executor/cmd_settings.go | 11 ++ .../cmd_settings_jvm_parameters_test.go | 114 ++++++++++++++++++ 7 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-configuration-extra-jvm-parameters.json create mode 100644 mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl create mode 100644 mdl/backend/modelsdk/settings_write_jvm_test.go create mode 100644 mdl/executor/cmd_settings_jvm_parameters_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-configuration-extra-jvm-parameters.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-configuration-extra-jvm-parameters.json new file mode 100644 index 0000000000..7ccadbfb3a --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-configuration-extra-jvm-parameters.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-22", "symptom": "`ALTER SETTINGS CONFIGURATION 'Default' ExtraJvmParameters = '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758'` → `Error: unknown configuration setting: ExtraJvmParameters` (create configuration: `unknown configuration property`), and `describe settings` never printed the field, so a described project replayed without it", "cause": "Only the executor's two hand-copied configuration key switches (alterSettingsConfiguration, createConfiguration) lacked the case. The reader already filled model.ServerConfiguration.ExtraJvmParameters (settings_read.go) and the overlay already wrote it back (settingsoverlay.ServerConfiguration) — on every settings write, so the value was preserved, just unreachable from MDL", "file": "mdl/executor/cmd_settings.go (alterSettingsConfiguration, createConfiguration, writeSettingsConfiguration); tests mdl/executor/cmd_settings_jvm_parameters_test.go, mdl/backend/modelsdk/settings_write_jvm_test.go", "insight": "For an 'unknown setting' report, check the three layers before designing anything: reader (settings_read.go), writer (settingsoverlay), executor switch. Here two of three were done and the fix was one case per switch plus a describe line. Version facts settled by the Model SDK (npm pack mendixmodelsdk, gen/settings.js): Configuration.extraJvmParameters has no versionInfo entry, so no gate; a Studio Pro-authored 11.14 configuration stores `ExtraJvmParameters` (string) beside `MaxJavaHeapSize`. Wrong turn to skip: replaying the regression with `create or modify configuration 'NewName'` on testdata/testapp-views (11.14) fails with \"3 element id(s) are used more than once\" — a separate, pre-existing bug (newServerConfiguration shallow-copies a sibling whose nested OpenTelemetry part keeps its $IDs; works on 11.13/11.6 fixtures), not this fix. `mxcli run --local` builds a fixed JVM argument list and does not apply the parameters — the model value is what Studio Pro uses", "refs": ["mendixlabs/mxcli#131", "ako/mxcli#1225"]} diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index 56d55aa836..4fddcf8731 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-308 | Commands | -| 309-336 | Common Patterns | -| 337-345 | Checklist | -| 346-350 | AfterStartupMicroflow Must Return Boolean | +| 32-319 | Commands | +| 320-347 | Common Patterns | +| 348-356 | Checklist | +| 357-361 | AfterStartupMicroflow Must Return Boolean | # Project Settings @@ -131,8 +131,19 @@ alter settings configuration 'Default' ( alter settings configuration 'Default' ( DatabaseUrl: 'newhost:5432' ); + +-- Server tab ▸ Extra JVM parameters, e.g. route the runtime through a proxy +alter settings configuration 'Default' ( + ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888' +); ``` +The configuration keys are `DatabaseType`, `DatabaseUrl`, `DatabaseName`, +`DatabaseUserName`, `DatabasePassword`, `HttpPortNumber`, `ServerPortNumber`, +`ApplicationRootUrl` and `ExtraJvmParameters`; `create configuration` takes the same. +`ExtraJvmParameters` is what Studio Pro passes to the JVM when it runs the app — +`mxcli run --local` starts the runtime itself and does not apply it. + `HttpPortNumber`, `ServerPortNumber`, `BcryptCost`, `DefaultTaskParallelism` and `WorkflowEngineParallelism` and `DecimalScale` are Integer-typed; `AllowUserMultipleSessions`, `EnableDataStorageOptimisticLocking`, diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index eea37b3171..c339ca517e 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -30,7 +30,7 @@ Modifies project settings by category. Each category has its own syntax and avai **MODEL** settings control application-level behavior such as the after-startup microflow, hashing algorithm, and Java version. -**CONFIGURATION** settings control named runtime configurations. Each project can have multiple configurations (e.g., `default`, `staging`, `production`). Settings include database type, database URL, HTTP port number, and other runtime parameters. The configuration name must be enclosed in single quotes. +**CONFIGURATION** settings control named runtime configurations. Each project can have multiple configurations (e.g., `default`, `staging`, `production`). The keys are `DatabaseType`, `DatabaseUrl`, `DatabaseName`, `DatabaseUserName`, `DatabasePassword`, `HttpPortNumber`, `ServerPortNumber`, `ApplicationRootUrl` and `ExtraJvmParameters` (Studio Pro's Server ▸ Extra JVM parameters). The configuration name must be enclosed in single quotes. **CONSTANT** settings override the default value of a project constant within a specific runtime configuration. The constant is named as MDL names a constant everywhere, `@Module.Name`; the configuration name is enclosed in single quotes. The older quoted constant name (`CONSTANT 'Module.Name'`) still parses and warns `MDL-DEPR085`; `mxcli fmt --upgrade` rewrites it. @@ -83,6 +83,16 @@ ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' ); ALTER SETTINGS CONFIGURATION 'production' ( DatabaseUrl: 'jdbc:postgresql://dbhost:5432/myapp' ); ``` +### Pass extra JVM parameters + +```sql +ALTER SETTINGS CONFIGURATION 'default' ( ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888' ); +``` + +The value is stored as written and is what Studio Pro passes to the JVM when it +runs the app. `mxcli run --local` starts the runtime JVM itself and does not +apply it. + ### Override a constant in a configuration ```sql diff --git a/mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl b/mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl new file mode 100644 index 0000000000..036987860a --- /dev/null +++ b/mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- Bug mendixlabs/mxcli#131 (ako/mxcli#1225): ALTER SETTINGS CONFIGURATION does not +-- support ExtraJvmParameters +-- +-- Symptom: +-- ./mxcli -p app.mpr -c "ALTER SETTINGS CONFIGURATION 'Default' ExtraJvmParameters = '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758'" +-- Error: unknown configuration setting: ExtraJvmParameters +-- +-- ExtraJvmParameters is Studio Pro's Configuration ▸ Server ▸ "Extra JVM +-- parameters" — how the runtime JVM is given a proxy. The reader already read it +-- and the overlay already wrote it back; only the executor's key switch refused +-- it, in ALTER SETTINGS CONFIGURATION and CREATE CONFIGURATION alike, and +-- DESCRIBE SETTINGS left it out, so a described project replayed without it. +-- +-- Fix: both statements accept the key, and DESCRIBE emits it when it is set. +-- +-- Manual verification: +-- mxcli exec 131-alter-settings-extra-jvm-parameters.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe settings configuration 'Default'" +-- → ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758' +-- +-- Note: this sets what Studio Pro passes when it runs the app. `mxcli run --local` +-- starts the runtime JVM itself and does not apply the configuration's parameters. + +alter settings configuration 'Default' ( + ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758' +); + +-- The form DESCRIBE emits: on an existing configuration it is a patch. +create or modify configuration 'Default' ( + ExtraJvmParameters: '-Dhttp.proxyHost=proxy.example.invalid -Dhttp.proxyPort=3128' +); diff --git a/mdl/backend/modelsdk/settings_write_jvm_test.go b/mdl/backend/modelsdk/settings_write_jvm_test.go new file mode 100644 index 0000000000..5d1d424a4a --- /dev/null +++ b/mdl/backend/modelsdk/settings_write_jvm_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" +) + +// mendixlabs/mxcli#131: the value `alter settings configuration 'Default' +// (ExtraJvmParameters: '…')` sets has to reach the stored configuration under the +// key Studio Pro reads — "ExtraJvmParameters", as a Studio Pro-authored 11.14 +// project stores it — and read back through the backend. +func TestUpdateProjectSettings_ExtraJvmParametersPersist(t *testing.T) { + const params = "-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758" + proj := copyFixture(t) + seeded := seedConfiguration(t, proj) + if _, ok := seeded["ExtraJvmParameters"]; !ok { + t.Fatalf("fixture precondition: the Default configuration does not store ExtraJvmParameters: %#v", seeded) + } + + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + ps, err := b.GetProjectSettings() + if err != nil { + t.Fatalf("GetProjectSettings: %v", err) + } + ps.Configuration.Configurations[0].ExtraJvmParameters = params + if err := b.UpdateProjectSettings(ps); err != nil { + t.Fatalf("UpdateProjectSettings: %v", err) + } + if err := b.Disconnect(); err != nil { + t.Fatalf("disconnect: %v", err) + } + + after := readConfiguration(t, proj) + if got := after["ExtraJvmParameters"]; got != params { + t.Errorf("stored ExtraJvmParameters = %#v, want %q", got, params) + } + // The write is an overlay: the seeded data beside it survives. + if after["OpenAdminPort"] != seeded["OpenAdminPort"] { + t.Errorf("OpenAdminPort = %#v, want %#v", after["OpenAdminPort"], seeded["OpenAdminPort"]) + } + + b2 := New() + if err := b2.Connect(proj); err != nil { + t.Fatalf("reconnect: %v", err) + } + t.Cleanup(func() { _ = b2.Disconnect() }) + ps2, err := b2.GetProjectSettings() + if err != nil { + t.Fatalf("GetProjectSettings(2): %v", err) + } + if got := ps2.Configuration.Configurations[0].ExtraJvmParameters; got != params { + t.Errorf("ExtraJvmParameters read back as %q, want %q", got, params) + } +} diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index fd71b28171..73135dcff6 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -747,6 +747,12 @@ func alterSettingsConfiguration(ctx *ExecContext, ps *model.ProjectSettings, stm cfg.ServerPortNumber = v case "ApplicationRootUrl": cfg.ApplicationRootUrl = valStr + case "ExtraJvmParameters": + // Studio Pro's Configuration ▸ Server ▸ "Extra JVM parameters", e.g. a + // proxy: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888'. Stored + // verbatim; every supported Mendix version carries the property + // (mendixlabs/mxcli#131). + cfg.ExtraJvmParameters = valStr default: return mdlerrors.NewUnsupported("unknown configuration setting: " + key) } @@ -943,6 +949,8 @@ func createConfiguration(ctx *ExecContext, stmt *ast.CreateConfigurationStmt) er newCfg.ServerPortNumber = v case "ApplicationRootUrl": newCfg.ApplicationRootUrl = valStr + case "ExtraJvmParameters": + newCfg.ExtraJvmParameters = valStr default: return mdlerrors.NewUnsupported("unknown configuration property: " + key) } @@ -1083,6 +1091,9 @@ func writeSettingsConfiguration(ctx *ExecContext, cfg *model.ServerConfiguration if cfg.ApplicationRootUrl != "" { parts = append(parts, " ApplicationRootUrl: "+mdlQuoted(cfg.ApplicationRootUrl)) } + if cfg.ExtraJvmParameters != "" { + parts = append(parts, " ExtraJvmParameters: "+mdlQuoted(cfg.ExtraJvmParameters)) + } if cfg.DatabasePassword != "" { fmt.Fprintf(ctx.Output, "-- DatabasePassword is set in configuration %s and is not printed; "+ "add `DatabasePassword: '…'` to set it on a project that lacks it.\n", mdlQuoted(cfg.Name)) diff --git a/mdl/executor/cmd_settings_jvm_parameters_test.go b/mdl/executor/cmd_settings_jvm_parameters_test.go new file mode 100644 index 0000000000..45e15c5939 --- /dev/null +++ b/mdl/executor/cmd_settings_jvm_parameters_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#131 (ako/mxcli#1225): Studio Pro's Configuration ▸ Server tab +// has an "Extra JVM parameters" field — the way to give the runtime JVM a proxy — +// and the settings reader already read it, but ALTER SETTINGS CONFIGURATION +// answered "unknown configuration setting: ExtraJvmParameters". + +const proxyJvmParameters = "-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758" + +// jvmParamsCtx returns an executor over one Default configuration, and a pointer +// to the settings the last write handed the backend. +func jvmParamsCtx(t *testing.T, cfg *model.ServerConfiguration) (*ExecContext, **model.ProjectSettings) { + t.Helper() + ps := &model.ProjectSettings{Configuration: &model.ConfigurationSettings{ + Configurations: []*model.ServerConfiguration{cfg}, + }} + var written *model.ProjectSettings + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { return ps, nil }, + UpdateProjectSettingsFunc: func(p *model.ProjectSettings) error { written = p; return nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + return ctx, &written +} + +// The statement exactly as reported, through the real parser: the old +// `Key = value` spelling, which still runs. +func TestAlterSettingsConfiguration_ExtraJvmParameters_AsReported(t *testing.T) { + cfg := &model.ServerConfiguration{Name: "Default", DatabaseType: "Hsqldb", HttpPortNumber: 8080} + ctx, written := jvmParamsCtx(t, cfg) + + prog, errs := visitor.Build("ALTER SETTINGS CONFIGURATION 'Default' ExtraJvmParameters = '" + proxyJvmParameters + "'") + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + stmt := findStmt[*ast.AlterSettingsStmt](t, prog, "") + if err := alterSettings(ctx, stmt); err != nil { + t.Fatalf("alter settings configuration refused ExtraJvmParameters: %v", err) + } + if *written == nil { + t.Fatal("nothing was written") + } + if got := (*written).Configuration.Configurations[0].ExtraJvmParameters; got != proxyJvmParameters { + t.Errorf("ExtraJvmParameters = %q, want %q", got, proxyJvmParameters) + } +} + +func TestCreateConfiguration_ExtraJvmParameters(t *testing.T) { + ctx, written := jvmParamsCtx(t, &model.ServerConfiguration{Name: "Default"}) + err := createConfiguration(ctx, &ast.CreateConfigurationStmt{ + Name: "Proxied", + Properties: map[string]any{"ExtraJvmParameters": proxyJvmParameters}, + }) + if err != nil { + t.Fatalf("create configuration refused ExtraJvmParameters: %v", err) + } + cfgs := (*written).Configuration.Configurations + if len(cfgs) != 2 || cfgs[1].Name != "Proxied" || cfgs[1].ExtraJvmParameters != proxyJvmParameters { + t.Fatalf("configurations = %+v, want a Proxied configuration carrying the JVM parameters", cfgs) + } +} + +// DESCRIBE has to carry the parameters, or a described project replays without +// them. The value holds a quote, so the round trip also proves the escape. +func TestDescribeSettings_ExtraJvmParametersRoundTrips(t *testing.T) { + const params = "-Dhttps.proxyHost=127.0.0.1 -Dapp.greeting='hi'" + cfg := &model.ServerConfiguration{ + Name: "Default", DatabaseType: "Hsqldb", DatabaseName: "default", + HttpPortNumber: 8080, ServerPortNumber: 8090, ExtraJvmParameters: params, + } + ctx, _ := jvmParamsCtx(t, cfg) + buf := ctx.Output.(*bytes.Buffer) + assertNoError(t, describeSettings(ctx, "Default")) + out := buf.String() + if !strings.Contains(out, "ExtraJvmParameters:") { + t.Fatalf("describe does not carry ExtraJvmParameters:\n%s", out) + } + + // Replay onto a project whose Default has no parameters: they come back. + stmt := findStmt[*ast.CreateConfigurationStmt](t, reparse(t, out), out) + target := &model.ServerConfiguration{Name: "Default", DatabaseType: "Hsqldb", DatabaseName: "default", + HttpPortNumber: 8080, ServerPortNumber: 8090} + tctx, _ := jvmParamsCtx(t, target) + assertNoError(t, createConfiguration(tctx, stmt)) + if !reflect.DeepEqual(*target, *cfg) { + t.Errorf("replaying describe output did not reproduce the configuration:\nwant %+v\ngot %+v\n%s", *cfg, *target, out) + } +} + +// Empty parameters are the default and are not printed, like an empty root URL — +// a blank project's describe output stays as it was. +func TestDescribeSettings_EmptyExtraJvmParametersNotPrinted(t *testing.T) { + ctx, _ := jvmParamsCtx(t, &model.ServerConfiguration{Name: "Default", DatabaseType: "Hsqldb"}) + buf := ctx.Output.(*bytes.Buffer) + assertNoError(t, describeSettings(ctx, "Default")) + if out := buf.String(); strings.Contains(out, "ExtraJvmParameters") { + t.Errorf("empty ExtraJvmParameters printed:\n%s", out) + } +} From d3e8fb8b16f51cfb4c9074f7a3b260fe625bc85f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:41:48 +0000 Subject: [PATCH 17/50] fix(alter page): a widget-name miss names the object-list item addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last row of the ako/mxcli#1326 (mendixlabs/mxcli#1453) repro still failed after the item address landed. CREATE accepts `group grp1 (…)` and drops the name — an Accordion group stores none — so the next statement is `insert into grp1 { … }`, and on a page that also holds a data grid the miss pointed at the grid: widget "grp1" not found. DataGrid2 columns are addressed by a derived name (the bound attribute, or the caption), not the name written in MDL — available columns: Name (run DESCRIBE PAGE to confirm) The miss now lists every item address on the page, spelled as typed, before the column hint: widget "grp1" not found. An object-list item (an Accordion group, a pop-up menu item) stores no name, so a name written on one in CREATE is not kept — address it on its widget: acc1 item@1 = acc1 item('One'). DataGrid2 columns are addressed by … Listed are the widgets `item` resolves (exactly one object list), not a list keyed `columns`, which the column hint covers by derived name. check -p and exec share the message. Control: the new test fails on main with the message above, for insert into, set and drop. Fixed, on a blank 11.12.5 app the reporter's page gives the new message at check and exec; the address it names applies and `docker check --no-update-widgets` reports 0 errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PALuhnbBpSb16e2a9A44z9 --- ...-unnamed-item-miss-names-item-address.json | 1 + ...PROPOSAL_alter_object_list_item_address.md | 5 +- ...-unnamed-group-miss-names-item-address.mdl | 46 ++++++++ mdl/backend/pagemutator/mutator.go | 10 +- .../pagemutator/object_list_item_miss_test.go | 100 ++++++++++++++++++ mdl/backend/pagemutator/objectlist_items.go | 56 ++++++++++ 6 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-22-unnamed-item-miss-names-item-address.json create mode 100644 mdl-examples/bug-tests/1453-alter-unnamed-group-miss-names-item-address.mdl create mode 100644 mdl/backend/pagemutator/object_list_item_miss_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-unnamed-item-miss-names-item-address.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-unnamed-item-miss-names-item-address.json new file mode 100644 index 0000000000..28a4dd4383 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-unnamed-item-miss-names-item-address.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-22", "symptom": "`alter page … { insert into grp1 { … }; }` for an Accordion group named in CREATE (`group grp1 (…)`), on a page that also holds a data grid: widget \"grp1\" not found. DataGrid2 columns are addressed by a derived name (the bound attribute, or the caption), not the name written in MDL — available columns: Name (run DESCRIBE PAGE to confirm). The group is addressable (`acc1 item@1`), but nothing said so", "cause": "widgetNotFoundError's only hint for unnamed things was the DataGrid 2 column list; the object-list item address (#1453) shipped without joining the miss. CREATE accepts a name on an item and drops it (no MDL-DEPR005 for def-driven item keywords — the visitor has no widget registry), so the name-based statement is the natural next one", "file": "`mdl/backend/pagemutator/mutator.go` (widgetNotFoundError); `mdl/backend/pagemutator/objectlist_items.go` (objectListItemMissHint, objectListItemAddresses)", "insight": "When an address form for unnamed nodes ships, the miss message is part of it: run the reporter's own failing statement against the new build, not only the new form — every new form here worked and the original row still pointed elsewhere. The hint lists only widgets `item` can resolve (exactly one object list) and skips a list keyed `columns`, the rule findInWidgetChildren uses; a DataGrid 2 test fixture without `Type: Object, IsList: true` on its columns would not exercise that skip (gridWithCaptionColumns omits it), so the test builds the shipped shape. check -p and exec share the message, so one fix covers both. Still open: a CREATE-time warning for a name on a def-driven item, which needs the registry at check time", "refs": ["mendixlabs/mxcli#1453", "ako/mxcli#1326"]} diff --git a/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md b/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md index 96f3475174..271480e591 100644 --- a/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md +++ b/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md @@ -127,7 +127,10 @@ resolver for every selector form; INSERT INTO an item's body or slot, REPLACE / DROP of a slot, DROP of an item, SET of an item property; the hidden-slot refusal, Open Question 2 answered as "refuse"). Measuring the SET path found that a mode switch must also keep the texts it hides or shows as -CREATE stores them, and does. +CREATE stores them, and does. A widget-name miss lists the item addresses on +the page (`acc1 item@1 = acc1 item('One')`), since CREATE accepts a name on an +item and drops it — the reporter's `insert into grp1` had been answered with +the DataGrid 2 column hint alone. Not done: inserting a NEW item (`insert after acc1 item@1 { group … }`) is refused; step 4's selector rename was not needed; legacy table cells stay diff --git a/mdl-examples/bug-tests/1453-alter-unnamed-group-miss-names-item-address.mdl b/mdl-examples/bug-tests/1453-alter-unnamed-group-miss-names-item-address.mdl new file mode 100644 index 0000000000..0697735db6 --- /dev/null +++ b/mdl-examples/bug-tests/1453-alter-unnamed-group-miss-names-item-address.mdl @@ -0,0 +1,46 @@ +mdl 1; +-- mendixlabs/mxcli#1453 / ako/mxcli#1326, the reporter's last row: CREATE +-- accepts a name on an Accordion group (`group grp1 (…)`) and drops it — the +-- group stores none — so the natural next statement misses: +-- +-- alter page Bug1453c.Repro { insert into grp1 { … }; }; +-- +-- On a page that also holds a data grid the miss named only the grid: +-- +-- widget "grp1" not found. DataGrid2 columns are addressed by a derived +-- name (the bound attribute, or the caption), not the name written in MDL — +-- available columns: Name (run DESCRIBE PAGE to confirm) +-- +-- It now names the address that resolves (check -p and exec alike): +-- +-- widget "grp1" not found. An object-list item (an Accordion group, a pop-up +-- menu item) stores no name, so a name written on one in CREATE is not kept — +-- address it on its widget: acc1 item@1 = acc1 item('One'). DataGrid2 +-- columns are addressed by … available columns: Name … +-- +-- Expected: the ALTER below — the address the miss names — applies, describe +-- shows btnNew in the group after btnInGroup, and +-- `mxcli docker check --no-update-widgets` reports 0 errors. + +create module Bug1453c; + +create persistent entity Bug1453c.Item (Name: string(50)); + +create page Bug1453c.Repro (title: 'Repro', layout: Atlas_Core.Atlas_Default) { + accordion acc1 (advancedMode: false, collapsible: true, expandBehavior: 'singleExpanded', animate: true, showIcon: 'right', animateIcon: true) { + group grp1 (headerRenderMode: 'text', headerText: 'One') { + actionbutton btnInGroup (Caption: 'Inside group', Action: close page) + } + } + datagrid dg1 (DataSource: database from Bug1453c.Item) { + column (Attribute: Name) { + actionbutton btnInColumn (Caption: 'Inside column', Action: close page) + } + } +}; + +alter page Bug1453c.Repro { + insert into acc1 item@1 { actionbutton btnNew (Caption: 'New', Action: close page) } +}; + +describe page Bug1453c.Repro; diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index b13f8a3466..29855f4150 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -2127,16 +2127,20 @@ func (m *Mutator) widgetNotFoundError(name string) error { if err := m.navItemAddressError(name); err != nil { return err } + msg := fmt.Sprintf("widget %q not found", name) + if hint := objectListItemMissHint(m.rawData); hint != "" { + msg += ". " + hint + } var cols []string collectColumnNamesBson(m.rawData, &cols) if len(cols) > 0 { return fmt.Errorf( - "widget %q not found. DataGrid2 columns are addressed by a derived name "+ + "%s. DataGrid2 columns are addressed by a derived name "+ "(the bound attribute, or the caption), not the name written in MDL — "+ "available columns: %s (run DESCRIBE PAGE to confirm)", - name, formatColumnNameList(cols)) + msg, formatColumnNameList(cols)) } - return fmt.Errorf("widget %q not found%s", name, m.dataViewFooterHint()) + return fmt.Errorf("%s%s", msg, m.dataViewFooterHint()) } // formatColumnNameList renders derived column names for an error message: each diff --git a/mdl/backend/pagemutator/object_list_item_miss_test.go b/mdl/backend/pagemutator/object_list_item_miss_test.go new file mode 100644 index 0000000000..83be386736 --- /dev/null +++ b/mdl/backend/pagemutator/object_list_item_miss_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#1326 (mendixlabs/mxcli#1453), the reporter's last row: CREATE +// accepts `group grp1 (…)` and drops the name, because an Accordion group +// stores none, so `insert into grp1 { … }` misses. The miss on a page that also +// held a data grid answered +// +// widget "grp1" not found. DataGrid2 columns are addressed by a derived name +// (the bound attribute, or the caption), not the name written in MDL — +// available columns: Name (run DESCRIBE PAGE to confirm) +// +// which sends the author to the grid. The group IS addressable — `acc1 item@1`, +// `acc1 item('One')` — and the miss has to say so. + +// realGrid is gridWithCaptionColumns with its `columns` declared the way the +// shipped DataGrid 2 declares it — an Object list — so the miss has to tell +// the grid's columns from an item list rather than miss them by shape. +func realGrid(name string, captions ...string) bson.D { + g := gridWithCaptionColumns(name, captions...) + pt := bsonnav.DGetArrayElements(bsonnav.DGet(bsonnav.DGetDoc(bsonnav.DGetDoc(g, "Type"), "ObjectType"), "PropertyTypes"))[0].(bson.D) + vt := bsonnav.DGetDoc(pt, "ValueType") + vt = append(bson.D{{Key: "Type", Value: "Object"}, {Key: "IsList", Value: true}}, vt...) + bsonnav.DSet(pt, "ValueType", vt) + return g +} + +func groupAndGridPage() bson.D { + return makeRawPage( + accordionWithGroups( + accordionGroup("One", nil, []string{"btnInGroup"}), + accordionGroup("Two", nil, nil), + ), + realGrid("dg1", "Name"), + ) +} + +func TestWidgetNotFound_NamesTheItemAddresses(t *testing.T) { + for _, tc := range []struct { + op string + run func(m *Mutator) error + }{ + {"insert into", func(m *Mutator) error { + return m.InsertWidget("grp1", "", backend.InsertPosition("into"), []pages.Widget{&pages.DynamicText{}}) + }}, + {"set", func(m *Mutator) error { return m.SetWidgetProperty("grp1", "headerText", "x") }}, + {"drop", func(m *Mutator) error { return m.DropWidget([]backend.WidgetRef{{Widget: "grp1"}}) }}, + } { + t.Run(tc.op, func(t *testing.T) { + m := New(groupAndGridPage(), model.ID("u"), &stubWidgetDeps{}) + err := tc.run(m) + if err == nil { + t.Fatal("grp1 resolved, but no widget or item on the page is named grp1") + } + msg := err.Error() + for _, want := range []string{ + `widget "grp1" not found`, + // what would hit, spelled as the author types it + "acc1 item@1", "acc1 item('One')", "acc1 item@2", "acc1 item('Two')", + // and the grid's columns are still listed + "available columns: Name", + } { + if !strings.Contains(msg, want) { + t.Errorf("miss does not name %q:\n%s", want, msg) + } + } + if strings.Contains(msg, "dg1 item@") { + t.Errorf("the grid's columns are addressed by name, not as items:\n%s", msg) + } + }) + } +} + +// Without an item list on the page, the miss stays as it was. +func TestWidgetNotFound_NoItemListNoItemHint(t *testing.T) { + m := New(makeRawPage(realGrid("dg1", "Name")), model.ID("u"), &stubWidgetDeps{}) + err := m.SetWidgetProperty("grp1", "Caption", "x") + if err == nil { + t.Fatal("grp1 resolved on a page that has no grp1") + } + if strings.Contains(err.Error(), "item@") { + t.Errorf("item addresses offered on a page with no item list:\n%s", err) + } + if !strings.Contains(err.Error(), "available columns: Name") { + t.Errorf("columns no longer listed:\n%s", err) + } +} diff --git a/mdl/backend/pagemutator/objectlist_items.go b/mdl/backend/pagemutator/objectlist_items.go index 4369233d3c..010bf2121f 100644 --- a/mdl/backend/pagemutator/objectlist_items.go +++ b/mdl/backend/pagemutator/objectlist_items.go @@ -286,6 +286,62 @@ func objectListItemHint(owner *bsonWidgetResult, name string) string { "`%s item('')` addresses one by its text", lists[0].key, name, name) } +// objectListItemMissHint names every object-list item address on the page, for +// a widget lookup that missed. CREATE accepts a name on an item — `group grp1 +// (…)` — and drops it, since the item stores none, so `insert into grp1 { … }` +// is the natural next statement and can never resolve (ako/mxcli#1326). The +// miss said only how DataGrid 2 columns are addressed, sending the author to +// the grid. Listed: each widget whose one object list `item` addresses; not a +// DataGrid 2's columns, which the column hint lists by their derived names. +func objectListItemMissHint(page bson.D) string { + var addrs []string + collectObjectListItemAddresses(page, &addrs) + if len(addrs) == 0 { + return "" + } + return "An object-list item (an Accordion group, a pop-up menu item) stores no name, so a name " + + "written on one in CREATE is not kept — address it on its widget: " + strings.Join(addrs, "; ") +} + +func collectObjectListItemAddresses(node any, out *[]string) { + switch v := node.(type) { + case bson.D: + *out = append(*out, objectListItemAddresses(v)...) + for _, e := range v { + collectObjectListItemAddresses(e.Value, out) + } + case bson.A: + for _, e := range v { + collectObjectListItemAddresses(e, out) + } + } +} + +// objectListItemAddresses spells each item of a pluggable widget's one object +// list as the author types it, `acc1 item@1 = acc1 item('One')`; nil for any +// other node. +func objectListItemAddresses(w bson.D) []string { + name := bsonnav.DGetString(w, "Name") + if name == "" || !strings.Contains(widgetTypeName(w), "CustomWidget") { + return nil + } + lists := objectItemLists(w) + if len(lists) != 1 || lists[0].key == "columns" { + return nil + } + _, kinds := buildColumnPropKeyMap(w, lists[0].typeID) + var out []string + for i, el := range bsonnav.DGetArrayElements(bsonnav.DGet(lists[0].value, "Objects")) { + itemDoc, _ := el.(bson.D) + addr := fmt.Sprintf("%s item@%d", name, i+1) + if texts := objectItemTexts(itemDoc, kinds); len(texts) > 0 { + addr += fmt.Sprintf(" = %s item('%s')", name, strings.ReplaceAll(texts[0], "'", "''")) + } + out = append(out, addr) + } + return out +} + // ObjectListItemValues reports a stored item's values for visibility rules. // ok is false when itemRef does not resolve to an object-list item. func (m *Mutator) ObjectListItemValues(itemRef string) (backend.ObjectListItemValues, bool) { From ac715c600565510efbcc219fc13981e84faed11d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:41:48 +0000 Subject: [PATCH 18/50] docs(wiki): mutator-addressing digests the click-action keys and the item-address miss Two sentences in paragraphs that already held the class: a click action is spelled per type (a setter knowing one key refused every container while the catalog knew all three), and a miss's hint has to cover every unnamed kind on the page, not the first one fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PALuhnbBpSb16e2a9A44z9 --- docs-wiki/bug-patterns/mutator-addressing.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs-wiki/bug-patterns/mutator-addressing.md b/docs-wiki/bug-patterns/mutator-addressing.md index 1e2c654098..390078c592 100644 --- a/docs-wiki/bug-patterns/mutator-addressing.md +++ b/docs-wiki/bug-patterns/mutator-addressing.md @@ -34,7 +34,10 @@ caption is edited. Persisting the name the author wrote is not available either: there is no slot for it, and inventing one is the write-a-property-Studio-Pro- does-not-declare hazard. What is left is to make the derivation *visible* — warn at authoring time which name will be addressable, and on a lookup miss list the -addressable names rather than reporting a bare not-found. +addressable names rather than reporting a bare not-found. The list has to cover +every unnamed kind on the page, not the first one someone fixed: a miss that +listed only grid columns sent the author of `insert into grp1` (an Accordion +group, named in CREATE and never stored) to the grid. **Ambiguity must be refused, not resolved.** Two columns deriving the same name is a real state, and taking the first is a data hazard rather than a wart. The @@ -55,7 +58,11 @@ name written where the model keeps none should be refused or deprecated at parse time (MDL-DEPR005), not silently discarded by the builder. **Property lookup is per-shape, and the shapes differ.** A button's text is a -`CaptionTemplate`, not a `Caption`. A column's value kind comes from the +`CaptionTemplate`, not a `Caption`. A click action is `Action` on a button, +`OnClickAction` on a container and `ClickAction` on a list view or an image; a +setter that knew only the first refused every container, while the catalog had +held all three keys all along — when a setter refuses a shape, look for another +reader of the same fact before believing it. A column's value kind comes from the schema — expression, primitive or text template — and writing a string where a reference belongs is accepted by everything and visible to nothing. From 33d1879f8319261f0fec02059f4d50e7c68ebac6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:42:31 +0000 Subject: [PATCH 19/50] fix: splice a reworded, added or removed free note (mendixlabs/mxcli#1517) Under mdl 1, any change to a free note (one attached to nothing) was refused as 'the free annotations change'. A new SetFreeNotes mutator op replaces the flow's free notes and leaves every other element as stored; create or modify applies it beside the moves and header and reports 'set: free notes'. Refs ako/mxcli#1399. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- .../2026-10-22-free-note-change-refused.json | 1 + mdl/backend/mcp/microflow_mutator.go | 5 + mdl/backend/mfmutator/splice.go | 34 +++++ .../mfmutator/splice_notes_add_test.go | 36 ++++++ mdl/backend/microflow_mutation.go | 5 + mdl/executor/cmd_flow_modify.go | 34 +++-- .../flow_splice_free_notes_pedapp_test.go | 116 ++++++++++++++++++ mdl/executor/flow_splice_node_notes.go | 19 +++ .../flow_splice_note_order_pedapp_test.go | 15 +-- 9 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-free-note-change-refused.json create mode 100644 mdl/executor/flow_splice_free_notes_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-free-note-change-refused.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-free-note-change-refused.json new file mode 100644 index 0000000000..066a1c885a --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-free-note-change-refused.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-22","symptom":"Item 3 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399), last form: under `mdl 1;` a re-run `create or modify` that rewords, adds or removes a free note (an `@annotation(text: …, position: …)` attached to nothing) is refused: 'the free annotations change (note \"free one\" is now \"free one, reworded\"); the splice edits activities only'","cause":"diffFlowBody compares the flow's free notes as a whole and refused any difference: the splice had operations for activities and for notes on a node, none for a note on the canvas","file":"mdl/backend/mfmutator/splice.go (SetFreeNotes), mdl/backend/microflow_mutation.go (interface), mdl/backend/mcp/microflow_mutator.go (refused), mdl/executor/cmd_flow_modify.go (diffFlowBody, planFlowModify), mdl/executor/flow_splice_node_notes.go (setFreeNotes)","fix":"New mutator op SetFreeNotes(frag): removes every top-level annotation no line attaches and appends the fragment's unattached annotations. diffFlowBody returns the declared free notes when they differ (after the misplaced-attached-note hint, which still refuses); planFlowModify builds them as free notes of a stand-in `log` and applies SetFreeNotes after the ops and header, reporting 'set: free notes'. A free note with an id is refused","test":"mdl/executor/flow_splice_free_notes_pedapp_test.go TestCreateOrModify_FreeNotesChangeInPlace (reworded, added, removed; every other object kept, attached note kept, re-run Unchanged); mfmutator splice_notes_add_test.go TestSplice_SetFreeNotesReplacesOnlyTheFreeNotes. Before the change all three executor subtests failed with the reported refusal. The note-order test's control (a new free note) now asserts it is written","insight":"A change that has no target does not fit the op list (every op is aimed at a stored candidate); it belongs beside the moves and the header, applied once to the open mutator, and it must be reported through `set` or the run says Unchanged while it writes. Order matters there too: SetHeader assigns p.set, so anything appended to it has to come after","refs":["mendixlabs/mxcli#1517","ako/mxcli#1399"]} diff --git a/mdl/backend/mcp/microflow_mutator.go b/mdl/backend/mcp/microflow_mutator.go index 126018d8f2..b0142eab8a 100644 --- a/mdl/backend/mcp/microflow_mutator.go +++ b/mdl/backend/mcp/microflow_mutator.go @@ -88,6 +88,11 @@ func (m *mcpFlowMutator) AddNotes(model.ID, *backend.MicroflowFragment) error { return fmt.Errorf("changing notes is not supported by the MCP backend yet; run without --mcp to alter %s in the .mpr", m.qn) } +// SetFreeNotes is refused like RemoveNotes: it removes the stored free notes. +func (m *mcpFlowMutator) SetFreeNotes(*backend.MicroflowFragment) error { + return fmt.Errorf("changing notes is not supported by the MCP backend yet; run without --mcp to alter %s in the .mpr", m.qn) +} + // SetReturnValue is refused like a replace: the patch Save sends carries // positions, pointers and added or removed elements only, so a changed value // would not reach Studio Pro and the edit would be lost without a word. diff --git a/mdl/backend/mfmutator/splice.go b/mdl/backend/mfmutator/splice.go index 67bbc9b679..640316dac6 100644 --- a/mdl/backend/mfmutator/splice.go +++ b/mdl/backend/mfmutator/splice.go @@ -777,6 +777,40 @@ func (m *Mutator) AddNotes(target model.ID, frag *backend.MicroflowFragment) err return m.addFragment(notes.frag) } +// SetFreeNotes replaces the flow's free notes — top-level annotations no line +// attaches to anything — by the free notes frag holds, written where the +// builder drew them. Every other element stays. +func (m *Mutator) SetFreeNotes(frag *backend.MicroflowFragment) error { + g := m.graph() + wired := map[string]bool{} + for _, f := range g.flows { + if !f.isSequence() { + wired[f.origin], wired[f.dest] = true, true + } + } + for _, n := range g.order { + if n.typ == "Microflows$Annotation" && n.loop == "" && !wired[n.id] { + if err := m.removeObject(n); err != nil { + return err + } + } + } + if frag == nil { + return nil + } + attached := map[model.ID]bool{} + for _, af := range frag.AnnotationFlows { + attached[af.OriginID], attached[af.DestinationID] = true, true + } + notes := &backend.MicroflowFragment{} + for _, obj := range frag.Objects { + if _, ok := obj.(*microflows.Annotation); ok && !attached[obj.GetID()] { + notes.Objects = append(notes.Objects, obj) + } + } + return m.addFragment(notes) +} + // Drop removes target and joins the flows that entered it to the object it // led to. Annotation lines attached to it go with it; the annotations stay. func (m *Mutator) Drop(target model.ID) error { diff --git a/mdl/backend/mfmutator/splice_notes_add_test.go b/mdl/backend/mfmutator/splice_notes_add_test.go index 4340cdd935..08bb3b2255 100644 --- a/mdl/backend/mfmutator/splice_notes_add_test.go +++ b/mdl/backend/mfmutator/splice_notes_add_test.go @@ -103,3 +103,39 @@ func TestSplice_AddNotesRefusesAnActivity(t *testing.T) { t.Error("a refused AddNotes changed the document") } } + +// A free note is attached to nothing, so SetFreeNotes swaps the free notes and +// leaves every other element — an attached note and its line included — as +// stored. +func TestSplice_SetFreeNotesReplacesOnlyTheFreeNotes(t *testing.T) { + objs := append(line(), obj("free", "Microflows$Annotation", 100, 50), obj("att", "Microflows$Annotation", 250, 100)) + flows := append(lineFlows(), annotationFlow("al", "att", "a")) + m, deps := newMutator(t, unit(objs, flows)) + frag, n1, _ := notesFor() + // Only the notes no line attaches are free: n1 and n2 both have lines in + // notesFor, so give the fragment one more, unattached. + free := model.ID(types.GenerateID()) + frag.Objects = append(frag.Objects, µflows.Annotation{BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: free}, Position: model.Point{X: 700, Y: 40}, + Size: model.Size{Width: 200, Height: 50}}, Caption: "new free"}) + if err := m.SetFreeNotes(frag); err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatalf("save: %v", err) + } + if bytes.Contains(deps.saved, types.UUIDToBlob(uid("free"))) { + t.Error("the stored free note is still there") + } + for _, kept := range []string{"att", "al", "a", "b"} { + if !bytes.Contains(deps.saved, types.UUIDToBlob(uid(kept))) { + t.Errorf("%s was removed", kept) + } + } + if !bytes.Contains(deps.saved, types.UUIDToBlob(string(free))) { + t.Error("the new free note was not written") + } + if bytes.Contains(deps.saved, types.UUIDToBlob(string(n1))) || bytes.Contains(deps.saved, types.UUIDToBlob(string(frag.Entry))) { + t.Error("an attached note of the fragment, or its stand-in, was written") + } +} diff --git a/mdl/backend/microflow_mutation.go b/mdl/backend/microflow_mutation.go index 7f411671ca..47f6d03a6d 100644 --- a/mdl/backend/microflow_mutation.go +++ b/mdl/backend/microflow_mutation.go @@ -63,6 +63,11 @@ type MicroflowMutator interface { // relative to the stand-in, but relative to target, and its line runs to // target. AddNotes(target model.ID, frag *MicroflowFragment) error + // SetFreeNotes replaces the flow's free notes — notes on the canvas, + // attached to nothing — by the ones frag holds (its Annotation objects + // that no line in it attaches; its other objects are ignored). Nothing + // else changes (mendixlabs/mxcli#1517). + SetFreeNotes(frag *MicroflowFragment) error // SetReturnValue sets the expression the end event target returns, in // place; "" is no value. SetReturnValue(target model.ID, value string) error diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index d97ca88456..aff67b98d4 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -295,6 +295,7 @@ func planFlowModify(ctx *ExecContext, d *flowDecl) (*flowPlan, error) { var ops []*ast.AlterFlowOperation var targets []mfmutator.Candidate var moves []flowMove + var freeNotes *[]ast.MicroflowAnnotation // A body that states what describe prints for the stored one — the // statement re-run unchanged, or a describe executed back — has nothing to // patch, and needs no build to say so (ako/mxcli#870): every statement @@ -314,7 +315,7 @@ func planFlowModify(ctx *ExecContext, d *flowDecl) (*flowPlan, error) { } } body := describedMemberSpellings(ctx, d.params, d.body, varTypes) - if ops, targets, moves, err = diffFlowBody(a, body, storedBody(stored), d.returnVar); err != nil { + if ops, targets, moves, freeNotes, err = diffFlowBody(a, body, storedBody(stored), d.returnVar); err != nil { return nil, asNotSpliceable(err) } } @@ -327,7 +328,7 @@ func planFlowModify(ctx *ExecContext, d *flowDecl) (*flowPlan, error) { a.declaredVarTypes = varTypes p := &flowPlan{a: a, ops: ops, moves: moves, storedFolder: storedFolderOf(stored)} - if len(ops) > 0 || len(moves) > 0 || headerChanged { + if len(ops) > 0 || len(moves) > 0 || headerChanged || freeNotes != nil { if p.mut, err = patch(ctx, a, ops, targets, moves); err != nil { return nil, cannotSplice("%v", err) } @@ -336,6 +337,12 @@ func planFlowModify(ctx *ExecContext, d *flowDecl) (*flowPlan, error) { return nil, cannotSplice("%v", err) } } + if freeNotes != nil { + if err := a.setFreeNotes(ctx, p.mut, *freeNotes); err != nil { + return nil, cannotSplice("%v", err) + } + p.set = append(p.set, "free notes") + } } return p, nil } @@ -742,7 +749,7 @@ func nanoflowDecl(s *ast.CreateNanoflowStmt) *flowDecl { // Targets are the stored activities, located by the @position describe printed // for each statement. A run whose stored end cannot be located, or that the // splice cannot express, is a notSpliceable error. -func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement, returnVar string) ([]*ast.AlterFlowOperation, []mfmutator.Candidate, []flowMove, error) { +func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement, returnVar string) ([]*ast.AlterFlowOperation, []mfmutator.Candidate, []flowMove, *[]ast.MicroflowAnnotation, error) { // Free annotations — notes wired to nothing — belong to the flow, not to // the statement describe happens to print them above (the first one), so // they are compared as a whole and kept out of the statement match: an @@ -750,11 +757,22 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement // write them a second time. declared, declaredFree := withoutFreeNotes(declared) stored, storedFree := withoutFreeNotes(stored) + // Changed, they are replaced as a whole: a free note is no part of any + // activity, so nothing else moves (mendixlabs/mxcli#1517). A note restated + // above its own activity's @position reads as a free one; the stored flow + // says it is attached, so that is refused with the spelling that works. + var freeNotes *[]ast.MicroflowAnnotation if !declaredMatches(declaredFree, storedFree) { if hint := misplacedNote(declaredFree, storedFree, stored); hint != "" { - return nil, nil, nil, cannotSplice("%s", hint) + return nil, nil, nil, nil, cannotSplice("%s", hint) + } + for _, n := range declaredFree { + if n.Label != "" { + return nil, nil, nil, nil, cannotSplice("the free annotations change (%s), and one of them has an id (%s); "+ + "the splice sets free notes that are attached to nothing", freeNoteChange(declaredFree, storedFree), n.Label) + } } - return nil, nil, nil, cannotSplice("the free annotations change (%s); the splice edits activities only", freeNoteChange(declaredFree, storedFree)) + freeNotes = &declaredFree } // @start is where the start event is drawn, which describe prints on the @@ -772,11 +790,11 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement pd := &patchDiff{loc: newStoredLocator(a)} if err := pd.statements(declared, stored); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } pd.startEvent(a, declared, declaredStart) if err := pd.followingEnd(declared, stored); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // Drops go last. Each operation's scope check runs against the flow as the // earlier operations left it, so a stored activity whose output only a @@ -793,7 +811,7 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement } } } - return ops, targets, pd.moves, nil + return ops, targets, pd.moves, freeNotes, nil } // withImplicitEnd states the end a declared body falls through to as the diff --git a/mdl/executor/flow_splice_free_notes_pedapp_test.go b/mdl/executor/flow_splice_free_notes_pedapp_test.go new file mode 100644 index 0000000000..3168dc4993 --- /dev/null +++ b/mdl/executor/flow_splice_free_notes_pedapp_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "sort" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// Item 3 of saingge's comment on mendixlabs/mxcli#1517 (ako/mxcli#1399), last +// form: rewording a free note — one on the canvas, attached to nothing — was +// refused: +// +// the free annotations change (note "free one" is now "free one, reworded"); +// the splice edits activities only. +// +// A free note is no part of any activity, so changing it touches nothing else: +// the stored free notes are replaced by the stated ones, and every activity, +// flow and attached note stays. + +const freeNotesFlow = `mdl 1; +create or modify microflow MyFirstModule.FreeNotes () +begin + @annotation(text: 'free one', position: (100, 50), size: (150, 60)) + @position(300, 200) + @annotation 'attached' + log info node 'X' 'a'; + log info node 'X' 'b'; +end;` + +// freeNoteState returns the captions of FreeNotes' free notes (sorted), and +// the $IDs of everything else. +func freeNoteState(t *testing.T, exec *Executor) ([]string, map[model.ID]bool) { + t.Helper() + mf := loopEditFlowOf(t, exec, "FreeNotes") + wired := map[model.ID]bool{} + for _, af := range mf.ObjectCollection.AnnotationFlows { + wired[af.OriginID], wired[af.DestinationID] = true, true + } + var free []string + rest := map[model.ID]bool{} + for _, obj := range mf.ObjectCollection.Objects { + if n, ok := obj.(*microflows.Annotation); ok && !wired[n.ID] { + free = append(free, n.Caption) + continue + } + rest[obj.GetID()] = true + } + sort.Strings(free) + return free, rest +} + +func TestCreateOrModify_FreeNotesChangeInPlace(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, freeNotesFlow); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + _, before := freeNoteState(t, exec) + + step := func(t *testing.T, src string, want ...string) { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + for _, v := range exec.CheckFlowVerdicts(prog) { + t.Errorf("check -p predicts a refusal: %s", v.Message) + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("refused: %v", err) + } + if !strings.Contains(out.String(), "Modified microflow: MyFirstModule.FreeNotes (set: free notes)") { + t.Errorf("want the free notes reported as set:\n%s", out.String()) + } + free, rest := freeNoteState(t, exec) + if strings.Join(free, "|") != strings.Join(want, "|") { + t.Errorf("free notes %v, want %v", free, want) + } + for id := range before { + if !rest[id] { + t.Errorf("object %s was rebuilt or removed", id) + } + } + out.Reset() + if err := agreeExec(t, exec, src); err != nil { + t.Fatalf("re-run: %v", err) + } + if !strings.Contains(out.String(), "Unchanged microflow: MyFirstModule.FreeNotes") { + t.Errorf("re-run is not Unchanged:\n%s", out.String()) + } + } + + t.Run("reworded", func(t *testing.T) { + step(t, strings.Replace(freeNotesFlow, "'free one'", "'free one, reworded'", 1), "free one, reworded") + }) + t.Run("added", func(t *testing.T) { + src := strings.Replace(freeNotesFlow, "'free one'", "'free one, reworded'", 1) + src = strings.Replace(src, " @position(300, 200)\n", + " @annotation(text: 'free two', position: (500, 50), size: (150, 60))\n @position(300, 200)\n", 1) + step(t, src, "free one, reworded", "free two") + }) + t.Run("taken away", func(t *testing.T) { + src := strings.Replace(freeNotesFlow, " @annotation(text: 'free one', position: (100, 50), size: (150, 60))\n", "", 1) + step(t, src) + }) + // The attached note is untouched throughout. + if desc := describeFlow(t, exec, "FreeNotes"); !strings.Contains(desc, "@annotation 'attached'") { + t.Errorf("the attached note is gone:\n%s", desc) + } +} diff --git a/mdl/executor/flow_splice_node_notes.go b/mdl/executor/flow_splice_node_notes.go index c129b0bdc7..07090b38cb 100644 --- a/mdl/executor/flow_splice_node_notes.go +++ b/mdl/executor/flow_splice_node_notes.go @@ -31,3 +31,22 @@ func (a *alterFlowContext) setNodeNotes(ctx *ExecContext, mut backend.MicroflowM } return mut.AddNotes(target.ID, frag) } + +// setFreeNotes replaces the stored flow's free notes by notes +// (mendixlabs/mxcli#1517), built as a full build draws them: a stand-in +// activity carries them as free notes, and the mutator keeps the notes alone. +func (a *alterFlowContext) setFreeNotes(ctx *ExecContext, mut backend.MicroflowMutator, notes []ast.MicroflowAnnotation) error { + if len(notes) == 0 { + return mut.SetFreeNotes(nil) + } + standIn := &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Value: "", Kind: ast.LiteralString}, + Annotations: &ast.ActivityAnnotations{FreeNotes: notes}, + } + frag, err := a.buildFragment(ctx, []ast.MicroflowStatement{standIn}) + if err != nil { + return err + } + return mut.SetFreeNotes(frag) +} diff --git a/mdl/executor/flow_splice_note_order_pedapp_test.go b/mdl/executor/flow_splice_note_order_pedapp_test.go index 4104240561..24a03c069d 100644 --- a/mdl/executor/flow_splice_note_order_pedapp_test.go +++ b/mdl/executor/flow_splice_note_order_pedapp_test.go @@ -45,15 +45,16 @@ func TestCreateOrModify_NoteBeforePositionNamesTheOrder(t *testing.T) { t.Fatalf("want the refusal to name the note's order, got %v", err) } - // Control: a note that is new to the flow gets the plain refusal, with no - // claim about an attached note it would be. + // Control: a note that is new to the flow is a free note, and is written + // as one (free notes change in place) — not refused as a misplaced + // attached note. added := strings.Replace(noteOrderFlow, " @position(300, 200)\n", " @annotation(text: 'brand new', position: (100, 50), size: (150, 60))\n @position(300, 200)\n", 1) - err = agreeExec(t, exec, added) - if err == nil || !strings.Contains(err.Error(), `note "brand new" is not in the stored flow`) { - t.Fatalf("want the free-note refusal, got %v", err) + if err := agreeExec(t, exec, added); err != nil { + t.Fatalf("a new free note is refused: %v", err) } - if strings.Contains(err.Error(), want) { - t.Errorf("a genuinely new free note is reported as a misplaced attached one: %v", err) + if desc := describeFlow(t, exec, "NoteOrder"); !strings.Contains(desc, "@annotation(text: 'brand new'") || + !strings.Contains(desc, "@annotation(text: 'hello note'") { + t.Errorf("want the free note added and the attached one kept:\n%s", desc) } } From 47920d89188348178121a1787ea0e29da3c55997 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:42:57 +0000 Subject: [PATCH 20/50] docs(changelog): microflow-splice fixes for mendixlabs/mxcli#1489, #1610 and #1517 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7304aa6130..c5cec4e069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **An activity with a custom error handler is changed in place** (mendixlabs/mxcli#1489) — under `mdl 1`, `create or modify` with one call argument changed, or only the handler's contents changed, was refused and advised `alter`; the advised `alter … replace` was refused and advised `create or modify`. The activity replaced by one handling errors the same way is now swapped in place, keeping the stored handler, its error flow and the merge it rejoins at; the handler's body is diffed like a branch; notes on the activity come with it. A change of the handler's kind is still refused, and every remaining refusal names drop-and-create instead of the other mode. +- **A decision's caption change is set in place** (mendixlabs/mxcli#1610) — adding, rewording or removing an `@caption` on an `if` was refused as "changes where its paths end or meet". It is now written onto the stored decision, which keeps its `$ID` and both paths. +- **Notes on a decision, an end event or the canvas are changed in place** (mendixlabs/mxcli#1517) — a note added to or reworded before an `if` or the final `return`, and any change to a free note, was refused. A decision's and an end event's notes are now replaced on the stored node, and the flow's free notes as a whole; nothing else is rebuilt. A note restated *before* its activity's `@position` reads as a free note, and the refusal now says so and names the activity it is attached to. +- **`check -p` predicts a flow statement after an earlier change to the same flow** (mendixlabs/mxcli#1517) — check (and exec's preflight) reported such a statement "not predicted", and exec could then refuse it after the earlier statements had run. Each change check predicts as spliced is applied to an in-memory simulation — never written; any write it cannot hold is refused — so the next statement on that flow is predicted against what exec will see. - **Rewriting a page, snippet or layout no longer deletes the widgets MDL cannot author** (mendixlabs/mxcli#1622) — `describe page` prints a classic data grid, a reference selector, a drop-down or a reference set selector as a `-- NOT re-executable` comment, so executing that output rebuilt the page without them and said only `Replaced page M.P`, with `mx check` clean on both sides. `create or modify` / `create or replace` of a stored page, snippet or layout that holds such a widget — one the statement does not declare by name — is now refused, listing each widget, with the `alter` that edits the document while keeping them and the `alter … { drop … }` that removes them on purpose first. `mxcli check -p` reports the same before anything is written, following an earlier `alter` of the document in the same script. - **A consumed REST service is written with Studio Pro's list markers** — `Operations`, an operation's `Headers` and `Parameters` are typed-array marker 3 (only `QueryParameters` is 2), measured on Studio Pro-authored services (11.14.0) and on `mx convert` output (11.6.6). mxcli wrote 2, copied from the legacy serializer, so running a Studio Pro-made service's `describe` output back rewrote it although nothing had changed — visible once that output parsed (mendixlabs/mxcli#1608). - **`create or modify consumed rest service` names the operations it removes** (mendixlabs/mxcli#1608) — the statement declares the service's whole operation list, so one naming only the operations to add deleted the stored ones — often made in Studio Pro, with their headers, timeouts and mappings — and the output said only `Modified rest client: MyFirstModule.BookingApi (2 operations)`. It still applies, as `create or modify entity` does for attributes, and now prints `⚠ create or modify consumed rest service MyFirstModule.BookingApi drops 1 existing operation(s) not listed in this statement: PostBooking` with the `alter consumed rest service … add operation` that adds one without touching the rest. From b7f402847c37364ae012a8d67cf8cdc6a16a5342 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:44:13 +0000 Subject: [PATCH 21/50] feat(grammar): address a legacy table cell by position, `table1 cell(row, column)` A legacy table cell stores no name and sits at a grid position, so an alter target names it by that position, counted from 1 as describe will print it. alterTarget gains `identifierOrKeyword CELL LPAREN row COMMA column RPAREN`; CELL is a new keyword, listed among the words usable as identifiers, so a widget named `cell` still parses. The visitor carries the address as WidgetRef.Cell and spells it into Widget (`table1 cell(1,2)`), the way a navigation list item's address travels, so every operation receives it unchanged. A 0 row or column is refused. The one-number @n read now takes NUMBER_LITERAL(0) and skips cell addresses, whose two numbers are the position. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- mdl/ast/ast_alter_page.go | 17 +++++++ mdl/grammar/MDLLexer.g4 | 1 + mdl/grammar/MDLParser.g4 | 4 ++ mdl/grammar/domains/MDLSettings.g4 | 2 +- mdl/visitor/visitor_alter_page.go | 24 +++++++++- mdl/visitor/visitor_alter_table_cell_test.go | 49 ++++++++++++++++++++ 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 mdl/visitor/visitor_alter_table_cell_test.go diff --git a/mdl/ast/ast_alter_page.go b/mdl/ast/ast_alter_page.go index f714a95a51..4c2875a8ce 100644 --- a/mdl/ast/ast_alter_page.go +++ b/mdl/ast/ast_alter_page.go @@ -59,6 +59,23 @@ type WidgetRef struct { // operation that takes a widget reaches the item unchanged. The item's @n // lives in Item, never in Ordinal. Item *NavItemRef + // Cell is set for a legacy table cell address `table1 cell(row, column)`. + // Widget then holds Cell.Spell(), the spelling the page mutator resolves. + Cell *TableCellRef +} + +// TableCellRef is a legacy table cell address. A cell stores no name; it is the +// cell whose top-left position is Row, Column, both counted from 1. +type TableCellRef struct { + Table string + Row int + Column int +} + +// Spell writes the address as the page mutator reads it +// (backend.ParseTableCellSelector). +func (r TableCellRef) Spell() string { + return r.Table + " cell(" + strconv.Itoa(r.Row) + "," + strconv.Itoa(r.Column) + ")" } // NavItemRef is a navigation list item address (mendixlabs/mxcli#1388). An item diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index a1663982db..91065a3c27 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -376,6 +376,7 @@ GALLERY: G A L L E R Y; CONTAINER: C O N T A I N E R; ROW: R O W; ITEM: I T E M; +CELL: C E L L; // legacy table cell address: `table1 cell(row, column)` CONTROLBAR: C O N T R O L B A R; SEARCH: S E A R C H; SEARCHBAR: S E A R C H B A R; diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index b85e3eacb9..9247d40786 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -447,10 +447,14 @@ alterFragment // (PROPOSAL_alter_object_list_item_address.md): `acc1 item@1` is the group, // INSERT INTO it fills its body slot, and `acc1 item@1.headercontent` names // another of its slots, as `grid.controlbar` names a widget's. +// +// A legacy table's cell has no name either and sits at a grid position, so it +// is addressed by that position, 1-based: `table1 cell(row, column)`. alterTarget : identifierOrKeyword COLUMN LPAREN (attributePathV3 | STRING_LITERAL) RPAREN (AT NUMBER_LITERAL)? | identifierOrKeyword ITEM LPAREN (identifierOrKeyword | STRING_LITERAL) RPAREN (AT NUMBER_LITERAL)? (DOT itemSlot=identifierOrKeyword)? | identifierOrKeyword ITEM AT NUMBER_LITERAL (DOT itemSlot=identifierOrKeyword)? + | identifierOrKeyword CELL LPAREN cellRow=NUMBER_LITERAL COMMA cellColumn=NUMBER_LITERAL RPAREN | identifierOrKeyword (DOT identifierOrKeyword)? (AT NUMBER_LITERAL)? | STRING_LITERAL (AT NUMBER_LITERAL)? ; diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index 7827980de3..19fae4b158 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -819,7 +819,7 @@ keyword | AFTER | BEFORE | DEFINE | FRAGMENT | FRAGMENTS | SLOT // General-purpose words (only tokens not already listed above) - | ACTION | BLOCKING | BOTH | CONTEXT | DATA | FORMAT | ITEM | LIST + | ACTION | BLOCKING | BOTH | CELL | CONTEXT | DATA | FORMAT | ITEM | LIST | DEFINITION | DEFINITIONS | IGNORE | MESSAGE | MOD | DIV | MULTIPLE | NONE | OBJECT | OBJECTS | OVERRIDABLE | ROOT | SINGLE | SQL | TEMPLATE | TEXT | TYPE | VALUE diff --git a/mdl/visitor/visitor_alter_page.go b/mdl/visitor/visitor_alter_page.go index fac82bc64a..10cb31be53 100644 --- a/mdl/visitor/visitor_alter_page.go +++ b/mdl/visitor/visitor_alter_page.go @@ -267,7 +267,9 @@ func (b *Builder) buildAlterFragment(ctx parser.IAlterFragmentContext) []*ast.Wi func (b *Builder) buildAlterTarget(ctx parser.IAlterTargetContext) ast.WidgetRef { tc := ctx.(*parser.AlterTargetContext) var ref ast.WidgetRef - if n := tc.NUMBER_LITERAL(); n != nil { + // A cell address's numbers are its row and column, read below; every other + // form's one number is @n. + if n := tc.NUMBER_LITERAL(0); n != nil && tc.CELL() == nil { // @n counts matches from 1, as the ambiguity error lists them. if v, err := strconv.Atoi(n.GetText()); err == nil && v >= 1 { ref.Ordinal = v @@ -300,6 +302,26 @@ func (b *Builder) buildAlterTarget(ctx parser.IAlterTargetContext) ast.WidgetRef ref.Widget = item.Spell() return ref } + if tc.CELL() != nil { + // `table1 cell(row, column)`: a legacy table cell, by its position. + cell := &ast.TableCellRef{Table: identifierOrKeywordText(tc.IdentifierOrKeyword(0))} + for _, n := range []struct { + text string + dst *int + }{{tc.GetCellRow().GetText(), &cell.Row}, {tc.GetCellColumn().GetText(), &cell.Column}} { + v, err := strconv.Atoi(n.text) + if err != nil || v < 1 { + b.addError(fmt.Errorf("alter target %s: table rows and columns are counted from 1, as describe prints them", + tc.GetText())) + v = 1 + } + *n.dst = v + } + ref.Ordinal = 0 + ref.Cell = cell + ref.Widget = cell.Spell() + return ref + } if tc.COLUMN() != nil { // `grid column(Attr)` / `grid column('Caption')` (#749). ref.Widget = identifierOrKeywordText(tc.IdentifierOrKeyword(0)) diff --git a/mdl/visitor/visitor_alter_table_cell_test.go b/mdl/visitor/visitor_alter_table_cell_test.go new file mode 100644 index 0000000000..13e58d3b07 --- /dev/null +++ b/mdl/visitor/visitor_alter_table_cell_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// A legacy table cell stores no name, so it is addressed by its position, +// `table1 cell(row, column)`, 1-based (PROPOSAL_alter_object_list_item_address.md). +// The address travels as the target's Widget spelling, like a navigation list +// item's, so every operation that takes a widget receives it unchanged. +func TestAlterTarget_TableCell(t *testing.T) { + stmt := buildAlterPage(t, `alter page M.P { + insert into table1 cell(1, 2) { dynamictext t1 (Content: 'x') }; + replace table1 cell(2,1) with { dynamictext t2 (Content: 'y') }; + drop table1 cell(3, 3); + };`) + check := func(label string, got ast.WidgetRef, want ast.TableCellRef, spelled string) { + t.Helper() + if got.Cell == nil || *got.Cell != want { + t.Errorf("%s: Cell = %+v, want %+v", label, got.Cell, want) + } + if got.Widget != spelled || got.Column != "" || got.Ordinal != 0 { + t.Errorf("%s: Widget %q Column %q Ordinal %d, want %q, \"\", 0", label, got.Widget, got.Column, got.Ordinal, spelled) + } + } + check("insert", stmt.Operations[0].(*ast.InsertWidgetOp).Target, ast.TableCellRef{Table: "table1", Row: 1, Column: 2}, "table1 cell(1,2)") + check("replace", stmt.Operations[1].(*ast.ReplaceWidgetOp).Target, ast.TableCellRef{Table: "table1", Row: 2, Column: 1}, "table1 cell(2,1)") + check("drop", stmt.Operations[2].(*ast.DropWidgetOp).Targets[0], ast.TableCellRef{Table: "table1", Row: 3, Column: 3}, "table1 cell(3,3)") +} + +// `cell` stays an ordinary name everywhere else. +func TestCellIsStillAnIdentifier(t *testing.T) { + stmt := buildAlterPage(t, `alter page M.P { set (Caption: 'x') on cell; };`) + if got := stmt.Operations[0].(*ast.SetPropertyOp).Target.Widget; got != "cell" { + t.Errorf("a widget named cell: Widget = %q", got) + } +} + +// Positions count from 1, as describe prints them. +func TestAlterTarget_TableCellZeroIsRefused(t *testing.T) { + _, errs := Build(`alter page M.P { drop table1 cell(0, 1); };`) + if len(errs) == 0 { + t.Error("cell(0, 1) must be refused: rows and columns count from 1") + } +} From eef55bd59cc4260d34a3f1179b7f7cdfa0aab8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:44:48 +0000 Subject: [PATCH 22/50] fix: refuse [id = $obj/System.owner] when the entity stores no owner MDL-RETRIEVE02 rewrites `retrieve $u from $obj/System.owner` to `retrieve $u from System.User where [id = $obj/System.owner] first;`. That rewrite passed `check -p` and exec, then mx check failed with [CE0161] "Error(s) in XPath constraint." whenever $obj's entity does not store its owner (ako/mxcli#1306, follow-up on mendixlabs/mxcli#1358). Measured with mx check on 11.12.5: $var/System.owner and $var/System.changedBy are clean iff the root of the variable's generalization chain stores the member (own entity, FileDocument specialization, specialization of an owning root) and CE0161 otherwise (plain entity, its specialization, a create-bound variable). - validateRetrieveSystemMemberPaths types the variable (flowObjectTypes) and judges the root's flag, for microflows and nanoflows; it names the `alter entity add attribute owner: AutoOwner` that fixes it. - scriptSystemMemberRoot judges an entity the same script declares from its declaration, and treats an altered one as unknown (the alter is how a user applies the fix). #641's [System.owner = ...] check now uses it, so it no longer skips the one-script shape. - MDL-RETRIEVE02's suggestion states the precondition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XD4JpQem7z5pjk2WuYRqf --- ...-system-owner-path-needs-stored-owner.json | 1 + CHANGELOG.md | 1 + docs-site/src/appendixes/error-messages.md | 6 + ...6-system-owner-path-needs-stored-owner.mdl | 60 +++++++ mdl/executor/validate.go | 61 ++++++- ...alidate_microflow_retrieve_system_assoc.go | 16 +- .../validate_retrieve_system_member_path.go | 80 +++++++++ ...lidate_retrieve_system_member_path_test.go | 162 ++++++++++++++++++ 8 files changed, 375 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json create mode 100644 mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl create mode 100644 mdl/executor/validate_retrieve_system_member_path.go create mode 100644 mdl/executor/validate_retrieve_system_member_path_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json new file mode 100644 index 0000000000..beefdb5631 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-22", "symptom": "MDL-RETRIEVE02's own suggested rewrite `retrieve $Owner from System.User where [id = $Order/System.owner] first;` passed `check -p` and exec, then mxbuild failed `[CE0161] \"Error(s) in XPath constraint.\" at Retrieve object(s) activity 'Retrieve User from database'` — whenever $Order's entity stores no owner (11.12.5). #641's `[System.owner = '[%CurrentUser%]']` on an entity declared in the same script passed the same way", "cause": "Nothing typed a VARIABLE in a constraint: #641's rule judged System.owner only on the retrieved entity, and only for a STORED entity (`ent == nil -> continue`), so the ordinary one-script shape (entity + microflow) was never judged", "file": "`mdl/executor/validate_retrieve_system_member_path.go` (wired beside validateRetrieveOperandTypes for microflows and nanoflows), `mdl/executor/validate.go` (`scriptSystemMemberRoot`, `systemMemberStoreFix`, validateRetrieveConstraints takes sc), `mdl/executor/flow_object_types.go` (MDL-SET01's variable typing, extracted), MDL-RETRIEVE02 suggestion names the precondition", "insight": "A rule's SUGGESTION is a code path too: MDL-RETRIEVE02's rewrite was measured clean on an entity that stores owner and its precondition never measured — run a suggested rewrite through the inverse case before shipping it. Measured matrix (11.12.5): `[id = $o/System.owner|changedBy]` is clean iff the generalization ROOT stores the member (own entity, FileDocument specialization, specialization of an owning root), CE0161 otherwise (plain entity, its specialization, a `create`-bound variable). An entity the script ALTERS must be unknown, not 'does not store': `alter entity X add attribute owner: AutoOwner` is exactly what the user does after reading the message, and it builds clean. Point the fix at the root (`alter entity …`) — the specialization refuses Auto* members", "refs": ["ako/mxcli#1306", "mendixlabs/mxcli#1358", "mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl"], "ce": ["CE0161"], "rules": ["MDL-RETRIEVE02"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7304aa6130..5a03f9116f 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 +- **`[id = $obj/System.owner]` on an entity that stores no owner is refused before it reaches the build** (ako/mxcli#1306, follow-up on mendixlabs/mxcli#1358) — the rewrite MDL-RETRIEVE02 suggests passed `check -p` and exec and then failed `mx check` with `[CE0161] "Error(s) in XPath constraint."` when `$obj`'s entity does not store its owner. `check -p` and exec now refuse a constraint reading `System.owner` / `System.changedBy` off a variable whose entity does not store it, judged on the root of its generalization chain (measured on 11.12.5), and name the `alter entity add attribute owner: AutoOwner` that fixes it; MDL-RETRIEVE02's suggestion states the precondition. The existing check for `[System.owner = …]` on the retrieved entity now also judges an entity the same script creates, which it skipped — the ordinary shape. - **Rewriting a page, snippet or layout no longer deletes the widgets MDL cannot author** (mendixlabs/mxcli#1622) — `describe page` prints a classic data grid, a reference selector, a drop-down or a reference set selector as a `-- NOT re-executable` comment, so executing that output rebuilt the page without them and said only `Replaced page M.P`, with `mx check` clean on both sides. `create or modify` / `create or replace` of a stored page, snippet or layout that holds such a widget — one the statement does not declare by name — is now refused, listing each widget, with the `alter` that edits the document while keeping them and the `alter … { drop … }` that removes them on purpose first. `mxcli check -p` reports the same before anything is written, following an earlier `alter` of the document in the same script. - **A consumed REST service is written with Studio Pro's list markers** — `Operations`, an operation's `Headers` and `Parameters` are typed-array marker 3 (only `QueryParameters` is 2), measured on Studio Pro-authored services (11.14.0) and on `mx convert` output (11.6.6). mxcli wrote 2, copied from the legacy serializer, so running a Studio Pro-made service's `describe` output back rewrote it although nothing had changed — visible once that output parsed (mendixlabs/mxcli#1608). - **`create or modify consumed rest service` names the operations it removes** (mendixlabs/mxcli#1608) — the statement declares the service's whole operation list, so one naming only the operations to add deleted the stored ones — often made in Studio Pro, with their headers, timeouts and mappings — and the output said only `Modified rest client: MyFirstModule.BookingApi (2 operations)`. It still applies, as `create or modify entity` does for attributes, and now prints `⚠ create or modify consumed rest service MyFirstModule.BookingApi drops 1 existing operation(s) not listed in this statement: PostBooking` with the `alter consumed rest service … add operation` that adds one without touching the rest. diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index 1b20afa0c3..1f7d3f9c26 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -349,6 +349,12 @@ retrieve $u from System.User where [id = $task/System.owner] first; retrieve $u from System.User where [id = $task/System.changedBy] first; ``` +That constraint reads the member off `$task`, so it builds only when `$task`'s entity stores +it — `owner: AutoOwner` / `changedBy: AutoChangedBy` on the root of its generalization chain +(a `System.FileDocument` specialization stores both). On an entity that does not, mxbuild +rejects it with CE0161 "Error(s) in XPath constraint." (measured on 11.12.5, ako/mxcli#1306), +and `check -p` and `exec` refuse it, naming the `alter entity` that stores the member. + ### MDL-RETRIEVE03: `change` over a list retrieve ``` diff --git a/mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl b/mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl new file mode 100644 index 0000000000..a8415d018e --- /dev/null +++ b/mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl @@ -0,0 +1,60 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1306 (follow-up on upstream #1358): $var/System.owner in a constraint +-- ============================================================================ +-- +-- MDL-RETRIEVE02 rewrites `retrieve $u from $o/System.owner;` (CE0136) to +-- `retrieve $u from System.User where [id = $o/System.owner] first;`. That +-- rewrite builds only when $o's entity stores its owner. On one that does not, +-- it passed `check -p` and exec, and mxbuild then reported +-- [CE0161] "Error(s) in XPath constraint." at Retrieve object(s) activity 'Retrieve User from database' +-- Measured with mx check on 11.12.5. `check -p` / exec now refuse it: +-- +-- create persistent entity B1306N.Order (Number: String(50)); -- no owner +-- ... retrieve $Owner from System.User where [id = $Order/System.owner] first; +-- → the constraint reads $Order/System.owner, but B1306N.Order does not store +-- System.owner … Store it first: alter entity B1306N.Order add attribute owner: AutoOwner +-- +-- The refusal needs the entity's flags, so it is a `check -p` rule; plain +-- `check` (what `make check-mdl` runs) cannot see it. This file holds the forms +-- that build, each measured clean on 11.12.5. +-- ============================================================================ + +create module B1306; + +-- The entity stores both members. +create persistent entity B1306.Order ( + "Number": String(50), + "owner": AutoOwner, + "changedBy": AutoChangedBy +); + +-- A specialization of a root that stores them: judged by the root. +create persistent entity B1306.RushOrder extends B1306.Order ("Deadline": DateTime); + +-- System.FileDocument stores all four system members. +create persistent entity B1306.Invoice extends System.FileDocument ("Title": String(200)); + +create or modify microflow B1306.Order_GetOwner ($Order: B1306.Order) +returns System.User as $Owner +begin + retrieve $Owner from System.User where [id = $Order/System.owner] first; +end; + +create or modify microflow B1306.Order_GetLastEditor ($Order: B1306.Order) +returns System.User as $Editor +begin + retrieve $Editor from System.User where [id = $Order/System.changedBy] first; +end; + +create or modify microflow B1306.RushOrder_GetOwner ($Order: B1306.RushOrder) +returns System.User as $Owner +begin + retrieve $Owner from System.User where [id = $Order/System.owner] first; +end; + +create or modify microflow B1306.Invoice_GetOwner ($Invoice: B1306.Invoice) +returns System.User as $Owner +begin + retrieve $Owner from System.User where [id = $Invoice/System.owner] first; +end; diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 43a994e59a..ebcd42468b 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -780,6 +780,7 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext refErrors := validateFlowBodyReferences(ctx, s.Body, sc) if ctx.Connected() { refErrors = append(refErrors, validateRetrieveOperandTypes(ctx, s.Parameters, s.Body, sc)...) + refErrors = append(refErrors, validateRetrieveSystemMemberPaths(ctx, s.Parameters, s.Body, sc)...) } if len(refErrors) > 0 && s.Excluded { sc.warnExcluded("microflow", s.Name.String(), refErrors) @@ -846,6 +847,7 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext refErrors := validateFlowBodyReferences(ctx, s.Body, sc) if ctx.Connected() { refErrors = append(refErrors, validateRetrieveOperandTypes(ctx, s.Parameters, s.Body, sc)...) + refErrors = append(refErrors, validateRetrieveSystemMemberPaths(ctx, s.Parameters, s.Body, sc)...) } if len(refErrors) > 0 && s.Excluded { sc.warnExcluded("nanoflow", s.Name.String(), refErrors) @@ -1524,7 +1526,7 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, } if len(refs.retrieves) > 0 { - errors = append(errors, validateRetrieveConstraints(ctx, refs.retrieves)...) + errors = append(errors, validateRetrieveConstraints(ctx, refs.retrieves, sc)...) errors = append(errors, validateXPathAssociations(ctx, refs.retrieves, sc)...) errors = append(errors, validateRetrieveMembers(ctx, refs.retrieves, sc)...) } @@ -1552,27 +1554,30 @@ var baseSystemMemberRe = regexp.MustCompile(`(^|[^/\w.])System\.(owner|changedBy // changedBy / changedDate / createdDate member referenced on an entity that // doesn't store it. This is the legitimate "owned by current user" pattern that // silently fails when owner isn't enabled (issue #641). -func validateRetrieveConstraints(ctx *ExecContext, retrieves []retrieveConstraintRef) []string { +// +// An entity the same script declares is judged from its declaration: judging +// only stored entities let the ordinary shape — one script creating the entity +// and the microflow — pass check and exec and fail the build (ako/mxcli#1306). +func validateRetrieveConstraints(ctx *ExecContext, retrieves []retrieveConstraintRef, sc *scriptContext) []string { entities := buildEntityIndex(ctx) if entities == nil { return nil } var errors []string for _, r := range retrieves { - ent := entities[r.entity] - if ent == nil { + if entities[r.entity] == nil && (sc == nil || sc.entityDecls[r.entity] == nil) { continue // entity-not-found is reported separately } for _, m := range baseSystemMemberRe.FindAllStringSubmatch(r.constraint, -1) { member := m[2] - stores, known := systemMemberStoredOnChain(entities, r.entity, systemMemberStore[member]) + stores, root, known := scriptSystemMemberRoot(entities, sc, r.entity, systemMemberStore[member]) if stores || !known { continue } errors = append(errors, fmt.Sprintf( "constraint references System.%s on %s, but the entity does not store %s — Studio Pro rejects this with CE0161. "+ - "Enable it first: alter entity %s add attribute %s: auto%s", - member, r.entity, member, r.entity, member, strings.ToLower(member))) + "Enable it first: %s", + member, r.entity, member, systemMemberStoreFix(root, member))) } } return errors @@ -1632,6 +1637,48 @@ func systemMemberRoot(index map[string]*domainmodel.Entity, entityQN, member str return false, "", false } +// scriptSystemMemberRoot is systemMemberRoot over the project AND the entities +// the script declares. A declared entity stores a member when its declaration +// carries the member's Auto* pseudo-type, and its generalization is the +// declaration's. An entity the script alters is not judged: the alter may be +// what adds the member, and what it leaves is not worked out here. +func scriptSystemMemberRoot(index map[string]*domainmodel.Entity, sc *scriptContext, entityQN, member string) (stores bool, rootQN string, known bool) { + seen := map[string]bool{} + for qn := entityQN; qn != "" && !seen[qn]; { + seen[qn] = true + if sc == nil { + return systemMemberRoot(index, qn, member) + } + if sc.alteredEntities[qn] { + return false, "", false + } + decl := sc.entityDecls[qn] + if decl == nil { + return systemMemberRoot(index, qn, member) + } + if decl.Generalization == nil { + for _, a := range decl.Attributes { + if systemMemberOfPseudoType(a.Type.Kind) == member { + return true, qn, true + } + } + return false, qn, true + } + qn = decl.Generalization.String() + } + return false, "", false +} + +// systemMemberStoreFix is the statement that makes rootQN store member — the +// root, because Mendix keeps the system members there and refuses them on a +// specialization — or why there is none for a System root. +func systemMemberStoreFix(rootQN, member string) string { + if strings.HasPrefix(rootQN, "System.") { + return "System entities cannot be changed, so nothing in the project can make " + rootQN + " store it" + } + return fmt.Sprintf("alter entity %s add attribute %s: %s", rootQN, member, autoTypeName(member)) +} + // systemChainRoot names the root of a System entity's generalization chain. func systemChainRoot(qn string) string { seen := map[string]bool{} diff --git a/mdl/executor/validate_microflow_retrieve_system_assoc.go b/mdl/executor/validate_microflow_retrieve_system_assoc.go index f80c1d5cad..c58d0581a9 100644 --- a/mdl/executor/validate_microflow_retrieve_system_assoc.go +++ b/mdl/executor/validate_microflow_retrieve_system_assoc.go @@ -28,6 +28,10 @@ const retrieveSystemAssocRule = "MDL-RETRIEVE02" // retrieve $u from $task/System.Owner; CE1613 (the name itself does not resolve) // retrieve $u from System.User where [id = $task/System.owner] first; clean // +// That rewrite needs the entity to store the member: on one that does not, it +// is CE0161 instead (11.12.5, ako/mxcli#1306), so the suggestion says so and +// validateRetrieveSystemMemberPaths refuses it where the entity is known. +// // So the name resolves and the source still yields no entity — there is nothing // to write that mxbuild accepts. The rule needs no project: no module but System // can declare these names, and the entity flags do not change the outcome. @@ -37,12 +41,12 @@ func (v *microflowValidator) checkRetrieveOverSystemAssociation(body []ast.Micro if !ok || r.StartVariable == "" || !strings.EqualFold(r.Source.Module, "System") { return } - var member string + var member, stored string switch strings.ToLower(r.Source.Name) { case "owner": - member = "System.owner" + member, stored = "System.owner", "owner: AutoOwner" case "changedby": - member = "System.changedBy" + member, stored = "System.changedBy", "changedBy: AutoChangedBy" default: return } @@ -52,7 +56,9 @@ func (v *microflowValidator) checkRetrieveOverSystemAssociation(body []ast.Micro "\"Retrieve object must specify the 'Entity' property.\"", r.Variable, r.StartVariable, r.Source.Module, r.Source.Name, member), fmt.Sprintf("Retrieve the user from the database instead: "+ - "`retrieve $%s from System.User where [id = $%s/%s] first;`", - r.Variable, r.StartVariable, member)) + "`retrieve $%s from System.User where [id = $%s/%s] first;` — which builds only when $%s's "+ + "entity stores the member (`%s` on the root of its generalization chain); otherwise mxbuild "+ + "rejects that constraint with CE0161", + r.Variable, r.StartVariable, member, r.StartVariable, stored)) }) } diff --git a/mdl/executor/validate_retrieve_system_member_path.go b/mdl/executor/validate_retrieve_system_member_path.go new file mode 100644 index 0000000000..b5bfcde61c --- /dev/null +++ b/mdl/executor/validate_retrieve_system_member_path.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "regexp" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// A retrieve constraint reading System.owner or System.changedBy off a +// VARIABLE — `[id = $o/System.owner]` — passed `check -p` and exec, and mxbuild +// then reported CE0161 "Error(s) in XPath constraint." whenever $o's entity +// does not store the member (ako/mxcli#1306, the follow-up on +// mendixlabs/mxcli#1358). It is the rewrite MDL-RETRIEVE02 suggests, so the +// shape is easy to reach. +// +// #641's rule judges `System.owner` on the retrieved entity itself; nothing +// judged it behind a variable. Measured with mx check on 11.12.5: +// +// $o: entity storing owner and changedBy [id = $o/System.owner] clean +// [id = $o/System.changedBy] clean +// $o: System.FileDocument specialization [id = $o/System.owner] clean +// $o: specialization of an owning root [id = $o/System.owner] clean +// $o: entity storing neither [id = $o/System.owner] CE0161 +// [id = $o/System.changedBy] CE0161 +// $o: specialization of that entity [id = $o/System.owner] CE0161 +// $p = create (…) [id = $p/System.owner] CE0161 +// +// So the verdict is the root's flag, as everywhere else system members are +// judged (scriptSystemMemberRoot). Silence wherever the variable's entity is +// not visible in the text (flowObjectTypes) or the chain cannot be walked. + +// systemMemberPathRe matches `$var/System.owner` and `$var/System.changedBy`. +// The case is significant: `System.Owner` is a different name (CE1613). +var systemMemberPathRe = regexp.MustCompile(`\$([A-Za-z_][A-Za-z0-9_]*)/System\.(owner|changedBy)\b`) + +// validateRetrieveSystemMemberPaths reports each database retrieve whose +// constraint reads System.owner / System.changedBy off a variable whose entity +// does not store it. +func validateRetrieveSystemMemberPaths(ctx *ExecContext, params []ast.MicroflowParam, body []ast.MicroflowStatement, sc *scriptContext) []string { + var index map[string]*domainmodel.Entity // loaded on first use: most flows have no such path + loaded := false + var errs []string + types := newFlowObjectTypes(params) + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + defer types.observe(s) + r, ok := s.(*ast.RetrieveStmt) + if !ok || r.StartVariable != "" || r.Source.Module == "" || r.Where == nil { + return + } + entityQN := r.Source.String() + constraint := blankXPathLiterals(retrieveXPathConstraint(r.Where, entityQN)) + for _, m := range systemMemberPathRe.FindAllStringSubmatch(constraint, -1) { + variable, member := m[1], m[2] + varEntity, ok := types.objects[variable] + if !ok { + continue + } + if !loaded { + index, loaded = buildEntityIndex(ctx), true + } + stores, root, known := scriptSystemMemberRoot(index, sc, varEntity, member) + if stores || !known { + continue + } + holder := varEntity + if root != varEntity { + holder = varEntity + "'s root " + root + } + errs = append(errs, fmt.Sprintf( + "retrieve from %s: the constraint reads $%s/System.%s, but %s does not store System.%s — mxbuild "+ + "rejects the constraint (CE0161 \"Error(s) in XPath constraint\"). Store it first: %s", + entityQN, variable, member, holder, member, systemMemberStoreFix(root, member))) + } + }) + return errs +} diff --git a/mdl/executor/validate_retrieve_system_member_path_test.go b/mdl/executor/validate_retrieve_system_member_path_test.go new file mode 100644 index 0000000000..771eb673b6 --- /dev/null +++ b/mdl/executor/validate_retrieve_system_member_path_test.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1358 follow-up (ako/mxcli#1306): MDL-RETRIEVE02 rewrites +// `retrieve $u from $o/System.owner` to `retrieve $u from System.User where +// [id = $o/System.owner] first;`, and that rewrite passed `check -p` and exec +// and then failed the build when $o's entity does not store its owner: +// +// [error] [CE0161] "Error(s) in XPath constraint." at Retrieve object(s) activity 'Retrieve User from database' +// +// Every row below was measured with mx check on 11.12.5. +const systemMemberPathHead = `mdl 1; +create module B1306; +create persistent entity B1306.Owned (Number: String(50), owner: AutoOwner, changedBy: AutoChangedBy); +create persistent entity B1306.Plain (Number: String(50)); +create persistent entity B1306.Doc extends System.FileDocument (Title: String(50)); +create persistent entity B1306.SubOwned extends B1306.Owned (Extra: String(50)); +create persistent entity B1306.SubPlain extends B1306.Plain (Extra: String(50)); +` + +const systemMemberPathFlow = `create microflow B1306.SUB_Owner ($o: %s) returns System.User as $u +begin + retrieve $u from System.User where %s first; +end; +` + +var systemMemberPathCases = []struct { + entity, where string + want []string // nil = builds clean, must pass check +}{ + // The reported shape, and its changedBy twin. + {"B1306.Plain", "[id = $o/System.owner]", []string{ + "$o/System.owner", "B1306.Plain does not store System.owner", "CE0161", + "alter entity B1306.Plain add attribute owner: AutoOwner"}}, + {"B1306.Plain", "[id = $o/System.changedBy]", []string{ + "B1306.Plain does not store System.changedBy", "alter entity B1306.Plain add attribute changedBy: AutoChangedBy"}}, + // A specialization is judged by its root, and the fix goes on the root. + {"B1306.SubPlain", "[id = $o/System.owner]", []string{ + "root B1306.Plain does not store System.owner", "alter entity B1306.Plain add attribute owner: AutoOwner"}}, + // Controls, each measured clean. + {"B1306.Owned", "[id = $o/System.owner]", nil}, + {"B1306.Owned", "[id = $o/System.changedBy]", nil}, + {"B1306.Doc", "[id = $o/System.owner]", nil}, // System.FileDocument stores it + {"B1306.SubOwned", "[id = $o/System.owner]", nil}, // the root stores it +} + +func TestRetrieveSystemMemberPath_ScriptEntity(t *testing.T) { + for _, c := range systemMemberPathCases { + exec, _, dir := openPedAppCopy(t) + src := systemMemberPathHead + fmt.Sprintf(systemMemberPathFlow, c.entity, c.where) + got := strings.Join(agreeCheck(t, exec, dir, src), "\n") + if c.want == nil { + if got != "" { + t.Errorf("$o: %s, %s — mxbuild builds this clean; check reported:\n%s", c.entity, c.where, got) + } + continue + } + for _, w := range c.want { + if !strings.Contains(got, w) { + t.Errorf("$o: %s, %s — check -p must report %q, reported:\n%s", c.entity, c.where, w, got) + } + } + } +} + +// The entity already in the project, the microflow in a later script: the +// flags come from the stored domain model. +func TestRetrieveSystemMemberPath_ProjectEntity(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + if err := agreeExec(t, exec, systemMemberPathHead); err != nil { + t.Fatalf("setup: %v", err) + } + bad := "mdl 1;\n" + fmt.Sprintf(systemMemberPathFlow, "B1306.Plain", "[id = $o/System.owner]") + if got := strings.Join(agreeCheck(t, exec, dir, bad), "\n"); !strings.Contains(got, "B1306.Plain does not store System.owner") { + t.Errorf("check -p must report the stored entity's missing owner, reported:\n%s", got) + } + good := "mdl 1;\n" + fmt.Sprintf(systemMemberPathFlow, "B1306.Owned", "[id = $o/System.owner]") + if got := agreeCheck(t, exec, dir, good); len(got) != 0 { + t.Errorf("control: a stored entity that stores owner must pass, reported:\n%s", strings.Join(got, "\n")) + } +} + +// A variable a create object binds is typed as well as a parameter (measured: +// CE0161), and one whose entity the text does not show is not judged. +func TestRetrieveSystemMemberPath_VariableKinds(t *testing.T) { + cases := []struct { + name, flow, want string + }{ + {"created object", `create microflow B1306.SUB_New () returns System.User as $u +begin + $p = create B1306.Plain (Number = 'x'); + retrieve $u from System.User where [id = $p/System.owner] first; +end; +`, "B1306.Plain does not store System.owner"}, + // #641's rule, on an entity the same script declares: it judged only + // stored entities, so this shape passed check and exec (measured CE0161). + {"own member on a script entity", `create microflow B1306.SUB_Mine () +begin + retrieve $l from B1306.Plain where [System.owner = '[%CurrentUser%]']; +end; +`, "does not store owner"}, + {"own member, entity stores it", `create microflow B1306.SUB_MineOk () +begin + retrieve $l from B1306.Owned where [System.owner = '[%CurrentUser%]']; +end; +`, ""}, + // The entity an association retrieve yields needs the project; the + // rule stays silent rather than guess. + {"association-retrieved object", `create association B1306.Owned_Plain from B1306.Owned to B1306.Plain; +create microflow B1306.SUB_Assoc ($o: B1306.Owned) returns System.User as $u +begin + retrieve $x from $o/B1306.Owned_Plain; + retrieve $u from System.User where [id = $x/System.owner] first; +end; +`, ""}, + // An entity the script alters may gain the member there. + {"altered entity", `alter entity B1306.Plain add attribute owner: AutoOwner; +create microflow B1306.SUB_Alt ($o: B1306.Plain) returns System.User as $u +begin + retrieve $u from System.User where [id = $o/System.owner] first; +end; +`, ""}, + } + for _, c := range cases { + exec, _, dir := openPedAppCopy(t) + got := strings.Join(agreeCheck(t, exec, dir, systemMemberPathHead+c.flow), "\n") + if c.want == "" { + if got != "" { + t.Errorf("%s: control must pass check, reported:\n%s", c.name, got) + } + continue + } + if !strings.Contains(got, c.want) || !strings.Contains(got, "CE0161") { + t.Errorf("%s: check -p must report %q (CE0161), reported:\n%s", c.name, c.want, got) + } + } +} + +// MDL-RETRIEVE02's rewrite builds only when the entity stores the member, so +// the suggestion has to say so — it is what the follow-up on #1358 applied. +func TestRetrieveOverSystemAssociation_SuggestionNamesPrecondition(t *testing.T) { + got := systemAssocRetrieveViolations(t, mfWith(&ast.RetrieveStmt{ + Variable: "u", StartVariable: "task", Source: ast.QualifiedName{Module: "System", Name: "owner"}, + })) + if len(got) != 1 { + t.Fatalf("want one violation, got %v", got) + } + for _, want := range []string{"[id = $task/System.owner]", "owner: AutoOwner", "CE0161"} { + if !strings.Contains(got[0], want) { + t.Errorf("suggestion does not mention %q: %s", want, got[0]) + } + } +} From 62fe0f18c2cc55b99daff00d8fbace92acfabf3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:45:01 +0000 Subject: [PATCH 23/50] fix: refuse create/change of System.owner with advice that applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$S = create M.Entity (Name = 'x', System.owner = $User);` passed `check -p`, and exec refused it with "member "System.owner" on entity M.Entity is not a known association ... create the association first (`create or modify association System.owner from ... to ...`)" — advice no project can follow (mendixlabs/mxcli#1612, ako/mxcli#1540). The issue expected the member to be written. Measured with mx check on 11.12.5, a MemberChange with Association "System.owner" (or System.changedBy) is CE1050 "Unknown association 'System.owner'." for an entity storing its owner and one that does not, a FileDocument specialization, a specialization of an owning root, in a microflow and a nanoflow. There is nothing to write, so this is a refusal: - MDL-CHANGE01, project-free (plain check reports it) and exec-enforced, covering create, change and `set $o/System.owner`. - The flow builder refuses at the create/change call sites with the same message, for paths that skip the rule (--no-check, alter). - The suggestion is an association to System.User the project models, verified clean with mx check. Kept out of resolveMemberChange on purpose: list operations share it, and `find $l by System.owner = $u` builds clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XD4JpQem7z5pjk2WuYRqf --- ...-22-create-change-system-owner-ce1050.json | 1 + CHANGELOG.md | 1 + docs-site/src/appendixes/error-messages.md | 26 ++++ .../1540-create-change-system-owner.fail.mdl | 26 ++++ .../1540-create-change-system-owner.mdl | 27 ++++ .../cmd_microflows_builder_actions.go | 6 + mdl/executor/validate.go | 4 + mdl/executor/validate_microflow.go | 1 + ...validate_microflow_system_member_change.go | 125 ++++++++++++++++++ ...ate_microflow_system_member_change_test.go | 107 +++++++++++++++ mdl/executor/validate_nanoflow.go | 3 + 11 files changed, 327 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json create mode 100644 mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl create mode 100644 mdl-examples/bug-tests/1540-create-change-system-owner.mdl create mode 100644 mdl/executor/validate_microflow_system_member_change.go create mode 100644 mdl/executor/validate_microflow_system_member_change_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json new file mode 100644 index 0000000000..ec2f3e60e9 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-22", "symptom": "`$S = create M.Entity (Name = 'x', System.owner = $User);` (or `change $o (System.changedBy = …)`, `set $o/System.owner = …`) passed `check -p`, and exec refused it with `member \"System.owner\" on entity M.Entity is not a known association (and a one-qualifier name cannot be an attribute) — create the association first (`create or modify association System.owner from ... to ...`)` — advice no project can follow. Issue mendixlabs/mxcli#1612 (11.12.5) expected the member to be writable 'as Studio Pro allows'", "cause": "resolveMemberChange looks the qualifier's module up in its domain model; System's virtual model lists real associations (UserRoles, User_Language) but not owner/changedBy, which are entity flags — so it fell to the generic 'unknown association' branch. And there IS nothing to write: Association \"System.owner\" in a MemberChange is CE1050 \"Unknown association 'System.owner'.\" for every entity shape, microflow and nanoflow", "file": "`mdl/executor/validate_microflow_system_member_change.go` (MDL-CHANGE01, project-free, exec-enforced; `refuseSystemMemberChange` at the create/change call sites in `cmd_microflows_builder_actions.go`, NOT inside resolveMemberChange)", "insight": "Measure before building what the issue asks for: the expected behaviour was impossible, so the fix became an accurate refusal. The first measurement LIED: one CE1613 (a wrong-case `System.Owner` row in the same project) suppressed all six CE1050s in that `mx check` run, so every row read 'clean' — a CE1613 anywhere masks CE1050 checks. Measure each variant group in a project with no other error, and distrust an all-clean result that contradicts its own sibling. Keep the refusal OUT of resolveMemberChange: list operations share it, and `find $l by System.owner = $u` builds clean (describe then prints it as `find $l by owner`, a separate round-trip drift). The working alternative for seeded/owned data is a modelled association to System.User — verified clean", "refs": ["mendixlabs/mxcli#1612", "ako/mxcli#1540", "mdl-examples/bug-tests/1540-create-change-system-owner.mdl", "mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl"], "ce": ["CE1050", "CE1613"], "rules": ["MDL-CHANGE01"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a03f9116f..2fa0baa56b 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 +- **`create` / `change` naming `System.owner` or `System.changedBy` is refused with advice that applies** (mendixlabs/mxcli#1612) — `$S = create M.Entity (Name = 'x', System.owner = $User);` passed `check -p`, and exec then refused it with `member "System.owner" on entity M.Entity is not a known association … create the association first (`create or modify association System.owner from ... to ...`)`, which no project can do. The member cannot be written at all: measured on 11.12.5, `System.owner` / `System.changedBy` as a create or change object member is CE1050 "Unknown association 'System.owner'." for every entity — one storing its owner, one not, a `System.FileDocument` specialization — in a microflow and a nanoflow. `check` (no project needed) and `exec` now refuse it as **MDL-CHANGE01** and name what works: an association to `System.User` the project models. `find $l by System.owner = $u` is not affected; it builds. - **`[id = $obj/System.owner]` on an entity that stores no owner is refused before it reaches the build** (ako/mxcli#1306, follow-up on mendixlabs/mxcli#1358) — the rewrite MDL-RETRIEVE02 suggests passed `check -p` and exec and then failed `mx check` with `[CE0161] "Error(s) in XPath constraint."` when `$obj`'s entity does not store its owner. `check -p` and exec now refuse a constraint reading `System.owner` / `System.changedBy` off a variable whose entity does not store it, judged on the root of its generalization chain (measured on 11.12.5), and name the `alter entity add attribute owner: AutoOwner` that fixes it; MDL-RETRIEVE02's suggestion states the precondition. The existing check for `[System.owner = …]` on the retrieved entity now also judges an entity the same script creates, which it skipped — the ordinary shape. - **Rewriting a page, snippet or layout no longer deletes the widgets MDL cannot author** (mendixlabs/mxcli#1622) — `describe page` prints a classic data grid, a reference selector, a drop-down or a reference set selector as a `-- NOT re-executable` comment, so executing that output rebuilt the page without them and said only `Replaced page M.P`, with `mx check` clean on both sides. `create or modify` / `create or replace` of a stored page, snippet or layout that holds such a widget — one the statement does not declare by name — is now refused, listing each widget, with the `alter` that edits the document while keeping them and the `alter … { drop … }` that removes them on purpose first. `mxcli check -p` reports the same before anything is written, following an earlier `alter` of the document in the same script. - **A consumed REST service is written with Studio Pro's list markers** — `Operations`, an operation's `Headers` and `Parameters` are typed-array marker 3 (only `QueryParameters` is 2), measured on Studio Pro-authored services (11.14.0) and on `mx convert` output (11.6.6). mxcli wrote 2, copied from the legacy serializer, so running a Studio Pro-made service's `describe` output back rewrote it although nothing had changed — visible once that output parsed (mendixlabs/mxcli#1608). diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index 1f7d3f9c26..a59a68c709 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -355,6 +355,32 @@ it — `owner: AutoOwner` / `changedBy: AutoChangedBy` on the root of its genera rejects it with CE0161 "Error(s) in XPath constraint." (measured on 11.12.5, ako/mxcli#1306), and `check -p` and `exec` refuse it, naming the `alter entity` that stores the member. +### MDL-CHANGE01: Setting `System.owner` / `System.changedBy` in a create or change + +``` +member "System.owner" on M.Entity: System.owner is kept by the runtime, not set by a +microflow — a create or change object cannot set it, and mxbuild rejects the member with +CE1050 "Unknown association 'System.owner'." (whether or not the entity stores it) [MDL-CHANGE01] +``` + +**Cause:** The owner and last editor of an object are system members, not associations a +project models. Written as a create or change object member, `System.owner` and +`System.changedBy` are CE1050 for every entity — one that stores its owner, one that does not, +a `System.FileDocument` specialization — in a microflow and a nanoflow alike (measured on +11.12.5, mendixlabs/mxcli#1612). `check` needs no project to know it; `check` and `exec` both +refuse it. A list operation is different: `find $l by System.owner = $u` builds. + +**Solution:** To record a user on an object — for example to own seeded data — model the +association and set that: + +```mdl +create association M.Entity_User from M.Entity to System.User; + +$S = create M.Entity (Name = 'x', M.Entity_User = $User); +``` + +Constrain on `M.Entity_User` where you would have used `System.owner`. + ### MDL-RETRIEVE03: `change` over a list retrieve ``` diff --git a/mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl b/mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl new file mode 100644 index 0000000000..70ced0df8b --- /dev/null +++ b/mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl @@ -0,0 +1,26 @@ +mdl 1; +-- ============================================================================ +-- Upstream #1612 (the refusal): create / change object setting System.owner +-- ============================================================================ +-- +-- `System.owner` and `System.changedBy` are kept by the runtime. Written as a +-- create or change object member they are +-- [CE1050] "Unknown association 'System.owner'." +-- for every entity — one storing its owner (as here), one that does not, a +-- System.FileDocument specialization — in a microflow and a nanoflow alike +-- (measured on 11.12.5). exec used to refuse it with advice to create a System +-- association, which no project can do, while `check -p` passed it. +-- `mxcli check` must refuse it as MDL-CHANGE01. See the companion +-- 1540-create-change-system-owner.mdl for the form that builds. +-- +-- This file is expected to FAIL `mxcli check`. +-- ============================================================================ + +create module F1540F; +create persistent entity F1540F.Entity ("Name": String(50), "owner": AutoOwner); + +create or modify microflow F1540F.Seed ($User: System.User) returns Boolean +begin + $S = create F1540F.Entity (Name = 'x', System.owner = $User); + return true; +end; diff --git a/mdl-examples/bug-tests/1540-create-change-system-owner.mdl b/mdl-examples/bug-tests/1540-create-change-system-owner.mdl new file mode 100644 index 0000000000..44dd3d6440 --- /dev/null +++ b/mdl-examples/bug-tests/1540-create-change-system-owner.mdl @@ -0,0 +1,27 @@ +mdl 1; +-- ============================================================================ +-- Upstream #1612 (the form that builds): record a user on seeded data +-- ============================================================================ +-- +-- A create or change object cannot set System.owner (CE1050, MDL-CHANGE01 — see +-- the .fail.mdl companion). To make data an after-startup microflow seeds +-- belong to a seeded user, model the association and set that; constrain on it +-- where System.owner would have been used. Measured clean with mx check on +-- 11.12.5. A list operation over System.owner is a different case and builds. +-- ============================================================================ + +create module B1540; +create persistent entity B1540.Entity ("Name": String(50), "owner": AutoOwner); +create association B1540.Entity_User from B1540.Entity to System.User; + +create or modify microflow B1540.Seed ($User: System.User) returns Boolean +begin + $S = create B1540.Entity (Name = 'x', B1540.Entity_User = $User); + commit $S; + return true; +end; + +create or modify microflow B1540.MineOnly ($User: System.User) returns List of B1540.Entity as $Mine +begin + retrieve $Mine from B1540.Entity where [B1540.Entity_User = $User]; +end; diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 818487e212..5b4bd83673 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -163,6 +163,9 @@ func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID { // Build InitialMembers for each SET assignment for _, change := range s.Changes { + if fb.refuseSystemMemberChange(change.Attribute, entityQN) { + continue + } memberChange := µflows.MemberChange{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, Type: microflows.MemberChangeTypeSet, @@ -362,6 +365,9 @@ func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID { // Build a MemberChange for each `=`, `+=` or `-=` member for _, change := range s.Changes { + if fb.refuseSystemMemberChange(change.Attribute, entityQN) { + continue + } memberChange := µflows.MemberChange{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, Type: memberChangeTypeOf(change.Op), diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index ebcd42468b..2aba06c29f 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -2035,6 +2035,10 @@ var execEnforcedMicroflowRules = map[string]bool{ // member, measured on 11.6.6 (mendixlabs/mxcli#1422). The builder refuses // it too, for the flows this AST walk cannot type. "MDL-RETRIEVE03": true, + // MDL-CHANGE01: a create or change object member naming System.owner / + // System.changedBy is CE1050 "Unknown association", measured on 11.12.5 + // for every entity shape, microflow and nanoflow (mendixlabs/mxcli#1612). + "MDL-CHANGE01": true, } // validateMicroflowRules runs the MDL0xx microflow rule set (ValidateMicroflow) diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index c8f5621f68..7a5c51a818 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -124,6 +124,7 @@ func (v *microflowValidator) validate(body []ast.MicroflowStatement) { v.checkShowMessageType(body) v.checkRetrieveOverSystemAssociation(body) v.checkSetOnObjectVariable(v.params, body) + v.checkSystemMemberChange(body) v.checkListOperationSource(body) v.checkMergeJoinLabels(body) v.checkAnnotationLabels(body) diff --git a/mdl/executor/validate_microflow_system_member_change.go b/mdl/executor/validate_microflow_system_member_change.go new file mode 100644 index 0000000000..6500743a22 --- /dev/null +++ b/mdl/executor/validate_microflow_system_member_change.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// systemMemberChangeRule is the rule ID for "a create or change object that +// sets System.owner / System.changedBy". +const systemMemberChangeRule = "MDL-CHANGE01" + +// A create or change object member naming System.owner or System.changedBy +// was refused at exec with advice to `create or modify association +// System.owner from …` — which no project can do — while `check -p` passed the +// same script (mendixlabs/mxcli#1612, ako/mxcli#1540). +// +// The issue expected the member to be written as Studio Pro would. Measured +// with mx check on 11.12.5, writing it the only way a member change can hold +// an association (Association "System.owner") is +// +// CE1050 "Unknown association 'System.owner'." +// +// for an entity storing its owner and one that does not, a System.FileDocument +// specialization, a specialization of an owning root, System.changedBy alike, +// in a microflow and in a nanoflow. The name itself resolves — `System.Owner` +// is CE1613 instead — but it is not a member a create or change can set. So +// there is nothing to write; this is the refusal, worded so it can be acted on. +// +// Caution when re-measuring: a CE1613 anywhere in the project hid every CE1050 +// from the same `mx check` run, which first read as "builds clean". Measure +// each variant in a project with no other error. +// +// A list operation is not covered: `find $l by System.owner = $u` builds clean. + +// builtinSystemMemberAssociation reports the spelling of the System +// association member names, in any case, when it is System.owner or +// System.changedBy. +func builtinSystemMemberAssociation(member string) (string, bool) { + module, name, ok := strings.Cut(member, ".") + if !ok || !strings.EqualFold(module, "System") { + return "", false + } + switch strings.ToLower(name) { + case "owner": + return "System.owner", true + case "changedby": + return "System.changedBy", true + } + return "", false +} + +// systemMemberChangeRefusal is the message and suggestion for a create or +// change object member naming System.owner / System.changedBy. The check rule +// and the flow builder both word it from here, so check and exec say the same. +// entityQN is "" when the variable's entity is not known. +func systemMemberChangeRefusal(member, canonical, entityQN string) (message, suggestion string) { + on, target := "the object", "." + if entityQN != "" { + on, target = entityQN, entityQN + } + message = fmt.Sprintf("member %q on %s: %s is kept by the runtime, not set by a microflow — a create or change "+ + "object cannot set it, and mxbuild rejects the member with CE1050 \"Unknown association '%s'.\" "+ + "(whether or not the entity stores it)", member, on, canonical, canonical) + suggestion = fmt.Sprintf("To record a user on the object, model the association: `create association %s_User "+ + "from %s to System.User;` — set that, and constrain on it instead of %s", target, target, canonical) + return message, suggestion +} + +// checkSystemMemberChange flags each create object, change object and member +// `set` that names System.owner or System.changedBy. It needs no project: no +// module but System declares these names, and the entity's flags do not change +// the verdict. +func (v *microflowValidator) checkSystemMemberChange(body []ast.MicroflowStatement) { + types := newFlowObjectTypes(v.params) + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + defer types.observe(s) + var entity string + var members []string + switch st := s.(type) { + case *ast.CreateObjectStmt: + if st.EntityType.Module != "" { + entity = st.EntityType.String() + } + for _, c := range st.Changes { + members = append(members, c.Attribute) + } + case *ast.ChangeObjectStmt: + entity = types.objects[strings.TrimPrefix(st.Variable, "$")] + for _, c := range st.Changes { + members = append(members, c.Attribute) + } + case *ast.MfSetStmt: + if i := strings.LastIndex(st.Target, "/"); i >= 0 { + entity = types.objects[strings.TrimPrefix(st.Target[:i], "$")] + members = append(members, st.Target[i+1:]) + } + } + for _, m := range members { + canonical, ok := builtinSystemMemberAssociation(m) + if !ok { + continue + } + msg, suggestion := systemMemberChangeRefusal(m, canonical, entity) + v.addViolation(systemMemberChangeRule, linter.SeverityError, msg, suggestion) + } + }) +} + +// refuseSystemMemberChange is checkSystemMemberChange's twin in the flow +// builder, for the paths that build without the rule having run (an ALTER that +// inserts statements, --no-check). It reports whether the member was refused. +func (fb *flowBuilder) refuseSystemMemberChange(member, entityQN string) bool { + canonical, ok := builtinSystemMemberAssociation(member) + if !ok { + return false + } + msg, suggestion := systemMemberChangeRefusal(member, canonical, entityQN) + fb.addError("%s. %s", msg, suggestion) + return true +} diff --git a/mdl/executor/validate_microflow_system_member_change_test.go b/mdl/executor/validate_microflow_system_member_change_test.go new file mode 100644 index 0000000000..fc2a68700c --- /dev/null +++ b/mdl/executor/validate_microflow_system_member_change_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#1612 (ako/mxcli#1540): `create M.Entity (…, System.owner = +// $User)` was refused at exec with +// +// member "System.owner" on entity M.Entity is not a known association (and a +// one-qualifier name cannot be an attribute) — create the association first +// (`create or modify association System.owner from ... to ...`) +// +// advice that cannot apply to a built-in association — and `check -p` passed +// the same script. Measured with mx check on 11.12.5, writing the member as +// the issue expected (Association "System.owner") is CE1050 "Unknown +// association 'System.owner'." in every case: an entity storing its owner, one +// that does not, a System.FileDocument specialization, a specialization of an +// owning root, System.changedBy alike, in a microflow and in a nanoflow. So +// there is nothing to write; the fix is a refusal that says so, in check and +// exec alike. +const systemMemberChangeHead = `mdl 1; +create module B1540; +create persistent entity B1540.Owned (Name: String(50), owner: AutoOwner, changedBy: AutoChangedBy); +create persistent entity B1540.Plain (Name: String(50)); +create association B1540.Owned_SeededBy from B1540.Owned to System.User; +` + +const systemMemberChangeFlow = `create %s B1540.F ($o: B1540.Owned, $p: B1540.Plain, $User: System.User) returns Boolean +begin + %s + return true; +end; +` + +func TestSystemMemberChange_CheckAndExecAgree(t *testing.T) { + cases := []struct { + name, kind, body, want string + }{ + {"create sets System.owner", "microflow", `$S = create B1540.Owned (Name = 'x', System.owner = $User);`, "CE1050"}, + {"change sets System.changedBy", "microflow", `change $o (System.changedBy = $User);`, "CE1050"}, + {"set on a member path", "microflow", `set $o/System.owner = $User;`, "CE1050"}, + {"entity that stores no owner", "microflow", `$S = create B1540.Plain (Name = 'x', System.owner = $User);`, "CE1050"}, + {"nanoflow", "nanoflow", `$S = create B1540.Owned (Name = 'x', System.owner = $User);`, "CE1050"}, + {"spelled System.Owner", "microflow", `change $o (System.Owner = $User);`, "System.owner is kept by the runtime"}, + // Controls: an association the project models, and a real System one. + {"modelled association to System.User", "microflow", `$S = create B1540.Owned (Name = 'x', B1540.Owned_SeededBy = $User);`, ""}, + {"System.User_Language", "microflow", `retrieve $Lang from System.Language first; + change $User (System.User_Language = $Lang);`, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + src := systemMemberChangeHead + fmt.Sprintf(systemMemberChangeFlow, c.kind, c.body) + assertAgree(t, exec, dir, src, c.want) + if c.want == "" { + return + } + // The advice the report quoted must be gone: no System association + // can be created. + got := strings.Join(agreeCheck(t, exec, dir, src), "\n") + if strings.Contains(got, "create the association first") { + t.Errorf("check still advises creating a System association:\n%s", got) + } + }) + } +} + +// Plain check needs no project to know this: no module but System declares +// these names, and the entity's flags do not change the verdict. +func TestSystemMemberChange_PlainCheck(t *testing.T) { + prog, errs := visitor.Build(systemMemberChangeHead + + fmt.Sprintf(systemMemberChangeFlow, "microflow", `$S = create B1540.Owned (Name = 'x', System.owner = $User);`)) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + var mf *ast.CreateMicroflowStmt + for _, s := range prog.Statements { + if m, ok := s.(*ast.CreateMicroflowStmt); ok { + mf = m + } + } + var got []string + for _, v := range ValidateMicroflow(mf) { + if v.RuleID == systemMemberChangeRule { + got = append(got, v.Message+" | "+v.Suggestion) + } + } + if len(got) != 1 { + t.Fatalf("want one %s violation, got %v", systemMemberChangeRule, got) + } + for _, want := range []string{"System.owner", "CE1050", "create association B1540.Owned_User from B1540.Owned to System.User"} { + if !strings.Contains(got[0], want) { + t.Errorf("violation does not mention %q: %s", want, got[0]) + } + } + if !execEnforcedMicroflowRules[systemMemberChangeRule] { + t.Errorf("%s was measured (CE1050, 11.12.5) and must be refused by exec, not only reported by check", systemMemberChangeRule) + } +} diff --git a/mdl/executor/validate_nanoflow.go b/mdl/executor/validate_nanoflow.go index 137037ab43..0091a8fa5c 100644 --- a/mdl/executor/validate_nanoflow.go +++ b/mdl/executor/validate_nanoflow.go @@ -53,6 +53,9 @@ func validateNanoflowWith(stmt *ast.CreateNanoflowStmt, voids *voidCodeActions) v.checkVoidCallOutputUse(v.params, stmt.Body) // MDL-SET01: a nanoflow's Change variable is as primitive-only (CE7247). v.checkSetOnObjectVariable(v.params, stmt.Body) + // MDL-CHANGE01: System.owner / System.changedBy are CE1050 in a nanoflow's + // create and change too, measured on 11.12.5 (mendixlabs/mxcli#1612). + v.checkSystemMemberChange(stmt.Body) // MDL-FLOW05: a nanoflow's flow is built by the same builder, so a statement // after a return is dead there too (mendixlabs/mxcli#1491). v.checkUnreachableStatements(stmt.Body) From 69cb58c9321c0604e9d53e6e02e68823f162a165 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:45:01 +0000 Subject: [PATCH 24/50] test: exec an association to a System entity end to end (ako/mxcli#1255) `create association ... to System.User` wrote the association and then exited 1: "failed to reconcile access rules of module System: load domain model 00000000-0000-0000-0000-000000000002: ... no such file or directory". reconcileModuleAccess already skips the virtual System module and has a unit test; the issue asked for an exec test. This runs the statement through exec on PedApp, with an access rule in the FROM module so the reconcile has work to do, for System.User and System.Language, and asserts check and exec agree and describe round-trips. Control: with the skip removed it fails with the reported "no such file or directory". On 11.12.5 the result is mx check clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XD4JpQem7z5pjk2WuYRqf --- .../610-association-to-system-entity.mdl | 7 +++ ..._associations_system_target_pedapp_test.go | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 mdl/executor/cmd_associations_system_target_pedapp_test.go diff --git a/mdl-examples/bug-tests/610-association-to-system-entity.mdl b/mdl-examples/bug-tests/610-association-to-system-entity.mdl index 0835a9c0f5..394d9a1926 100644 --- a/mdl-examples/bug-tests/610-association-to-system-entity.mdl +++ b/mdl-examples/bug-tests/610-association-to-system-entity.mdl @@ -13,6 +13,13 @@ mdl 1; -- -- Fix: findEntity now resolves the module from dm.ContainerID. -- +-- Second half (ako/mxcli#1255): exec then wrote the association and still +-- exited 1 — "failed to reconcile access rules of module System: load domain +-- model 00000000-0000-0000-0000-000000000002: … no such file or directory". +-- System's domain model is virtual, so reconcileModuleAccess skips it. +-- TestCreateAssociationToSystemEntity_ExecAgreesWithCheck runs this through +-- exec on PedApp; on 11.12.5 the result is `mx check` clean. +-- -- Run against a project that has MyFirstModule.Cars (e.g. test5): -- ./bin/mxcli exec mdl-examples/bug-tests/610-association-to-system-entity.mdl -p .mpr -- diff --git a/mdl/executor/cmd_associations_system_target_pedapp_test.go b/mdl/executor/cmd_associations_system_target_pedapp_test.go new file mode 100644 index 0000000000..4123429b7d --- /dev/null +++ b/mdl/executor/cmd_associations_system_target_pedapp_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" +) + +// ako/mxcli#1255 (mendixlabs/mxcli#610): `create association … to System.User` +// passed `check --references`, and exec first failed with "child entity not +// found: System.User", then — once that was fixed — exited 1 after writing it: +// +// Error: failed to reconcile access rules of module System: load domain model +// 00000000-0000-0000-0000-000000000002: open …/00000000-0000-0000-0000-000000000002.mxunit: +// no such file or directory +// +// The unit test of reconcileModuleAccess pins the skip; this one runs the whole +// statement through exec on the Studio Pro-authored PedApp fixture, with an +// access rule in the FROM module so the reconcile has work to do, and asserts +// check and exec agree. Measured on 11.12.5: the resulting project is +// `mx check` clean, `owner both` included. +func TestCreateAssociationToSystemEntity_ExecAgreesWithCheck(t *testing.T) { + for _, target := range []string{"System.User", "System.Language"} { + t.Run(target, func(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + src := fmt.Sprintf(`mdl 1; +create module B610; +create module role B610.Reader; +create persistent entity B610.Cars (Name: String(50)); +grant read * on entity B610.Cars to B610.Reader; +create association B610.Cars_Owner from B610.Cars to %s owner both; +`, target) + assertAgree(t, exec, dir, src, "") + + out.Reset() + if err := agreeExec(t, exec, "describe association B610.Cars_Owner;"); err != nil { + t.Fatalf("describe: %v", err) + } + if want := "from B610.Cars to " + target; !strings.Contains(out.String(), want) { + t.Errorf("describe must round-trip %q, got:\n%s", want, out.String()) + } + }) + } +} From e17e77bfb59f72abb579e7e9f5eb9cd4bc149dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:50:58 +0000 Subject: [PATCH 25/50] feat(alter page): fill, replace and empty a legacy table cell by position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A legacy table cell stores no name, so an EMPTY one could not be reached at all; the widgets already inside cells became addressable by name in #1463 (ako/mxcli#1326, mendixlabs/mxcli#1453). Now: insert into table1 cell(1,2) { … } -- append to the cell replace table1 cell(1,1) with { … } -- swap its content drop table1 cell(1,2) -- empty it (the cell stays: the table's grid needs it) INSERT BEFORE/AFTER a cell is refused with the form that works. A position under a spanning cell names that cell, and a miss lists the table's cells. A cell is not a data container: widgets inserted into it bind in the context of the table's surroundings, as a navigation item sees its list's. The stored shape was taken from the modeler, not from gen: a table injected into a blank 11.13.0 app and run through `mx convert -p` (0 errors) came back as Forms$Table { Cells [2] of Forms$DbTableCell { TopRowIndex, LeftColumnIndex, Width, Height, IsHeader, Widgets [2] }, ColumnWidths [2], Rows [2], WidthUnit "Weight" }. gen's Forms$TableCell and Columns are not what Mendix 11 stores; the fixture is the converted shape. Control: the new tests fail before the change with `widget "table1 cell(1,2)" not found`; the data-context test fails without the table-cell mapping with an empty entity. On the converted table inside a data view, inserting an attribute-bound text box into the empty cell binds T.Item.Name, replace and drop act on their cells, and `docker check --no-update-widgets` reports 0 errors; main does not parse the address. The regenerated LSP completion list gains the CELL keyword. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- cmd/mxcli/lsp_completions_gen.go | 1 + mdl/backend/pagemutator/alter_target.go | 3 + mdl/backend/pagemutator/mutator.go | 28 ++- .../pagemutator/table_cell_address_test.go | 185 ++++++++++++++++++ mdl/backend/pagemutator/tablecells.go | 142 ++++++++++++++ mdl/backend/tablecell_selector.go | 54 +++++ 6 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 mdl/backend/pagemutator/table_cell_address_test.go create mode 100644 mdl/backend/pagemutator/tablecells.go create mode 100644 mdl/backend/tablecell_selector.go diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index baa6f35a19..3ba08e24f0 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -202,6 +202,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "CONTAINER", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "ROW", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "ITEM", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, + {Label: "CELL", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "CONTROLBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SEARCH", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SEARCHBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, diff --git a/mdl/backend/pagemutator/alter_target.go b/mdl/backend/pagemutator/alter_target.go index a72a8889df..be7c181317 100644 --- a/mdl/backend/pagemutator/alter_target.go +++ b/mdl/backend/pagemutator/alter_target.go @@ -45,6 +45,9 @@ func (m *Mutator) ResolveAlterTarget(t backend.AlterTarget) (backend.AlterTarget if result == nil { return backend.AlterTargetMatch{}, m.widgetNotFoundError(name) } + if result.cell != nil { + return backend.AlterTargetMatch{Kind: "cell", Name: name}, nil + } if widgetTypeName(result.widget) == navListItemType || result.item != nil { return backend.AlterTargetMatch{Kind: "item", Name: name}, nil } diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 8517c02fab..935bf4b954 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -97,7 +97,7 @@ func New(rawData bson.D, unitID model.ID, deps Deps) *Mutator { containerType: containerType, unitID: unitID, deps: deps, - widgetFinder: withNavItemAddresses(finder), + widgetFinder: withTableCellAddresses(withNavItemAddresses(finder)), } } @@ -526,6 +526,9 @@ func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position back if result.item != nil { return m.insertIntoObjectListItem(result, widgetRef, position, widgets) } + if err := refuseBesideTableCell(result, widgetRef, position); err != nil { + return err + } if err := refuseObjectListItemTarget(result, widgetRef); err != nil { return err } @@ -683,6 +686,11 @@ func (m *Mutator) DropWidget(refs []backend.WidgetRef) error { if result == nil { return m.widgetNotFoundError(ref.Name()) } + if result.cell != nil { + // A cell is emptied, not removed: the table's grid needs it. + setCellWidgets(result.widget, nil) + continue + } if n := m.columnMatchCount(ref.Name()); n > 1 { return columnAmbiguityError(ref.Name(), n) } @@ -717,6 +725,14 @@ func (m *Mutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pa if result == nil { return m.widgetNotFoundError(widgetRef) } + if result.cell != nil { + newBson, err := m.serializeWidgets(widgets) + if err != nil { + return fmt.Errorf("serialize widgets: %w", err) + } + setCellWidgets(result.widget, newBson) + return nil + } if result.item != nil { // Widgets in place of an item would be written into the item list, // which the loader cannot read (the #935 shape). The item's content is @@ -1486,6 +1502,9 @@ type bsonWidgetResult struct { // addressed as `owner item…` (an Accordion group): its slots are reached // through it, and it is never a widget to insert beside or replace. item *objectListItemInfo + // cell is set when the result is a legacy table cell addressed as + // `table cell(row, column)`: a position in the table, never a widget. + cell *backend.TableCellSelector } // widgetFinder is a function type for locating widgets in a raw BSON tree. @@ -2111,6 +2130,9 @@ func (m *Mutator) widgetNotFoundError(name string) error { if err := m.navItemAddressError(name); err != nil { return err } + if err := m.tableCellAddressError(name); err != nil { + return err + } var cols []string collectColumnNamesBson(m.rawData, &cols) if len(cols) > 0 { @@ -2333,6 +2355,10 @@ func findNearestDataSourceDoc(rawData bson.D, widgetName string) (bson.D, bool) if sel, ok := backend.ParseNavItemSelector(widgetName); ok { widgetName = sel.List } + // Nor is a legacy table cell: `table1 cell(1,2)` sees what table1 sees. + if sel, ok := backend.ParseTableCellSelector(widgetName); ok { + widgetName = sel.Table + } if formCall := bsonnav.DGetDoc(rawData, "FormCall"); formCall != nil { for _, arg := range bsonnav.DGetArrayElements(bsonnav.DGet(formCall, "Arguments")) { argDoc, ok := arg.(bson.D) diff --git a/mdl/backend/pagemutator/table_cell_address_test.go b/mdl/backend/pagemutator/table_cell_address_test.go new file mode 100644 index 0000000000..c4a6a10fc8 --- /dev/null +++ b/mdl/backend/pagemutator/table_cell_address_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/model" +) + +// A legacy table cell stores no name, so an empty one could not be filled at +// all (ako/mxcli#1326, mendixlabs/mxcli#1453). It is addressed by position, +// `table1 cell(row, column)`, 1-based. The fixture is the shape Mendix 11.13's +// `mx convert` writes for a table (Forms$DbTableCell cells keyed by +// TopRowIndex/LeftColumnIndex with Width/Height spans, ColumnWidths, Weight): +// +// row 1: [ tInCell ] [ empty ] +// row 2: [ one cell spanning both columns ] + +func dbTableCell(row, col, width int32, widgets ...string) bson.D { + return bson.D{ + {Key: "$Type", Value: "Forms$DbTableCell"}, + {Key: "Height", Value: int32(1)}, + {Key: "IsHeader", Value: false}, + {Key: "LeftColumnIndex", Value: col}, + {Key: "TopRowIndex", Value: row}, + {Key: "Widgets", Value: widgetList(widgets...)}, + {Key: "Width", Value: width}, + } +} + +func legacyTable2x2() bson.D { + return bson.D{ + {Key: "$Type", Value: "Forms$Table"}, + {Key: "Cells", Value: bson.A{int32(2), + dbTableCell(0, 0, 1, "tInCell"), + dbTableCell(0, 1, 1), + dbTableCell(1, 0, 2, "tWide"), + }}, + {Key: "ColumnWidths", Value: bson.A{int32(2), + bson.D{{Key: "$Type", Value: "Forms$TableColumn"}, {Key: "Value", Value: int32(1)}}, + bson.D{{Key: "$Type", Value: "Forms$TableColumn"}, {Key: "Value", Value: int32(1)}}, + }}, + {Key: "Name", Value: "table1"}, + {Key: "Rows", Value: bson.A{int32(2), bson.D{{Key: "$Type", Value: "Forms$TableRow"}}, bson.D{{Key: "$Type", Value: "Forms$TableRow"}}}}, + {Key: "WidthUnit", Value: "Weight"}, + } +} + +func tableMutator() *Mutator { + return New(makeRawPage(legacyTable2x2()), model.ID("u"), &stubWidgetDeps{}) +} + +// cellNames returns the widget names in the cell at stored index i, checking +// the cell count and the Widgets marker are intact. +func cellNames(t *testing.T, m *Mutator, i int) []string { + t.Helper() + table := findWidgetForTest(t, m.rawData, "table1") + cells := bsonnav.DGetArrayElements(bsonnav.DGet(table, "Cells")) + if len(cells) != 3 { + t.Fatalf("the table has %d cells, want 3 — a cell was added or removed", len(cells)) + } + raw := bsonnav.ToBsonA(bsonnav.DGet(cells[i].(bson.D), "Widgets")) + if len(raw) == 0 || raw[0] != int32(2) { + t.Fatalf("cell %d lost its Widgets [2] marker: %v", i, raw) + } + var out []string + for _, w := range raw[1:] { + out = append(out, bsonnav.DGetString(w.(bson.D), "Name")) + } + return out +} + +func TestInsertIntoEmptyTableCell(t *testing.T) { + m := tableMutator() + if err := m.InsertWidget("table1 cell(1,2)", "", into, itemButton("btnNew")); err != nil { + t.Fatalf("insert into table1 cell(1,2): %v", err) + } + if got := strings.Join(cellNames(t, m, 1), ","); got != "btnNew" { + t.Errorf("cell(1,2) = %s, want btnNew", got) + } +} + +func TestInsertIntoTableCellAppends(t *testing.T) { + m := tableMutator() + if err := m.InsertWidget("table1 cell(1,1)", "", into, itemButton("btnNew")); err != nil { + t.Fatal(err) + } + if got := strings.Join(cellNames(t, m, 0), ","); got != "tInCell,btnNew" { + t.Errorf("cell(1,1) = %s, want tInCell,btnNew", got) + } +} + +func TestReplaceAndDropTableCell(t *testing.T) { + m := tableMutator() + if err := m.ReplaceWidget("table1 cell(1,1)", "", itemButton("btnRep")); err != nil { + t.Fatalf("replace table1 cell(1,1): %v", err) + } + if got := strings.Join(cellNames(t, m, 0), ","); got != "btnRep" { + t.Errorf("after replace cell(1,1) = %s, want btnRep", got) + } + // DROP empties the cell; removing the cell itself would break the grid. + if err := m.DropWidget([]backend.WidgetRef{{Widget: "table1 cell(1,1)"}}); err != nil { + t.Fatalf("drop table1 cell(1,1): %v", err) + } + if got := cellNames(t, m, 0); len(got) != 0 { + t.Errorf("after drop cell(1,1) = %v, want empty", got) + } +} + +func TestSpannedTableCell(t *testing.T) { + m := tableMutator() + if err := m.InsertWidget("table1 cell(2,1)", "", into, itemButton("btnNew")); err != nil { + t.Fatalf("the spanning cell's own top-left: %v", err) + } + err := m.InsertWidget("table1 cell(2,2)", "", into, itemButton("btnX")) + if err == nil || !strings.Contains(err.Error(), "cell(2,1)") { + t.Errorf("cell(2,2) lies under the spanning cell(2,1); want a refusal naming it, got %v", err) + } +} + +func TestTableCellMissListsCells(t *testing.T) { + m := tableMutator() + err := m.InsertWidget("table1 cell(5,1)", "", into, itemButton("b")) + if err == nil || !strings.Contains(err.Error(), "cell(1,2)") || !strings.Contains(err.Error(), "cell(2,1)") { + t.Errorf("want a miss listing the table's cells, got %v", err) + } +} + +func TestInsertBesideTableCellIsRefused(t *testing.T) { + m := tableMutator() + if err := m.InsertWidget("table1 cell(1,1)", "", backend.InsertPosition("after"), itemButton("b")); err == nil { + t.Fatal("insert after a cell must be refused: a cell is a position in the table, not a widget") + } + if got := strings.Join(cellNames(t, m, 0), ","); got != "tInCell" { + t.Errorf("a refused insert changed cell(1,1): %s", got) + } +} + +func TestResolveAlterTargetTableCell(t *testing.T) { + m := tableMutator() + got, err := m.ResolveAlterTarget(backend.AlterTarget{Path: []string{"table1 cell(1,2)"}}) + if err != nil { + t.Fatalf("ResolveAlterTarget: %v", err) + } + if got.Kind != "cell" { + t.Errorf("kind = %q, want cell", got.Kind) + } +} + +func TestTableCellSelectorSpelling(t *testing.T) { + sel, ok := backend.ParseTableCellSelector("table1 cell(2,1)") + if !ok || sel != (backend.TableCellSelector{Table: "table1", Row: 2, Column: 1}) || sel.String() != "table1 cell(2,1)" { + t.Errorf("parse/spell round trip: %+v %v %q", sel, ok, sel.String()) + } + for _, bad := range []string{"table1", "table1 cell(0,1)", "table1 cell(1)", "a b cell(1,1)", "table1 item@1"} { + if _, ok := backend.ParseTableCellSelector(bad); ok { + t.Errorf("%q must not parse as a cell", bad) + } + } +} + +// A cell is not a data container: widgets inserted into it bind in the data +// context its table sits in, or an attribute-bound widget is refused as +// "in no data container" (measured on a table inside a data view). +func TestTableCellSeesItsTablesDataContext(t *testing.T) { + dv := bson.D{ + {Key: "$Type", Value: "Forms$DataView"}, + {Key: "Name", Value: "dv"}, + {Key: "DataSource", Value: bson.D{ + {Key: "$Type", Value: "Forms$DataViewSource"}, + {Key: "EntityRef", Value: bson.D{{Key: "$Type", Value: "DomainModels$DirectEntityRef"}, {Key: "Entity", Value: "T.Item"}}}, + }}, + {Key: "Widgets", Value: bson.A{int32(2), legacyTable2x2()}}, + } + m := New(makeRawPage(dv), model.ID("u"), &stubWidgetDeps{}) + if got := m.EnclosingEntity("table1 cell(1,2)"); got != "T.Item" { + t.Errorf("EnclosingEntity(table1 cell(1,2)) = %q, want T.Item", got) + } +} diff --git a/mdl/backend/pagemutator/tablecells.go b/mdl/backend/pagemutator/tablecells.go new file mode 100644 index 0000000000..249e5d35bc --- /dev/null +++ b/mdl/backend/pagemutator/tablecells.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// A legacy table (Forms$Table) keeps its widgets in cells, and a cell stores +// no name — Mendix 11.13's `mx convert` writes each as a Forms$DbTableCell with +// TopRowIndex / LeftColumnIndex (0-based), Width / Height (its span) and a +// Widgets list. Widgets inside a cell are reachable by their own names +// (#1463); the cell itself, and so an EMPTY cell, is addressed by position: +// `table1 cell(row, column)`, 1-based (ako/mxcli#1326, mendixlabs/mxcli#1453). +// +// A cell is a position in the table's grid, not a widget: INSERT INTO appends +// to it, REPLACE swaps its content, DROP empties it — removing the cell element +// would leave a hole in the grid — and BEFORE/AFTER a cell are refused. + +const legacyTableType = "Forms$Table" + +// withTableCellAddresses extends a widget finder with table cell addresses. +// The result for a cell is the cell itself, in the table's Cells. +func withTableCellAddresses(inner widgetFinder) widgetFinder { + return func(rawData bson.D, ref string) *bsonWidgetResult { + if sel, ok := backend.ParseTableCellSelector(ref); ok { + r, _ := resolveTableCell(rawData, inner, sel) + return r + } + return inner(rawData, ref) + } +} + +type storedCell struct { + index int // position in the table's Cells, marker excluded + row, col, rows, cols int // 0-based top-left and span +} + +func tableCells(table bson.D) []storedCell { + var out []storedCell + for i, el := range bsonnav.DGetArrayElements(bsonnav.DGet(table, "Cells")) { + d, ok := el.(bson.D) + if !ok { + continue + } + c := storedCell{index: i, row: intField(d, "TopRowIndex"), col: intField(d, "LeftColumnIndex"), + rows: intField(d, "Height"), cols: intField(d, "Width")} + if c.rows < 1 { + c.rows = 1 + } + if c.cols < 1 { + c.cols = 1 + } + out = append(out, c) + } + return out +} + +func intField(d bson.D, key string) int { + switch v := bsonnav.DGet(d, key).(type) { + case int32: + return int(v) + case int64: + return int(v) + case int: + return v + } + return 0 +} + +func (c storedCell) address(table string) string { + return backend.TableCellSelector{Table: table, Row: c.row + 1, Column: c.col + 1}.String() +} + +// resolveTableCell finds the cell whose top-left is the selector's position. +func resolveTableCell(rawData bson.D, find widgetFinder, sel backend.TableCellSelector) (*bsonWidgetResult, error) { + table := find(rawData, sel.Table) + if table == nil { + return nil, fmt.Errorf("%s: table %q not found", sel, sel.Table) + } + if bsonnav.DGetString(table.widget, "$Type") != legacyTableType { + return nil, fmt.Errorf("%s: %q is a %s, not a table — `cell(row, column)` addresses a legacy table's cells", + sel, sel.Table, widgetTypeName(table.widget)) + } + row, col := sel.Row-1, sel.Column-1 + cells := tableCells(table.widget) + elements := bsonnav.DGetArrayElements(bsonnav.DGet(table.widget, "Cells")) + for _, c := range cells { + if c.row == row && c.col == col { + return &bsonWidgetResult{widget: elements[c.index].(bson.D), parentArr: elements, parentKey: "Cells", + parentDoc: table.widget, index: c.index, cell: &sel}, nil + } + } + for _, c := range cells { + if row >= c.row && row < c.row+c.rows && col >= c.col && col < c.col+c.cols { + return nil, fmt.Errorf("%s lies under %s, which spans %d row(s) and %d column(s) — address that cell", + sel, c.address(sel.Table), c.rows, c.cols) + } + } + var addrs []string + for _, c := range cells { + addrs = append(addrs, c.address(sel.Table)) + } + return nil, fmt.Errorf("%s: the table has no cell there. Cells of %s: %s", sel, sel.Table, strings.Join(addrs, ", ")) +} + +// tableCellAddressError explains a cell address that did not resolve. +func (m *Mutator) tableCellAddressError(ref string) error { + sel, ok := backend.ParseTableCellSelector(ref) + if !ok { + return nil + } + inner := m.widgetFinder + if _, err := resolveTableCell(m.rawData, inner, sel); err != nil { + return err + } + return nil +} + +// setCellWidgets stores a cell's new content, keeping its list marker. +func setCellWidgets(cell bson.D, widgets []any) { + out := bson.A{int32(2)} + if raw := bsonnav.ToBsonA(bsonnav.DGet(cell, "Widgets")); len(raw) > 0 && isListMarker(raw[0]) { + out = bson.A{raw[0]} + } + bsonnav.DSet(cell, "Widgets", append(out, widgets...)) +} + +// refuseBesideTableCell refuses INSERT BEFORE/AFTER a cell. +func refuseBesideTableCell(result *bsonWidgetResult, ref string, position backend.InsertPosition) error { + if result.cell == nil || strings.EqualFold(string(position), "into") { + return nil + } + return fmt.Errorf("cannot insert %s %q: a table cell is a position in the table, not a widget — "+ + "write `insert into %s { … }` to add to it, or name a widget inside it", strings.ToLower(string(position)), ref, ref) +} diff --git a/mdl/backend/tablecell_selector.go b/mdl/backend/tablecell_selector.go new file mode 100644 index 0000000000..e1157298f0 --- /dev/null +++ b/mdl/backend/tablecell_selector.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "strconv" + "strings" +) + +// TableCellSelector is the address of a legacy table cell: the cell whose +// top-left position is Row, Column, both counted from 1 — +// +// table1 cell(2,1) +// +// A cell stores no name (Forms$DbTableCell carries only TopRowIndex and +// LeftColumnIndex, 0-based), so it is addressed by position. Like +// NavItemSelector it travels through the PageMutator interface as the +// widgetRef string, spelled by String, which no widget name can collide with: +// a widget name contains no space. +type TableCellSelector struct { + Table string + Row int + Column int +} + +const tableCellSelectorInfix = " cell(" + +// String spells the selector as MDL writes it (ast.TableCellRef.Spell). +func (s TableCellSelector) String() string { + return s.Table + tableCellSelectorInfix + strconv.Itoa(s.Row) + "," + strconv.Itoa(s.Column) + ")" +} + +// ParseTableCellSelector reads a widgetRef spelled by String. ok is false for +// anything else, including every widget name and a position below 1. +func ParseTableCellSelector(ref string) (TableCellSelector, bool) { + table, rest, found := strings.Cut(ref, tableCellSelectorInfix) + if !found || table == "" || strings.ContainsAny(table, " ()'") { + return TableCellSelector{}, false + } + pos, ok := strings.CutSuffix(rest, ")") + if !ok { + return TableCellSelector{}, false + } + r, c, ok := strings.Cut(pos, ",") + if !ok { + return TableCellSelector{}, false + } + row, err1 := strconv.Atoi(strings.TrimSpace(r)) + col, err2 := strconv.Atoi(strings.TrimSpace(c)) + if err1 != nil || err2 != nil || row < 1 || col < 1 { + return TableCellSelector{}, false + } + return TableCellSelector{Table: table, Row: row, Column: col}, true +} From 50ad810baece70090bad9948d66ff6120fb98770 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:52:29 +0000 Subject: [PATCH 26/50] docs: mxcli syntax shows the ExtraJvmParameters configuration key Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- cmd/mxcli/syntax/features_misc.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 0430795bac..e8f8e09b10 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -675,6 +675,7 @@ create or modify translations in Administration for nl_NL ( "add language", "remove language", "enable language", "translations", "optimistic locking", "concurrency", "lost update", "workflow group", "workflow groups", "add group", "task assignment", + "jvm parameters", "proxy", }, Syntax: `ALTER SETTINGS RUNTIME (: , ...); ALTER SETTINGS CONFIGURATION '' (: , ...); @@ -708,6 +709,10 @@ CREATE CONFIGURATION 'Production' ( DatabaseType: 'PostgreSql', HttpPortNumber: 8080 ); +-- Server tab > Extra JVM parameters, e.g. a proxy for the runtime JVM. +ALTER SETTINGS CONFIGURATION 'Default' ( + ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888' +); -- LANGUAGE ADD/REMOVE change the ENABLED languages — the list under App -- Settings > Languages, and the only languages a build emits anything for. A From 5957af52e0e72505969832df5a5d7c2eac0d00ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:26:14 +0000 Subject: [PATCH 27/50] fix: marketplace install/update writes package metadata into the app root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `marketplace update` (and `install`) left the package's own metadata — manifest.json, License.txt, the READMEOSS report, Mendix EULA.docx — in the app root as untracked files (mendixlabs/mxcli#1487, ako/mxcli#1344). InstallPackageFiles copied every archive entry but project.mpr and package.xml. It now installs what package.xml lists under , which is what `mx module-import` installs: measured on 11.12.1 with ExternalDatabaseConnector 6.3.0 and BusinessEvents 3.12.1, mx wrote exactly the listed files and passed over a listed one the archive lacks. Entries left out are reported on one line. A package with no list falls back to leaving out root-level entries only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UDwYCiqWmrM3iXhnz2YeJT --- ...e-update-package-metadata-in-app-root.json | 1 + CHANGELOG.md | 1 + .../cmd_marketplace_skipped_report_test.go | 34 ++++ cmd/mxcli/cmd_marketplace_update.go | 16 +- .../install_declared_files_test.go | 179 ++++++++++++++++++ .../marketplace/install_relative_dir_test.go | 11 +- cmd/mxcli/marketplace/update.go | 87 ++++++++- docs-site/src/guides/marketplace.md | 2 +- ...rketplace-package-metadata-in-app-root.mdl | 38 ++++ 9 files changed, 355 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-update-package-metadata-in-app-root.json create mode 100644 cmd/mxcli/cmd_marketplace_skipped_report_test.go create mode 100644 cmd/mxcli/marketplace/install_declared_files_test.go create mode 100644 mdl-examples/bug-tests/1487-marketplace-package-metadata-in-app-root.mdl diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-update-package-metadata-in-app-root.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-update-package-metadata-in-app-root.json new file mode 100644 index 0000000000..cf0235fc01 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-update-package-metadata-in-app-root.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli/marketplace", "date": "2026-10-22", "symptom": "`mxcli marketplace update -p app.mpr --to ` (and `marketplace install`) leaves the package's own metadata in the app root: `manifest.json`, `License.txt`, `SiemensMendix____READMEOSS_.html` (and on BusinessEvents, `Mendix EULA.docx`) show up as untracked files. Model and mx check are fine.", "cause": "InstallPackageFiles copied every archive entry except project.mpr and package.xml. package.xml's `` list is the package's declaration of its project files, and nothing read it; the publisher's metadata sits at the archive root, undeclared.", "file": "`cmd/mxcli/marketplace/update.go` (`declaredProjectFiles`, `isProjectFile`, `ReasonNotAProjectFile`), `cmd/mxcli/cmd_marketplace_update.go` (`reportSkippedFiles` groups them on one line); tests `cmd/mxcli/marketplace/install_declared_files_test.go`, `cmd/mxcli/cmd_marketplace_skipped_report_test.go`", "insight": "The arbiter was one `mx module-import` into a blank 11.12.1 app with a before/after `find`: mx wrote exactly the `` entries of ExternalDatabaseConnector 6.3.0 + BusinessEvents 3.12.1 (175 + 16), no root file, and silently passed over a listed entry the archive lacks (the snowflake jar stripped from mx-modules/). That settles both 'which entries' and 'is a missing listed entry an error' without guessing. Comparing `` against `unzip -Z1` on every .mpk in the repo first showed the only undeclared entries are root-level metadata — cheap, and it is what made the declared list safe to trust. The earlier relative-path test (finding 2026-09-20) used manifest.json as its 'legitimate entry' and so encoded this bug as expected behaviour; check old tests' fixtures when tightening what gets installed. Distinguish an empty `` (installs nothing) from no list (fallback: skip root-level entries only).", "refs": ["mendixlabs/mxcli#1487", "ako/mxcli#1344"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7304aa6130..3c37f122fc 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 +- **`marketplace update` and `install` no longer write the package's metadata into the app root** (mendixlabs/mxcli#1487) — after updating Nanoflow Commons 6.2.1 → 7.4.0, `manifest.json`, `License.txt` and `SiemensMendixNanoflowCommons__7.4.0__READMEOSS_.html` sat beside the `.mpr` as untracked files, because every archive entry but `project.mpr` and `package.xml` was copied into the project. Only the files the package's `package.xml` lists under `` are installed now — exactly what `mx module-import` installs, measured on 11.12.1 with ExternalDatabaseConnector 6.3.0 and BusinessEvents 3.12.1, which includes passing over a listed file the archive lacks. What is left out (`License.txt`, `manifest.json`, the READMEOSS report, `Mendix EULA.docx`) is named on one line of the output. - **Rewriting a page, snippet or layout no longer deletes the widgets MDL cannot author** (mendixlabs/mxcli#1622) — `describe page` prints a classic data grid, a reference selector, a drop-down or a reference set selector as a `-- NOT re-executable` comment, so executing that output rebuilt the page without them and said only `Replaced page M.P`, with `mx check` clean on both sides. `create or modify` / `create or replace` of a stored page, snippet or layout that holds such a widget — one the statement does not declare by name — is now refused, listing each widget, with the `alter` that edits the document while keeping them and the `alter … { drop … }` that removes them on purpose first. `mxcli check -p` reports the same before anything is written, following an earlier `alter` of the document in the same script. - **A consumed REST service is written with Studio Pro's list markers** — `Operations`, an operation's `Headers` and `Parameters` are typed-array marker 3 (only `QueryParameters` is 2), measured on Studio Pro-authored services (11.14.0) and on `mx convert` output (11.6.6). mxcli wrote 2, copied from the legacy serializer, so running a Studio Pro-made service's `describe` output back rewrote it although nothing had changed — visible once that output parsed (mendixlabs/mxcli#1608). - **`create or modify consumed rest service` names the operations it removes** (mendixlabs/mxcli#1608) — the statement declares the service's whole operation list, so one naming only the operations to add deleted the stored ones — often made in Studio Pro, with their headers, timeouts and mappings — and the output said only `Modified rest client: MyFirstModule.BookingApi (2 operations)`. It still applies, as `create or modify entity` does for attributes, and now prints `⚠ create or modify consumed rest service MyFirstModule.BookingApi drops 1 existing operation(s) not listed in this statement: PostBooking` with the `alter consumed rest service … add operation` that adds one without touching the rest. diff --git a/cmd/mxcli/cmd_marketplace_skipped_report_test.go b/cmd/mxcli/cmd_marketplace_skipped_report_test.go new file mode 100644 index 0000000000..32202e704e --- /dev/null +++ b/cmd/mxcli/cmd_marketplace_skipped_report_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/marketplace" +) + +// Package metadata is left out of every install (mendixlabs/mxcli#1487), so it +// is named on one line; a widget the package would have rolled back keeps its +// own line, because that one is a decision the user may want to revisit. +func TestReportSkippedFiles_PackageMetadataOnOneLine(t *testing.T) { + var out bytes.Buffer + reportSkippedFiles(&out, []marketplace.SkippedFile{ + {Path: "License.txt", Reason: marketplace.ReasonNotAProjectFile}, + {Path: "manifest.json", Reason: marketplace.ReasonNotAProjectFile}, + {Path: "widgets/Charts.mpk", Reason: "older", Kept: "3.11.3", Offered: "3.4.0"}, + }) + got := out.String() + + if n := strings.Count(got, "License.txt"); n != 1 { + t.Errorf("License.txt named %d times, want once:\n%s", n, got) + } + if !strings.Contains(got, "Left out 2 package file(s) package.xml does not list as project files: License.txt, manifest.json.") { + t.Errorf("metadata not reported on one line:\n%s", got) + } + if !strings.Contains(got, "widgets/Charts.mpk — kept 3.11.3, package ships 3.4.0") { + t.Errorf("kept widget no longer reported:\n%s", got) + } +} diff --git a/cmd/mxcli/cmd_marketplace_update.go b/cmd/mxcli/cmd_marketplace_update.go index e902da3a0d..135d9a2e25 100644 --- a/cmd/mxcli/cmd_marketplace_update.go +++ b/cmd/mxcli/cmd_marketplace_update.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/mendixlabs/mxcli/cmd/mxcli/marketplace" "github.com/mendixlabs/mxcli/internal/auth" @@ -347,18 +348,29 @@ func init() { // modules in one order and then another used to roll widgets backwards with no // sign that anything had happened (an older widget is not a check error). These // lines are what makes that visible. +// +// The package's own metadata (License.txt, manifest.json, ...) is left out of +// every install, so it gets one line rather than one per file. func reportSkippedFiles(out io.Writer, skipped []marketplace.SkippedFile) { if len(skipped) == 0 { return } var kept, dupes []marketplace.SkippedFile + var metadata []string for _, s := range skipped { - if s.Kept != "" { + switch { + case s.Kept != "": kept = append(kept, s) - } else { + case s.Reason == marketplace.ReasonNotAProjectFile: + metadata = append(metadata, s.Path) + default: dupes = append(dupes, s) } } + if len(metadata) > 0 { + fmt.Fprintf(out, " Left out %d package file(s) package.xml does not list as project files: %s.\n", + len(metadata), strings.Join(metadata, ", ")) + } if len(kept) > 0 { fmt.Fprintf(out, "\n Kept %d newer widget(s) the package would have rolled back:\n", len(kept)) for _, s := range kept { diff --git a/cmd/mxcli/marketplace/install_declared_files_test.go b/cmd/mxcli/marketplace/install_declared_files_test.go new file mode 100644 index 0000000000..f28235cd8c --- /dev/null +++ b/cmd/mxcli/marketplace/install_declared_files_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package marketplace + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// A module package's package.xml lists, under , every project file the +// package installs. Everything else in the archive is the package's own +// metadata. Measured with `mx module-import` on Mendix 11.12.1, importing +// ExternalDatabaseConnector 6.3.0 and BusinessEvents 3.12.1 into a blank app: +// the files mx wrote were exactly the declared ones — not License.txt, +// manifest.json, the READMEOSS report or "Mendix EULA.docx", all of which sit at +// the root of those packages — and the one declared entry missing from the +// archive (the snowflake driver stripped from mx-modules/, see its README) was +// passed over without complaint. +const edcPackageXML = ` + + + + + + + + + + + +` + +// TestInstallPackageFiles_LeavesPackageMetadataOutOfTheProject is +// mendixlabs/mxcli#1487 (ako/mxcli#1344), reported against an update of +// Nanoflow Commons 6.2.1 → 7.4.0: +// +// the package's own metadata files end up in the **app root**: +// - `manifest.json` +// - `License.txt` +// - `SiemensMendixNanoflowCommons__7.4.0__READMEOSS_.html` +// +// InstallPackageFiles copied every entry but project.mpr and package.xml, so +// whatever else a publisher put at the package root landed beside the .mpr. +func TestInstallPackageFiles_LeavesPackageMetadataOutOfTheProject(t *testing.T) { + mpk := buildMPK(t, map[string]string{ + "package.xml": edcPackageXML, + "project.mpr": "model", + // Package metadata, at the root of every real package measured. + "License.txt": "license", + "manifest.json": `{"name":"ExternalDatabaseConnector"}`, + "SiemensMendixExternalDatabaseConnector__6.3.0__READMEOSS_2026-06-17__04-28-14.html": "", + "Mendix EULA.docx": "eula", + // Declared payload. The snowflake jar is declared and absent. + "vendorlib/gson-2.13.1.jar": "jar", + "javasource/externaldatabaseconnector/pojo/QueryDetails.java": "class QueryDetails {}", + "themesource/externaldatabaseconnector/settings.json": "{}", + "themesource/externaldatabaseconnector/undeclared-but-shipped.x": "not in package.xml", + }) + proj := t.TempDir() + + written, skipped, err := InstallPackageFiles(mpk, proj) + if err != nil { + t.Fatalf("InstallPackageFiles: %v", err) + } + + for _, name := range []string{"License.txt", "manifest.json", + "SiemensMendixExternalDatabaseConnector__6.3.0__READMEOSS_2026-06-17__04-28-14.html", + "Mendix EULA.docx"} { + if _, err := os.Stat(filepath.Join(proj, name)); err == nil { + t.Errorf("%s was written into the app root; it is package metadata, not a project file", name) + } + } + if _, err := os.Stat(filepath.Join(proj, "themesource", "externaldatabaseconnector", "undeclared-but-shipped.x")); err == nil { + t.Errorf("an entry package.xml does not list was installed") + } + + // Control: the declared payload must still arrive, or the assertions above + // pass because nothing was installed at all. + want := []string{ + filepath.Join("javasource", "externaldatabaseconnector", "pojo", "QueryDetails.java"), + filepath.Join("themesource", "externaldatabaseconnector", "settings.json"), + filepath.Join("vendorlib", "gson-2.13.1.jar"), + } + if strings.Join(written, "|") != strings.Join(want, "|") { + t.Errorf("written = %v\nwant %v", written, want) + } + for _, rel := range want { + if _, err := os.Stat(filepath.Join(proj, rel)); err != nil { + t.Errorf("declared file %s not installed: %v", rel, err) + } + } + + // What was left out is reported, not dropped silently. + var left []string + for _, s := range skipped { + left = append(left, s.Path) + } + sort.Strings(left) + wantLeft := []string{"License.txt", "Mendix EULA.docx", + "SiemensMendixExternalDatabaseConnector__6.3.0__READMEOSS_2026-06-17__04-28-14.html", + "manifest.json", "themesource/externaldatabaseconnector/undeclared-but-shipped.x"} + if strings.Join(left, "|") != strings.Join(wantLeft, "|") { + t.Errorf("skipped = %v\nwant %v", left, wantLeft) + } +} + +// TestInstallPackageFiles_RealPackageWritesOnlyItsDeclaredFiles runs the same +// question against the published ExternalDatabaseConnector 6.3.0 package in +// mx-modules/, so the parsing is held to a real package.xml (two default +// namespaces, a declared entry the archive lacks) rather than to the fixture +// above. The expectation is what `mx module-import` itself wrote. +func TestInstallPackageFiles_RealPackageWritesOnlyItsDeclaredFiles(t *testing.T) { + mpk := filepath.Join("..", "..", "..", "mx-modules", "ExternalDatabaseConnector-v6.3.0.mpk") + if _, err := os.Stat(mpk); err != nil { + t.Fatalf("fixture missing: %v", err) + } + proj := t.TempDir() + written, _, err := InstallPackageFiles(mpk, proj) + if err != nil { + t.Fatalf("InstallPackageFiles: %v", err) + } + + root, err := os.ReadDir(proj) + if err != nil { + t.Fatal(err) + } + var rootFiles, rootDirs []string + for _, e := range root { + if e.IsDir() { + rootDirs = append(rootDirs, e.Name()) + } else { + rootFiles = append(rootFiles, e.Name()) + } + } + if len(rootFiles) != 0 { + t.Errorf("files written into the app root: %v", rootFiles) + } + sort.Strings(rootDirs) + if got := strings.Join(rootDirs, ","); got != "javasource,themesource,vendorlib" { + t.Errorf("top-level directories = %s, want javasource,themesource,vendorlib", got) + } + // 176 entries declared, one of them (the snowflake driver) stripped from + // the fixture: mx module-import wrote 175. + if len(written) != 175 { + t.Errorf("wrote %d files, want the 175 declared files the archive holds", len(written)) + } +} + +// An empty is a declaration that the package installs no project +// files; no list at all is not, and falls back to leaving out only the root. +func TestInstallPackageFiles_EmptyListIsNotNoList(t *testing.T) { + entries := map[string]string{ + "License.txt": "license", + "themesource/probe/web/main.scss": "// probe", + } + withEmpty := map[string]string{"package.xml": ``} + for k, v := range entries { + withEmpty[k] = v + } + + written, _, err := InstallPackageFiles(buildMPK(t, withEmpty), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if len(written) != 0 { + t.Errorf("an empty installed %v", written) + } + + written, _, err = InstallPackageFiles(buildMPK(t, entries), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if strings.Join(written, "|") != filepath.Join("themesource", "probe", "web", "main.scss") { + t.Errorf("with no package.xml, written = %v; want the folder entry and not the root one", written) + } +} diff --git a/cmd/mxcli/marketplace/install_relative_dir_test.go b/cmd/mxcli/marketplace/install_relative_dir_test.go index cb25845484..00acb46342 100644 --- a/cmd/mxcli/marketplace/install_relative_dir_test.go +++ b/cmd/mxcli/marketplace/install_relative_dir_test.go @@ -17,10 +17,14 @@ import ( // install (2026-09-20): the module had already been transplanted into the model // when the bundled-file step failed, so the command reported failure over a // half-finished install. +// +// The entry in that report was manifest.json, which is package metadata and is +// no longer installed at all (mendixlabs/mxcli#1487); the guard refused every +// entry alike, so two project files stand in for it here. func TestInstallPackageFiles_RelativeProjectDir(t *testing.T) { mpk := buildMPK(t, map[string]string{ - "manifest.json": `{"name":"probe"}`, - "themesource/probe/web/main.scss": "// probe", + "javasource/probe/actions/Probe.java": "class Probe {}", + "themesource/probe/web/main.scss": "// probe", }) proj := t.TempDir() wd, _ := os.Getwd() @@ -36,7 +40,8 @@ func TestInstallPackageFiles_RelativeProjectDir(t *testing.T) { if len(written) != 2 { t.Fatalf("written = %v, want both entries", written) } - for _, rel := range []string{"manifest.json", filepath.Join("themesource", "probe", "web", "main.scss")} { + for _, rel := range []string{filepath.Join("javasource", "probe", "actions", "Probe.java"), + filepath.Join("themesource", "probe", "web", "main.scss")} { if _, err := os.Stat(filepath.Join(proj, rel)); err != nil { t.Errorf("%s not written under the project: %v", rel, err) } diff --git a/cmd/mxcli/marketplace/update.go b/cmd/mxcli/marketplace/update.go index f27972f006..677a2adc3b 100644 --- a/cmd/mxcli/marketplace/update.go +++ b/cmd/mxcli/marketplace/update.go @@ -4,6 +4,7 @@ package marketplace import ( "archive/zip" + "encoding/xml" "fmt" "io" "os" @@ -348,14 +349,23 @@ func setBoolField(doc bson.D, key string, value bool) { // 3.5.0 copy. // // The second looked like a cross-module dependency on a newer Atlas. It was not. -// Copying everything the package ships, rather than enumerating the directories -// that seem to matter, is what stops there being a third instance. -// Two entries are deliberately not installed, both measured on real packages — -// see widgetversion.go: a bundled widget older than the copy the project already -// has (it would silently roll the project back), and the unpacked twin of a -// widget the same package also ships as a .mpk. Both are returned in skipped -// rather than dropped quietly, because "the package wanted a different version" -// is exactly the thing that was invisible before. +// Copying everything the package declares, rather than enumerating the +// directories that seem to matter, is what stops there being a third instance. +// +// "Declares" is package.xml's list, and it is the line between payload +// and the package's own metadata: License.txt, manifest.json, a READMEOSS +// report and "Mendix EULA.docx" sit at the root of published packages, are not +// listed, and landed in the app root as untracked files (mendixlabs/mxcli#1487). +// `mx module-import` writes exactly the listed files and passes over a listed +// one the archive lacks — measured on 11.12.1 — so this does the same. +// +// Three kinds of entry are deliberately not installed, all measured on real +// packages: one package.xml does not list; a bundled widget older than the copy +// the project already has (it would silently roll the project back, see +// widgetversion.go); and the unpacked twin of a widget the same package also +// ships as a .mpk. All are returned in skipped rather than dropped quietly, +// because "the package wanted a different version" is exactly the thing that +// was invisible before. func InstallPackageFiles(mpkPath, projectDir string) (written []string, skipped []SkippedFile, err error) { // Anchor the project first. A relative projectDir ("." from `-p app.mpr`) // made filepath.Join drop the dot, so "manifest.json" was compared against @@ -370,6 +380,8 @@ func InstallPackageFiles(mpkPath, projectDir string) (written []string, skipped } defer zr.Close() + declared, listed := declaredProjectFiles(zr.File) + // Which unpacked trees duplicate a .mpk in this same package. twins := map[string]string{} for _, f := range zr.File { @@ -411,6 +423,10 @@ func InstallPackageFiles(mpkPath, projectDir string) (written []string, skipped if !strings.HasPrefix(filepath.Clean(dst), filepath.Clean(projectDir)+string(os.PathSeparator)) { return written, skipped, fmt.Errorf("package entry %q would write outside the project", f.Name) } + if !isProjectFile(f.Name, declared, listed) { + skipped = append(skipped, SkippedFile{Path: f.Name, Reason: ReasonNotAProjectFile}) + continue + } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return written, skipped, fmt.Errorf("create %s: %w", filepath.Dir(dst), err) } @@ -457,6 +473,61 @@ func duplicateOfPackagedWidget(name string, twins map[string]string) (string, bo return "", false } +// ReasonNotAProjectFile is the SkippedFile reason for an entry package.xml does +// not list: the package's own metadata (License.txt, manifest.json, ...), which +// Studio Pro does not install either. +const ReasonNotAProjectFile = "package metadata: package.xml does not list it as a project file" + +// declaredProjectFiles reads the project files a module package's package.xml +// lists under . listed is false when there is no such list at all — no +// package.xml, or one without — which is not the same as an empty list. +func declaredProjectFiles(entries []*zip.File) (declared map[string]bool, listed bool) { + for _, f := range entries { + if f.Name != "package.xml" { + continue + } + rc, err := zipentry.Open(f) + if err != nil { + return nil, false + } + defer rc.Close() + var manifest struct { + Files *struct { + File []struct { + Path string `xml:"path,attr"` + } `xml:"file"` + } `xml:"modelerProject>files"` + } + if xml.NewDecoder(rc).Decode(&manifest) != nil || manifest.Files == nil { + return nil, false + } + declared = make(map[string]bool, len(manifest.Files.File)) + for _, d := range manifest.Files.File { + declared[packagePath(d.Path)] = true + } + return declared, true + } + return nil, false +} + +// isProjectFile reports whether a package entry belongs in the project. With a +// list that list decides. Without one, only an entry at the package +// root is left out: every root entry in the packages measured was metadata, and +// every project file sits under a folder (javasource/, themesource/, widgets/). +func isProjectFile(name string, declared map[string]bool, listed bool) bool { + name = packagePath(name) + if listed { + return declared[name] + } + return strings.Contains(name, "/") +} + +// packagePath puts a package path in one spelling, so a package.xml entry +// written with Windows separators still matches the archive's. +func packagePath(p string) string { + return strings.TrimPrefix(strings.ReplaceAll(p, `\`, "/"), "./") +} + // PerformInstall adds a module that is not yet in the project, copying it from // referenceMpr and installing everything the package ships. // diff --git a/docs-site/src/guides/marketplace.md b/docs-site/src/guides/marketplace.md index 9f84c5e76a..5e95f29122 100644 --- a/docs-site/src/guides/marketplace.md +++ b/docs-site/src/guides/marketplace.md @@ -242,7 +242,7 @@ Administration updated 4.3.2 → 4.5.0 - **Element identity.** The runtime keys entities and their attributes on the model's `GUID`, so a module whose documents are replaced without carrying the old GUIDs is a *different* module to the database and its tables are dropped on the next deploy. Studio Pro transplants them; so does this. - **Access.** A user role's grant of a module role lives in the project's security document, not the module, so removing the module takes the grants with it and putting it back does not return them. -- **Everything else the package ships.** A module is not only its model: the `.mpk` carries widget binaries under `widgets/`, styling and design-property declarations under `themesource/`, and whatever else it needs. All of it is replaced — only `project.mpr` and `package.xml` are manifest rather than payload. DataWidgets 3.11.3 replaces 49 such files, and skipping them leaves the app running old widget code and reporting `CE6083` for design properties the module itself declares. +- **Everything else the package ships.** A module is not only its model: the `.mpk` carries widget binaries under `widgets/`, styling and design-property declarations under `themesource/`, and whatever else it needs. All of it is replaced: every file the package's `package.xml` lists under ``, which is exactly what `mx module-import` installs. What it does not list is the package's own metadata — `License.txt`, `manifest.json`, the `READMEOSS` report, `Mendix EULA.docx` — and is left out, named on one line of the output. DataWidgets 3.11.3 replaces 49 such files, and skipping them leaves the app running old widget code and reporting `CE6083` for design properties the module itself declares. It does not use `mx module-import`, which would rewrite an MPR v2 project as v1 and refuses theme modules. Units are copied with mxcli's own writer, so the project keeps its format. diff --git a/mdl-examples/bug-tests/1487-marketplace-package-metadata-in-app-root.mdl b/mdl-examples/bug-tests/1487-marketplace-package-metadata-in-app-root.mdl new file mode 100644 index 0000000000..30dc69f541 --- /dev/null +++ b/mdl-examples/bug-tests/1487-marketplace-package-metadata-in-app-root.mdl @@ -0,0 +1,38 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1487 (ako/mxcli#1344): `marketplace update` writes the +-- package's manifest.json, License.txt and OSS readme into the app root +-- ============================================================================ +-- +-- Symptom, reported against v0.26.0 updating Nanoflow Commons 6.2.1 → 7.4.0: +-- +-- the package's own metadata files end up in the **app root**: +-- - `manifest.json` +-- - `License.txt` +-- - `SiemensMendixNanoflowCommons__7.4.0__READMEOSS_.html` +-- +-- Studio Pro does not put these in the project folder, so they show up as +-- untracked files. `marketplace install` had the same defect: both copy the +-- package's bundled files through InstallPackageFiles. +-- +-- Root cause: +-- InstallPackageFiles copied every archive entry except project.mpr and +-- package.xml. package.xml's list is what separates a package's +-- project files from its metadata, and nothing read it. +-- +-- Measured with `mx module-import` on 11.12.1 (ExternalDatabaseConnector +-- 6.3.0 and BusinessEvents 3.12.1 into a blank app): mx writes exactly the +-- listed files — none of License.txt, manifest.json, the READMEOSS report or +-- "Mendix EULA.docx" — and passes over a listed entry the archive lacks. +-- +-- After fix: only listed files are installed; the rest are reported in one +-- line ("Left out N package file(s) package.xml does not list ..."). +-- +-- Usage (on a project with Nanoflow Commons installed from the marketplace): +-- mxcli marketplace update -p app.mpr --to --no-baseline +-- git status --short # no License.txt / manifest.json / *READMEOSS*.html +-- mxcli exec mdl-examples/bug-tests/1487-marketplace-package-metadata-in-app-root.mdl -p app.mpr +-- ============================================================================ + +-- The model side of the update, unchanged by the fix: the module is present. +list modules; From 74e344ce814324aea060eae93f85f244f198ffca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:54:54 +0000 Subject: [PATCH 28/50] feat(describe): list a legacy table's cell addresses and contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MDL cannot create a legacy table, so describe prints it as one NOT re-executable line — which left the new `table1 cell(row, col)` address undiscoverable and hid which cell held which widget. The cells are now listed under that line, 1-based as ALTER PAGE takes them: -- Forms$Table (table1) -- NOT re-executable: … -- cell(1,1): tInCell -- cell(1,2): (empty) Control: without the output change the test misses all three cell lines. On the converted 11.13.0 table after replace/drop, describe prints cell(1,1): tRep and cell(1,2): (empty), matching the stored cells. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- mdl/executor/cmd_pages_describe.go | 11 +++++ mdl/executor/cmd_pages_describe_output.go | 7 +++ mdl/executor/cmd_pages_describe_parse.go | 31 +++++++++++++ .../cmd_pages_describe_tablecells_test.go | 44 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 mdl/executor/cmd_pages_describe_tablecells_test.go diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index aa439bdeb0..e6157f7b74 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -810,6 +810,13 @@ type rawDataGridColumn struct { } // rawWidget represents a widget from raw BSON data for MDL output. +// rawTableCell is one legacy table cell: its 1-based ALTER PAGE address and +// the names (or types, when unnamed) of the widgets directly inside it. +type rawTableCell struct { + Row, Column int + Widgets []string +} + type rawWidget struct { Type string Name string @@ -873,6 +880,10 @@ type rawWidget struct { SubmitBehaviour string SubmitOnInputDelay *int LayoutGridWidth string + // TableCells are a legacy Forms$Table's cells, which MDL cannot create + // but ALTER PAGE can address as `table1 cell(row, col)`; describe lists + // them so the address is discoverable (mendixlabs/mxcli#1453). + TableCells []rawTableCell // Formatting is the widget's Forms$FormattingInfo as MDL properties // (DateFormat, CustomDateFormat, DecimalPrecision, GroupDigits), only the // non-default ones (ako/mxcli#968). diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 443e90d4a6..73d9e5f46f 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1253,6 +1253,13 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { fmt.Fprintf(ctx.Output, " (%s)", w.Name) } fmt.Fprint(ctx.Output, " -- NOT re-executable: mxcli cannot author this widget, so re-running this script would drop it\n") + for _, c := range w.TableCells { + contents := "(empty)" + if len(c.Widgets) > 0 { + contents = strings.Join(c.Widgets, ", ") + } + fmt.Fprintf(ctx.Output, "%s-- cell(%d,%d): %s\n", prefix, c.Row, c.Column, contents) + } } } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 4ef9c3b6c4..af836cf39c 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -866,12 +866,43 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s }) return []rawWidget{widget} + case "Forms$Table": + widget.TableCells = parseTableCells(w) + return []rawWidget{widget} + default: // For unknown types, just note them return []rawWidget{widget} } } +// parseTableCells reads a legacy table's cells in stored order. A cell's +// TopRowIndex / LeftColumnIndex are 0-based; the address ALTER PAGE takes is +// 1-based. +func parseTableCells(w map[string]any) []rawTableCell { + var cells []rawTableCell + for _, c := range getBsonArrayElements(w["Cells"]) { + cell, ok := c.(map[string]any) + if !ok { + continue + } + rc := rawTableCell{Row: extractInt(cell["TopRowIndex"]) + 1, Column: extractInt(cell["LeftColumnIndex"]) + 1} + for _, cw := range getBsonArrayElements(cell["Widgets"]) { + m, ok := cw.(map[string]any) + if !ok { + continue + } + label, _ := m["Name"].(string) + if label == "" { + label, _ = m["$Type"].(string) + } + rc.Widgets = append(rc.Widgets, label) + } + cells = append(cells, rc) + } + return cells +} + func parseLayoutGridRows(ctx *ExecContext, w map[string]any, entityContext ...string) []rawWidgetRow { entCtx := "" if len(entityContext) > 0 { diff --git a/mdl/executor/cmd_pages_describe_tablecells_test.go b/mdl/executor/cmd_pages_describe_tablecells_test.go new file mode 100644 index 0000000000..48f9e57b6e --- /dev/null +++ b/mdl/executor/cmd_pages_describe_tablecells_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// mendixlabs/mxcli#1453 (ako/mxcli#1326): ALTER PAGE addresses a legacy table +// cell as `table1 cell(row, col)`, but describe printed the table as one +// unauthorable line, so neither the address nor which cell held what could be +// read back. The cells are listed under it, 1-based, in the converted shape a +// Mendix 11.13 table is stored in (Forms$DbTableCell, 0-based indexes). +func TestDescribeLegacyTable_ListsCellAddresses(t *testing.T) { + cell := func(row, col int32, widgets ...any) map[string]any { + return map[string]any{ + "$Type": "Forms$DbTableCell", "TopRowIndex": row, "LeftColumnIndex": col, + "Width": int32(1), "Height": int32(1), "IsHeader": false, + "Widgets": append([]any{int32(2)}, widgets...), + } + } + table := map[string]any{ + "$Type": "Forms$Table", + "Name": "table1", + "Cells": []any{int32(2), + cell(0, 0, map[string]any{"$Type": "Forms$DynamicText", "Name": "tInCell"}), + cell(0, 1), + cell(1, 0, map[string]any{"$Type": "Forms$DynamicText", "Name": "tA"}, + map[string]any{"$Type": "Forms$DynamicText", "Name": "tB"}), + }, + } + got := describeStoredWidget(t, table) + for _, want := range []string{ + "-- Forms$Table (table1)", + "-- cell(1,1): tInCell", + "-- cell(1,2): (empty)", + "-- cell(2,1): tA, tB", + } { + if !strings.Contains(got, want) { + t.Errorf("description lacks %q:\n%s", want, got) + } + } +} From ee7169bac84d2f8efc3ba0c720eaaef3cee93a26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:55:03 +0000 Subject: [PATCH 29/50] fix: marketplace diff/update fail with 'path too long' under %TEMP% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, `marketplace diff` and `update` failed building the reference project (mendixlabs/mxcli#1486, ako/mxcli#1343): Extracting module files into C:\Users\\AppData\Local\Temp\mxdiff1185592798\target-ref The specified file name or path is too long, or a component of the specified path is too long. mx refuses to write any file whose path is 260 characters or longer, and it does so itself, so the limit holds on Linux too: measured with mx 11.12.1, 259 is written and 260 refused with the reported message. Bundled node_modules trees reach 181 characters (11.12.1 blank app) to about 202 (NanoflowCommons 7.x), and %TEMP%\mxdiff\target-ref left room for no user name at all. NewWorkDir builds references under the shortest root the user owns — %USERPROFILE%\.mxcli\t on Windows, /tmp on POSIX, or TMP/TMPDIR when shorter — with one-letter names beneath it. A drive-root folder is not a candidate: children of C:\ inherit Modify for every authenticated user. A remaining refusal names the directory, its length and the budget left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UDwYCiqWmrM3iXhnz2YeJT --- ...22-marketplace-work-dir-path-too-long.json | 1 + CHANGELOG.md | 1 + cmd/mxcli/cmd_marketplace_diff.go | 11 +- cmd/mxcli/cmd_marketplace_install.go | 4 +- cmd/mxcli/cmd_marketplace_update.go | 6 +- cmd/mxcli/marketplace/scratch.go | 12 +- .../marketplace/scratch_integration_test.go | 10 +- cmd/mxcli/marketplace/workdir.go | 83 ++++++++++ .../marketplace/workdir_integration_test.go | 151 ++++++++++++++++++ cmd/mxcli/marketplace/workdir_test.go | 137 ++++++++++++++++ docs-site/src/guides/marketplace.md | 18 +++ ...486-marketplace-work-dir-path-too-long.mdl | 42 +++++ 12 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-work-dir-path-too-long.json create mode 100644 cmd/mxcli/marketplace/workdir.go create mode 100644 cmd/mxcli/marketplace/workdir_integration_test.go create mode 100644 cmd/mxcli/marketplace/workdir_test.go create mode 100644 mdl-examples/bug-tests/1486-marketplace-work-dir-path-too-long.mdl diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-work-dir-path-too-long.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-work-dir-path-too-long.json new file mode 100644 index 0000000000..2639cf0702 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-22-marketplace-work-dir-path-too-long.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli/marketplace", "date": "2026-10-22", "symptom": "On Windows, `mxcli marketplace diff` / `update` fail building the reference project: `Extracting module files into C:\\Users\\\\AppData\\Local\\Temp\\mxdiff1185592798\\target-ref` then `The specified file name or path is too long, or a component of the specified path is too long.` Works with `TMP=C:\\mxt`.", "cause": "mx refuses any file path of 260+ characters ITSELF (Mendix FileSystem.OpenNewFileForWriting throws PathTooLongException), on every OS. The reference project was built in os.MkdirTemp(\"\", \"mxdiff\")/target-ref — %TEMP% is ~28+len(user) on Windows — and bundled node_modules reach 181 (11.12.1 blank template) to ~202 (NanoflowCommons 7.x) characters, so 7.x fit for no user name.", "file": "`cmd/mxcli/marketplace/workdir.go` (`NewWorkDir`, `workRoot`, `mxFailure`, `mxMaxPath`), callers in `cmd/mxcli/cmd_marketplace_{diff,update,install}.go` (one-letter slot dirs), `scratch.go` (hint on mx failure); tests `workdir_test.go`, `workdir_integration_test.go` (-tags integration, real mx)", "insight": "The 'Windows-only' bug reproduces on Linux: the first `mx create-project` in the scratchpad (~85-char path) threw PathTooLongException, which is what exposed that the limit is mx's, not the OS's — then a bisection on base-dir length (77 ok / 78 fails for create-project; 259 ok / 260 fails for module-import of a 200-char entry) pinned it exactly, with the reported message verbatim. So no Windows box is needed: point TMPDIR at a 48-char dir and import a package with one deep declared entry. Sizing came from the deepest file in a real blank app plus the npm tarballs NanoflowCommons 7.x pins (registry.npmjs.org is reachable; marketplace downloads need a PAT). Wrong turns to skip: a relative .mpr or --output-dir does not help (mx resolves to absolute, and create-project --output-dir ./x throws UriFormatException); and a drive-root default like C:\\mxt is NOT safe — children of C:\\ inherit Authenticated Users:Modify, so another account could swap the package before import. Shortest PRIVATE root wins (%USERPROFILE%\\.mxcli\\t, /tmp via 0700 MkdirTemp, or TMP/TMPDIR if shorter); that still bounds the Windows user name at ~22 chars for 202-char packages, hence the hint naming dir, length and remaining budget.", "refs": ["mendixlabs/mxcli#1486", "ako/mxcli#1343"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c37f122fc..bf994f8681 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 +- **`marketplace diff` and `update` no longer fail with "path is too long" under the default Windows `%TEMP%`** (mendixlabs/mxcli#1486) — `mx` refuses to write any file whose path is 260 characters or longer, on every platform (measured on Linux with mx 11.12.1: a 259-character path is written, a 260-character one refused with the reported *"The specified file name or path is too long"*), and the reference project was built in `%TEMP%\mxdiff\target-ref`. The deepest files mx writes there are bundled `node_modules` trees — 181 characters in the 11.12.1 blank app, about 202 in NanoflowCommons 7.x — so that layout fitted 7.x for no user name at all. References are now built under the shortest directory the user owns (`%USERPROFILE%\.mxcli\t` on Windows, `/tmp` on Linux and macOS, or `TMP`/`TMPDIR` when shorter), named in one letter beneath it; never a drive-root folder such as `C:\mxt`, whose children every local account may modify. A package deep enough to hit the limit anyway gets an error naming the directory, its length, what it leaves for the package, and how to move it. `marketplace install` builds its reference the same way. - **`marketplace update` and `install` no longer write the package's metadata into the app root** (mendixlabs/mxcli#1487) — after updating Nanoflow Commons 6.2.1 → 7.4.0, `manifest.json`, `License.txt` and `SiemensMendixNanoflowCommons__7.4.0__READMEOSS_.html` sat beside the `.mpr` as untracked files, because every archive entry but `project.mpr` and `package.xml` was copied into the project. Only the files the package's `package.xml` lists under `` are installed now — exactly what `mx module-import` installs, measured on 11.12.1 with ExternalDatabaseConnector 6.3.0 and BusinessEvents 3.12.1, which includes passing over a listed file the archive lacks. What is left out (`License.txt`, `manifest.json`, the READMEOSS report, `Mendix EULA.docx`) is named on one line of the output. - **Rewriting a page, snippet or layout no longer deletes the widgets MDL cannot author** (mendixlabs/mxcli#1622) — `describe page` prints a classic data grid, a reference selector, a drop-down or a reference set selector as a `-- NOT re-executable` comment, so executing that output rebuilt the page without them and said only `Replaced page M.P`, with `mx check` clean on both sides. `create or modify` / `create or replace` of a stored page, snippet or layout that holds such a widget — one the statement does not declare by name — is now refused, listing each widget, with the `alter` that edits the document while keeping them and the `alter … { drop … }` that removes them on purpose first. `mxcli check -p` reports the same before anything is written, following an earlier `alter` of the document in the same script. - **A consumed REST service is written with Studio Pro's list markers** — `Operations`, an operation's `Headers` and `Parameters` are typed-array marker 3 (only `QueryParameters` is 2), measured on Studio Pro-authored services (11.14.0) and on `mx convert` output (11.6.6). mxcli wrote 2, copied from the legacy serializer, so running a Studio Pro-made service's `describe` output back rewrote it although nothing had changed — visible once that output parsed (mendixlabs/mxcli#1608). diff --git a/cmd/mxcli/cmd_marketplace_diff.go b/cmd/mxcli/cmd_marketplace_diff.go index ff5022aa4d..33ed2fcb5d 100644 --- a/cmd/mxcli/cmd_marketplace_diff.go +++ b/cmd/mxcli/cmd_marketplace_diff.go @@ -81,7 +81,7 @@ func runMarketplaceDiff(cmd *cobra.Command, args []string) error { return err } - work, err := os.MkdirTemp("", "mxdiff") + work, err := marketplace.NewWorkDir("mxd") if err != nil { return fmt.Errorf("create work directory: %w", err) } @@ -107,7 +107,7 @@ func runMarketplaceDiff(cmd *cobra.Command, args []string) error { if err != nil { return err } - baseRef, basePkgModule, err := referenceFor(ctx, client, base, mendixVersion, work, "base") + baseRef, basePkgModule, err := referenceFor(ctx, client, base, mendixVersion, work, "b") if err != nil { return err } @@ -132,7 +132,7 @@ func runMarketplaceDiff(cmd *cobra.Command, args []string) error { if err != nil { return err } - targetRef, targetPkgModule, err := referenceFor(ctx, client, targetVersion, mendixVersion, work, "target") + targetRef, targetPkgModule, err := referenceFor(ctx, client, targetVersion, mendixVersion, work, "t") if err != nil { return err } @@ -205,7 +205,10 @@ func downloadVersion(ctx context.Context, client *mp.Client, v *mp.Version, work func referenceFor(ctx context.Context, client *mp.Client, v *mp.Version, mendixVersion, work, slot string) (mprPath, pkgModule string, err error) { - refDir := filepath.Join(work, slot+"-ref") + // The slot is one letter on purpose: mx writes the whole reference project + // beneath refDir, and every character here is one less for the package's own + // paths (see marketplace.NewWorkDir). + refDir := filepath.Join(work, slot) if err := os.MkdirAll(refDir, 0o755); err != nil { return "", "", err } diff --git a/cmd/mxcli/cmd_marketplace_install.go b/cmd/mxcli/cmd_marketplace_install.go index 8477bf99cb..8863a8d848 100644 --- a/cmd/mxcli/cmd_marketplace_install.go +++ b/cmd/mxcli/cmd_marketplace_install.go @@ -326,13 +326,13 @@ func reportTransplantInstall(out io.Writer, mprPath, mendixVer, moduleName, labe func installByTransplant(ctx context.Context, mpkPath, mprPath, moduleName, mendixVer string, versionNumber, versionID string) (*mp.UpdateResult, error) { - work, err := os.MkdirTemp("", "mxinstall") + work, err := mp.NewWorkDir("mxi") if err != nil { return nil, err } defer os.RemoveAll(work) - refDir := filepath.Join(work, "ref") + refDir := filepath.Join(work, "r") if err := os.MkdirAll(refDir, 0o755); err != nil { return nil, err } diff --git a/cmd/mxcli/cmd_marketplace_update.go b/cmd/mxcli/cmd_marketplace_update.go index 135d9a2e25..2b6ff903f1 100644 --- a/cmd/mxcli/cmd_marketplace_update.go +++ b/cmd/mxcli/cmd_marketplace_update.go @@ -88,7 +88,7 @@ func runMarketplaceUpdate(cmd *cobra.Command, args []string) error { return err } - work, err := os.MkdirTemp("", "mxupdate") + work, err := marketplace.NewWorkDir("mxu") if err != nil { return err } @@ -135,7 +135,7 @@ func runMarketplaceUpdate(cmd *cobra.Command, args []string) error { " downloadable. It is not, and --force does not help: there is nothing to compare against.\n"+ " hint: re-run with --no-baseline to update without that check (local edits are lost silently)", berr) } - baseRef, basePkgModule, rerr := referenceFor(ctx, client, base, mendixVersion, work, "base") + baseRef, basePkgModule, rerr := referenceFor(ctx, client, base, mendixVersion, work, "b") if rerr != nil { return rerr } @@ -167,7 +167,7 @@ func runMarketplaceUpdate(cmd *cobra.Command, args []string) error { if err != nil { return err } - targetRef, _, err := referenceFor(ctx, client, targetVersion, mendixVersion, work, "target") + targetRef, _, err := referenceFor(ctx, client, targetVersion, mendixVersion, work, "t") if err != nil { return err } diff --git a/cmd/mxcli/marketplace/scratch.go b/cmd/mxcli/marketplace/scratch.go index 6ef0a3753e..236459b5b7 100644 --- a/cmd/mxcli/marketplace/scratch.go +++ b/cmd/mxcli/marketplace/scratch.go @@ -130,10 +130,10 @@ func ModuleForVersionIDs(mprPath string, versionIDs []string) (moduleName, versi // edits. // // workDir must already exist and is written to freely; callers own its lifetime -// (t.TempDir in tests, an os.MkdirTemp the command removes in production). -// **Keep it short**: mx create-project extracts its template with .NET path -// handling and fails with PathTooLongException under a deep directory, so a -// nested scratch path breaks it where /tmp/xxxx works. +// (a NewWorkDir the command removes in production). **Keep it short**: mx +// refuses to write a file whose path reaches mxMaxPath, on every platform, and +// the blank template and bundled node_modules trees come close on their own — +// see NewWorkDir. // // newBackend is used to remove a module the template already ships (see below). func PackageProject(ctx context.Context, mpkPath, mendixVersion, workDir string, newBackend func() backend.FullBackend) (string, error) { @@ -199,7 +199,7 @@ func PackageProject(ctx context.Context, mpkPath, mendixVersion, workDir string, imp := exec.CommandContext(ctx, mxPath, "module-import", importable, mprPath) docker.PrepareMxCommand(imp) if out, err := imp.CombinedOutput(); err != nil { - return "", fmt.Errorf("mx module-import failed: %w\n%s", err, strings.TrimSpace(string(out))) + return "", mxFailure("module-import", err, out, workDir) } return mprPath, nil } @@ -232,7 +232,7 @@ func blankProjectInto(ctx context.Context, mxPath, mendixVersion, workDir string create.Dir = workDir docker.PrepareMxCommand(create) if out, err := create.CombinedOutput(); err != nil { - return fmt.Errorf("mx create-project failed: %w\n%s", err, strings.TrimSpace(string(out))) + return mxFailure("create-project", err, out, workDir) } if cerr != nil { return nil diff --git a/cmd/mxcli/marketplace/scratch_integration_test.go b/cmd/mxcli/marketplace/scratch_integration_test.go index 073af030d5..7a9a5365cf 100644 --- a/cmd/mxcli/marketplace/scratch_integration_test.go +++ b/cmd/mxcli/marketplace/scratch_integration_test.go @@ -15,12 +15,12 @@ import ( "github.com/mendixlabs/mxcli/cmd/mxcli/docker" ) -// shortTemp returns a work dir with a short path. mx create-project extracts its -// template with .NET path handling and dies with PathTooLongException under a -// deep directory, so t.TempDir() nested inside a long module path is not safe -// here — this was found the hard way. +// shortTemp returns a work dir with a short path. mx refuses to write a file +// whose path reaches mxMaxPath, so t.TempDir() nested inside a long module path +// is not safe here — this was found the hard way. NewWorkDir is what the +// commands use. func shortTemp(t *testing.T) string { - d, err := os.MkdirTemp("", "mxref") + d, err := NewWorkDir("mxref") if err != nil { t.Fatal(err) } diff --git a/cmd/mxcli/marketplace/workdir.go b/cmd/mxcli/marketplace/workdir.go new file mode 100644 index 0000000000..130349d3a6 --- /dev/null +++ b/cmd/mxcli/marketplace/workdir.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package marketplace + +import ( + "fmt" + "os" + "runtime" + "strings" +) + +// mxMaxPath is the path length mx refuses to write a file at or beyond. It is +// Windows' MAX_PATH, but mx enforces it itself (Mendix's +// FileSystem.OpenNewFileForWriting throws PathTooLongException), so it holds on +// Linux and macOS too. Measured with mx 11.12.1 on Linux: a 259-character file +// path is written, a 260-character one is refused. +const mxMaxPath = 260 + +// NewWorkDir creates the scratch directory a command builds its reference +// projects in, under the shortest root the user owns. +// +// Length is the whole point. mx writes every file of the blank template and of +// the imported package beneath this directory, and the deepest of those are a +// bundled node_modules tree: 181 characters for the 11.12.1 blank app, about +// 202 for NanoflowCommons 7.x. Under the default Windows %TEMP% +// (C:\Users\\AppData\Local\Temp) and the old +// \mxdiff<10 digits>\target-ref beneath it, that left no room for a user name +// at all (mendixlabs/mxcli#1486). +// +// The caller removes the directory. Name its contents in a letter or two: every +// character spent here is one less for the package's own paths. +func NewWorkDir(prefix string) (string, error) { + home, _ := os.UserHomeDir() + root := workRoot(runtime.GOOS, os.TempDir(), home) + if err := os.MkdirAll(root, 0o700); err == nil { + if dir, err := os.MkdirTemp(root, prefix); err == nil { + return dir, nil + } + } + return os.MkdirTemp("", prefix) +} + +// workRoot picks the shortest of the roots a work directory may go under. +// +// Every candidate is private to the user, which rules out the obvious short +// one: a directory at a Windows drive root inherits Modify for all +// authenticated users from C:\, so another account could swap the downloaded +// package between its download and its import. So on Windows the alternative to +// %TEMP% is ~\.mxcli\t, beside the caches mxcli already keeps there; on POSIX +// it is /tmp, where os.MkdirTemp makes a 0700 directory (macOS's per-user +// $TMPDIR is about 49 characters on its own). An explicitly shorter TMP or +// TMPDIR still wins, so pointing it somewhere short remains the way out. +func workRoot(goos, tempDir, home string) string { + candidates := []string{tempDir} + switch { + case goos == "windows" && home != "": + candidates = append(candidates, strings.TrimRight(home, `\`)+`\.mxcli\t`) + case goos != "windows": + candidates = append(candidates, "/tmp") + } + best := candidates[0] + for _, c := range candidates[1:] { + if len(c) < len(best) { + best = c + } + } + return best +} + +// mxFailure wraps a failed mx invocation, explaining a path-length refusal. +// mx's own message names neither the limit nor that the directory was mxcli's +// choice, so it reads as a broken install. +func mxFailure(step string, err error, out []byte, workDir string) error { + msg := strings.TrimSpace(string(out)) + if !strings.Contains(msg, "path is too long") && !strings.Contains(msg, "PathTooLongException") { + return fmt.Errorf("mx %s failed: %w\n%s", step, err, msg) + } + return fmt.Errorf("mx %s failed: %w\n%s\n"+ + "hint: mx refuses to write any file whose path is %d characters or longer, on every platform.\n"+ + " It was writing under %s (%d characters), which leaves %d for the package's own paths.\n"+ + " Point TMP (Windows) or TMPDIR at a shorter directory you own and re-run.", + step, err, msg, mxMaxPath, workDir, len(workDir), mxMaxPath-1-len(workDir)-1) +} diff --git a/cmd/mxcli/marketplace/workdir_integration_test.go b/cmd/mxcli/marketplace/workdir_integration_test.go new file mode 100644 index 0000000000..eb6f53b15a --- /dev/null +++ b/cmd/mxcli/marketplace/workdir_integration_test.go @@ -0,0 +1,151 @@ +//go:build integration + +// SPDX-License-Identifier: Apache-2.0 + +package marketplace + +import ( + "archive/zip" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestPackageProject_BuildsUnderADeepTempDir reproduces mendixlabs/mxcli#1486 +// with a real mx, on Linux, because the limit is mx's own and not Windows': +// +// Extracting module files into C:\Users\\AppData\Local\Temp\mxdiff1185592798\target-ref +// The specified file name or path is too long, or a component of the specified path is too long. +// +// TMPDIR is pointed at a directory as deep as a Windows %TEMP%, and the package +// carries one file as deep as NanoflowCommons 7.x bundles. The control builds +// in the reported layout and must fail with the reported message; the fix +// builds in NewWorkDir's and must succeed. +func TestPackageProject_BuildsUnderADeepTempDir(t *testing.T) { + const version = "11.12.1" + if !mxVersionAvailable(version) { + t.Skipf("mxbuild %s is not cached; run 'mxcli setup mxbuild --version %s'", version, version) + } + pkg := withDeepEntry(t, filepath.Join("..", "..", "..", "mx-modules", "BusinessEvents_3.12.0.mpk"), nanoflowCommons7Deepest) + + // %TEMP% for a 20-character user name, C:\Users\\AppData\Local\Temp, + // is 48 characters. + deepTemp := "/tmp/mx1486-" + strings.Repeat("t", 48-len("/tmp/mx1486-")) + if len(deepTemp) != 48 { + t.Fatalf("deep temp is %d characters, want 48", len(deepTemp)) + } + if err := os.MkdirAll(deepTemp, 0o755); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(deepTemp) }) + t.Setenv("TMPDIR", deepTemp) + + // Control: the layout the report shows, built exactly. + reported := filepath.Join(deepTemp, "mxdiff1185592798", "target-ref") + if err := os.MkdirAll(reported, 0o755); err != nil { + t.Fatal(err) + } + _, err := PackageProject(context.Background(), pkg, version, reported, testBackend) + if err == nil || !strings.Contains(err.Error(), "The specified file name or path is too long") { + t.Fatalf("control: building under %s (%d characters) should fail as reported; got %v", + reported, len(reported), err) + } + t.Logf("control failed as reported:\n%s", err) + + // Fix. + work, err := NewWorkDir("mxd") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(work) }) + refDir := filepath.Join(work, "t") + if err := os.MkdirAll(refDir, 0o755); err != nil { + t.Fatal(err) + } + mpr, err := PackageProject(context.Background(), pkg, version, refDir, testBackend) + if err != nil { + t.Fatalf("building under %s (%d characters): %v", refDir, len(refDir), err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(mpr), deepEntryName(nanoflowCommons7Deepest))); err != nil { + t.Fatalf("mx did not write the deep entry: %v", err) + } + if strings.HasPrefix(work, deepTemp) { + t.Errorf("NewWorkDir chose the deep TMPDIR (%s) over a shorter root", work) + } +} + +// deepEntryName is a project file path of exactly n characters, nested the way +// a bundled node_modules tree is. +func deepEntryName(n int) string { + const head = "javasource/businessevents/node_modules/" + var b strings.Builder + b.WriteString(head) + for b.Len() < n-len("x.txt")-20 { + b.WriteString("deeply-nested-dir/") + } + b.WriteString(strings.Repeat("x", n-b.Len()-len(".txt"))) + b.WriteString(".txt") + return b.String() +} + +// withDeepEntry copies a package and adds one declared file of the given path +// length, so mx module-import has to write it. +func withDeepEntry(t *testing.T, src string, n int) string { + t.Helper() + zr, err := zip.OpenReader(src) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + defer zr.Close() + + name := deepEntryName(n) + if len(name) != n { + t.Fatalf("deep entry is %d characters, want %d", len(name), n) + } + out := filepath.Join(t.TempDir(), "deep.mpk") + f, err := os.Create(out) + if err != nil { + t.Fatal(err) + } + defer f.Close() + zw := zip.NewWriter(f) + for _, e := range zr.File { + if e.Name != "package.xml" { + if err := zw.Copy(e); err != nil { + t.Fatal(err) + } + continue + } + rc, err := e.Open() + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + t.Fatal(err) + } + manifest := strings.Replace(string(body), "", "\n ", 1) + w, err := zw.Create("package.xml") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(manifest)); err != nil { + t.Fatal(err) + } + } + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("deep")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return out +} diff --git a/cmd/mxcli/marketplace/workdir_test.go b/cmd/mxcli/marketplace/workdir_test.go new file mode 100644 index 0000000000..5346a797a0 --- /dev/null +++ b/cmd/mxcli/marketplace/workdir_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package marketplace + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Path lengths that decide whether mx can build a reference project at all. +// +// mx refuses to write a file whose absolute path is 260 characters or longer, +// on every platform: Mendix's own FileSystem.OpenNewFileForWriting throws +// PathTooLongException, so Linux fails exactly as Windows does. Measured with +// mx 11.12.1 on Linux: create-project of the blank template succeeds under a +// 77-character directory and fails under a 78-character one, and module-import +// of a package carrying a 200-character entry succeeds at 259 and fails at 260 +// with the reported "The specified file name or path is too long". +const ( + // blankTemplateDeepest is the longest relative path `mx create-project` + // writes for the 11.12.1 blank app (NanoflowCommons 6.0.0's node_modules). + blankTemplateDeepest = len("javascriptsource/nanoflowcommons/actions/node_modules/@react-native-async-storage/async-storage/macos/RNCAsyncStorage.xcodeproj/xcshareddata/xcschemes/RNCAsyncStorage-macOS.xcscheme") + // nanoflowCommons7Deepest is the longest path NanoflowCommons 7.x bundles, + // taken from the npm packages its package.json pins + // (react-native-nitro-geolocation 1.4.3). + nanoflowCommons7Deepest = len("javascriptsource/nanoflowcommons/actions/node_modules/react-native-nitro-geolocation/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrogeolocation/Func_void_std__vector_ReverseGeocodedAddress_.kt") +) + +// TestWorkRoot_FitsWhatTheReportedTempDirDidNot is mendixlabs/mxcli#1486 +// (ako/mxcli#1343): +// +// Extracting module files into C:\Users\\AppData\Local\Temp\mxdiff1185592798\target-ref +// The specified file name or path is too long, or a component of the specified path is too long. +// +// The work directory came from os.MkdirTemp("", ...), i.e. %TEMP%, which on +// Windows is deep inside the user profile. Under it, NanoflowCommons 7.x does +// not fit for any user name at all. The fix puts the work directory under the +// shortest root the user owns, and names what lives in it in one letter. +func TestWorkRoot_FitsWhatTheReportedTempDirDidNot(t *testing.T) { + const ( + user = "GlennCorvers" + winTemp = `C:\Users\` + user + `\AppData\Local\Temp` + winHome = `C:\Users\` + user + ) + + // The reported layout, and that it cannot fit. The random suffix of + // os.MkdirTemp is up to ten digits. + reported := winTemp + `\mxdiff1185592798\target-ref` + if n := len(reported) + 1 + nanoflowCommons7Deepest; n < mxMaxPath { + t.Fatalf("control: the reported layout should not fit NanoflowCommons 7 (%d < %d)", n, mxMaxPath) + } + + root := workRoot("windows", winTemp, winHome) + if want := winHome + `\.mxcli\t`; root != want { + t.Fatalf("workRoot = %q, want %q", root, want) + } + // The deepest reference-project directory now: root\<10 digits>\. + deepest := root + `\mxd1185592798\` + "t" + if n := len(deepest) + 1 + nanoflowCommons7Deepest; n >= mxMaxPath { + t.Errorf("NanoflowCommons 7 under %s is %d characters; mx refuses %d and more", deepest, n, mxMaxPath) + } + if n := len(deepest) + 1 + blankTemplateDeepest; n >= mxMaxPath { + t.Errorf("the blank template under %s is %d characters; mx refuses %d and more", deepest, n, mxMaxPath) + } +} + +// TestWorkRoot_PrefersTheShortestRootTheUserOwns: the rule is "shortest wins", +// among roots private to the user — never a directory at the drive root, whose +// children inherit Modify for every authenticated user on Windows, and which +// would let another account swap the package between download and import. +func TestWorkRoot_PrefersTheShortestRootTheUserOwns(t *testing.T) { + for _, tc := range []struct { + name, goos, temp, home, want string + }{ + {"windows default temp", "windows", `C:\Users\GlennCorvers\AppData\Local\Temp`, `C:\Users\GlennCorvers`, `C:\Users\GlennCorvers\.mxcli\t`}, + // The reporter's workaround keeps working: TMP is honoured when shorter. + {"windows TMP set short", "windows", `C:\mxt`, `C:\Users\GlennCorvers`, `C:\mxt`}, + {"windows no home", "windows", `C:\Users\GlennCorvers\AppData\Local\Temp`, "", `C:\Users\GlennCorvers\AppData\Local\Temp`}, + {"macOS per-user TMPDIR", "darwin", "/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/T/", "/Users/glenn", "/tmp"}, + {"linux", "linux", "/tmp", "/home/glenn", "/tmp"}, + {"linux TMPDIR set long", "linux", "/run/user/1000/some/long/tmp", "/home/glenn", "/tmp"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := workRoot(tc.goos, tc.temp, tc.home); got != tc.want { + t.Errorf("workRoot(%s, %q, %q) = %q, want %q", tc.goos, tc.temp, tc.home, got, tc.want) + } + }) + } +} + +// TestNewWorkDir_IsCreatedAndPrivate: the directory exists, sits under the +// chosen root, and on POSIX is the 0700 os.MkdirTemp makes. +func TestNewWorkDir_IsCreatedAndPrivate(t *testing.T) { + dir, err := NewWorkDir("mxt") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + t.Fatalf("work dir not created: %v", err) + } + if !strings.HasPrefix(filepath.Base(dir), "mxt") { + t.Errorf("work dir %s does not carry its prefix", dir) + } + if runtime.GOOS != "windows" && fi.Mode().Perm() != 0o700 { + t.Errorf("work dir mode = %v, want 0700", fi.Mode().Perm()) + } +} + +// TestMxFailure_ExplainsAPathTooLong: mx's own message names neither the limit +// nor that the directory is mxcli's choice, so on its own it reads as a broken +// install. The hint says which directory, how long, and how to move it. +func TestMxFailure_ExplainsAPathTooLong(t *testing.T) { + workDir := `C:\Users\GlennCorvers\AppData\Local\Temp\mxdiff1185592798\target-ref` + out := []byte("Extracting module files into " + workDir + "\n" + + "The specified file name or path is too long, or a component of the specified path is too long.\n") + err := mxFailure("module-import", errors.New("exit status 208"), out, workDir) + msg := err.Error() + for _, want := range []string{"mx module-import failed", "exit status 208", + "The specified file name or path is too long", workDir, "68 characters", "260", "TMP"} { + if !strings.Contains(msg, want) { + t.Errorf("error should mention %q; got:\n%s", want, msg) + } + } + + // Control: any other mx failure is passed through without the hint. + other := mxFailure("module-import", errors.New("exit status 173"), + []byte("Project already contains a module with the name"), workDir) + if strings.Contains(other.Error(), "TMP") { + t.Errorf("hint attached to an unrelated failure:\n%s", other) + } +} diff --git a/docs-site/src/guides/marketplace.md b/docs-site/src/guides/marketplace.md index 5e95f29122..bf2703e68e 100644 --- a/docs-site/src/guides/marketplace.md +++ b/docs-site/src/guides/marketplace.md @@ -356,6 +356,24 @@ Note that `--force` still builds the baseline. It is not wasted work — under replace, and that list is the whole point of the check. Use `--no-baseline` when you genuinely want to skip it. +### Where references are built: the 260-character limit + +`mx` refuses to write any file whose path is 260 characters or longer — on every +platform, not only Windows: the check is in `mx` itself. A reference project holds +the blank template plus the imported package, and their deepest files are bundled +`node_modules` trees: 181 characters for the 11.12.1 blank app, about 202 for +NanoflowCommons 7.x. That leaves under 60 characters for the directory they are +built in, and the default Windows `%TEMP%` (`C:\Users\\AppData\Local\Temp`) +used most of it, so `diff` and `update` failed with *"The specified file name or +path is too long"*. + +References are therefore built under the shortest directory the user owns: +`%USERPROFILE%\.mxcli\t` on Windows, `/tmp` on Linux and macOS, or `TMP` / +`TMPDIR` when that is shorter still. It is never a folder at a drive root such as +`C:\mxt`, which every local account may modify; point `TMP` there yourself if you +accept that. If a package is deep enough to hit the limit anyway, the error names +the directory, its length, and what is left for the package. + ## Repairing the model (`mxcli fix`) `mx update-widgets` and `mx rename-design-properties` each fix something only Mendix can fix, and each rewrites an MPR v2 project into the single-file v1 format while doing it. Measured on 11.12.1: `update-widgets` took 369 `.mxunit` files to 0 and a 69,632-byte index to 14,405,632 bytes; `rename-design-properties` took 1,865 files to 0 and a 249,856-byte index to 39,895,040 bytes, having renamed 149 design properties across 41 documents. The conversion is one-way. diff --git a/mdl-examples/bug-tests/1486-marketplace-work-dir-path-too-long.mdl b/mdl-examples/bug-tests/1486-marketplace-work-dir-path-too-long.mdl new file mode 100644 index 0000000000..5f0ffa26a8 --- /dev/null +++ b/mdl-examples/bug-tests/1486-marketplace-work-dir-path-too-long.mdl @@ -0,0 +1,42 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1486 (ako/mxcli#1343): marketplace diff/update fail with +-- 'path too long' when unpacking under %TEMP% +-- ============================================================================ +-- +-- Symptom, reported against v0.26.0 on Windows 11 (Mendix 11.12.4): +-- +-- Extracting module files into C:\Users\\AppData\Local\Temp\mxdiff1185592798\target-ref +-- The specified file name or path is too long, or a component of the specified path is too long. +-- +-- The same command succeeds with TMP=C:\mxt. +-- +-- Root cause: +-- mx refuses to write any file whose path is 260 characters or longer, and +-- it does so itself (Mendix's FileSystem.OpenNewFileForWriting), so the +-- limit holds on Linux and macOS too. Measured with mx 11.12.1 on Linux: +-- module-import of a package with a 200-character entry succeeds under a +-- 58-character directory and fails under a 59-character one, with exactly +-- the reported message; create-project of the blank app fails from a +-- 78-character directory up. +-- +-- The reference project was built in %TEMP%\mxdiff<10 digits>\target-ref. +-- The deepest bundled files are node_modules trees (181 characters in the +-- 11.12.1 blank app, about 202 in NanoflowCommons 7.x), so for +-- NanoflowCommons 7.x that layout fits no user name at all. +-- +-- After fix: references are built under the shortest directory the user owns +-- (%USERPROFILE%\.mxcli\t on Windows, /tmp on POSIX, or TMP/TMPDIR when +-- shorter), named in one letter beneath it; a remaining refusal says which +-- directory, how long, and what is left for the package. +-- +-- Usage (Windows, a project with Nanoflow Commons 6.x installed): +-- mxcli marketplace diff -p app.mpr --to 7.4.0 +-- mxcli marketplace update -p app.mpr --to 7.4.0 --save-edits ./local-edits +-- mxcli exec mdl-examples/bug-tests/1486-marketplace-work-dir-path-too-long.mdl -p app.mpr +-- Regression test with a real mx (Linux, mxbuild 11.12.1 cached): +-- go test -tags integration ./cmd/mxcli/marketplace/ -run TestPackageProject_BuildsUnderADeepTempDir +-- ============================================================================ + +-- After the update the module is listed at its new version. +list modules; From 7b8a4de2c49252fdc4f4087fe56c1bea152a5110 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:57:23 +0000 Subject: [PATCH 30/50] test: name the new bug-tests by their upstream issue numbers Bug-test files carry the upstream (mendixlabs/mxcli) issue number; 1306- and 1540- were the fork mirror numbers, and both collide with unrelated upstream issues (1306-lint-languages-builtin.mdl already exists). Now 1358- (the #1358 follow-up) and 1612-. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XD4JpQem7z5pjk2WuYRqf --- .../2026-10-22-create-change-system-owner-ce1050.json | 2 +- .../2026-10-22-system-owner-path-needs-stored-owner.json | 2 +- ...-owner.mdl => 1358-system-owner-path-needs-stored-owner.mdl} | 0 ...-owner.fail.mdl => 1612-create-change-system-owner.fail.mdl} | 2 +- ...nge-system-owner.mdl => 1612-create-change-system-owner.mdl} | 0 5 files changed, 3 insertions(+), 3 deletions(-) rename mdl-examples/bug-tests/{1306-system-owner-path-needs-stored-owner.mdl => 1358-system-owner-path-needs-stored-owner.mdl} (100%) rename mdl-examples/bug-tests/{1540-create-change-system-owner.fail.mdl => 1612-create-change-system-owner.fail.mdl} (94%) rename mdl-examples/bug-tests/{1540-create-change-system-owner.mdl => 1612-create-change-system-owner.mdl} (100%) diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json index ec2f3e60e9..56e2a6a0b3 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-create-change-system-owner-ce1050.json @@ -1 +1 @@ -{"area": "mdl/executor", "date": "2026-10-22", "symptom": "`$S = create M.Entity (Name = 'x', System.owner = $User);` (or `change $o (System.changedBy = …)`, `set $o/System.owner = …`) passed `check -p`, and exec refused it with `member \"System.owner\" on entity M.Entity is not a known association (and a one-qualifier name cannot be an attribute) — create the association first (`create or modify association System.owner from ... to ...`)` — advice no project can follow. Issue mendixlabs/mxcli#1612 (11.12.5) expected the member to be writable 'as Studio Pro allows'", "cause": "resolveMemberChange looks the qualifier's module up in its domain model; System's virtual model lists real associations (UserRoles, User_Language) but not owner/changedBy, which are entity flags — so it fell to the generic 'unknown association' branch. And there IS nothing to write: Association \"System.owner\" in a MemberChange is CE1050 \"Unknown association 'System.owner'.\" for every entity shape, microflow and nanoflow", "file": "`mdl/executor/validate_microflow_system_member_change.go` (MDL-CHANGE01, project-free, exec-enforced; `refuseSystemMemberChange` at the create/change call sites in `cmd_microflows_builder_actions.go`, NOT inside resolveMemberChange)", "insight": "Measure before building what the issue asks for: the expected behaviour was impossible, so the fix became an accurate refusal. The first measurement LIED: one CE1613 (a wrong-case `System.Owner` row in the same project) suppressed all six CE1050s in that `mx check` run, so every row read 'clean' — a CE1613 anywhere masks CE1050 checks. Measure each variant group in a project with no other error, and distrust an all-clean result that contradicts its own sibling. Keep the refusal OUT of resolveMemberChange: list operations share it, and `find $l by System.owner = $u` builds clean (describe then prints it as `find $l by owner`, a separate round-trip drift). The working alternative for seeded/owned data is a modelled association to System.User — verified clean", "refs": ["mendixlabs/mxcli#1612", "ako/mxcli#1540", "mdl-examples/bug-tests/1540-create-change-system-owner.mdl", "mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl"], "ce": ["CE1050", "CE1613"], "rules": ["MDL-CHANGE01"]} +{"area": "mdl/executor", "date": "2026-10-22", "symptom": "`$S = create M.Entity (Name = 'x', System.owner = $User);` (or `change $o (System.changedBy = …)`, `set $o/System.owner = …`) passed `check -p`, and exec refused it with `member \"System.owner\" on entity M.Entity is not a known association (and a one-qualifier name cannot be an attribute) — create the association first (`create or modify association System.owner from ... to ...`)` — advice no project can follow. Issue mendixlabs/mxcli#1612 (11.12.5) expected the member to be writable 'as Studio Pro allows'", "cause": "resolveMemberChange looks the qualifier's module up in its domain model; System's virtual model lists real associations (UserRoles, User_Language) but not owner/changedBy, which are entity flags — so it fell to the generic 'unknown association' branch. And there IS nothing to write: Association \"System.owner\" in a MemberChange is CE1050 \"Unknown association 'System.owner'.\" for every entity shape, microflow and nanoflow", "file": "`mdl/executor/validate_microflow_system_member_change.go` (MDL-CHANGE01, project-free, exec-enforced; `refuseSystemMemberChange` at the create/change call sites in `cmd_microflows_builder_actions.go`, NOT inside resolveMemberChange)", "insight": "Measure before building what the issue asks for: the expected behaviour was impossible, so the fix became an accurate refusal. The first measurement LIED: one CE1613 (a wrong-case `System.Owner` row in the same project) suppressed all six CE1050s in that `mx check` run, so every row read 'clean' — a CE1613 anywhere masks CE1050 checks. Measure each variant group in a project with no other error, and distrust an all-clean result that contradicts its own sibling. Keep the refusal OUT of resolveMemberChange: list operations share it, and `find $l by System.owner = $u` builds clean (describe then prints it as `find $l by owner`, a separate round-trip drift). The working alternative for seeded/owned data is a modelled association to System.User — verified clean", "refs": ["mendixlabs/mxcli#1612", "ako/mxcli#1540", "mdl-examples/bug-tests/1612-create-change-system-owner.mdl", "mdl-examples/bug-tests/1612-create-change-system-owner.fail.mdl"], "ce": ["CE1050", "CE1613"], "rules": ["MDL-CHANGE01"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json index beefdb5631..a6a5afe398 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-system-owner-path-needs-stored-owner.json @@ -1 +1 @@ -{"area": "mdl/executor", "date": "2026-10-22", "symptom": "MDL-RETRIEVE02's own suggested rewrite `retrieve $Owner from System.User where [id = $Order/System.owner] first;` passed `check -p` and exec, then mxbuild failed `[CE0161] \"Error(s) in XPath constraint.\" at Retrieve object(s) activity 'Retrieve User from database'` — whenever $Order's entity stores no owner (11.12.5). #641's `[System.owner = '[%CurrentUser%]']` on an entity declared in the same script passed the same way", "cause": "Nothing typed a VARIABLE in a constraint: #641's rule judged System.owner only on the retrieved entity, and only for a STORED entity (`ent == nil -> continue`), so the ordinary one-script shape (entity + microflow) was never judged", "file": "`mdl/executor/validate_retrieve_system_member_path.go` (wired beside validateRetrieveOperandTypes for microflows and nanoflows), `mdl/executor/validate.go` (`scriptSystemMemberRoot`, `systemMemberStoreFix`, validateRetrieveConstraints takes sc), `mdl/executor/flow_object_types.go` (MDL-SET01's variable typing, extracted), MDL-RETRIEVE02 suggestion names the precondition", "insight": "A rule's SUGGESTION is a code path too: MDL-RETRIEVE02's rewrite was measured clean on an entity that stores owner and its precondition never measured — run a suggested rewrite through the inverse case before shipping it. Measured matrix (11.12.5): `[id = $o/System.owner|changedBy]` is clean iff the generalization ROOT stores the member (own entity, FileDocument specialization, specialization of an owning root), CE0161 otherwise (plain entity, its specialization, a `create`-bound variable). An entity the script ALTERS must be unknown, not 'does not store': `alter entity X add attribute owner: AutoOwner` is exactly what the user does after reading the message, and it builds clean. Point the fix at the root (`alter entity …`) — the specialization refuses Auto* members", "refs": ["ako/mxcli#1306", "mendixlabs/mxcli#1358", "mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl"], "ce": ["CE0161"], "rules": ["MDL-RETRIEVE02"]} +{"area": "mdl/executor", "date": "2026-10-22", "symptom": "MDL-RETRIEVE02's own suggested rewrite `retrieve $Owner from System.User where [id = $Order/System.owner] first;` passed `check -p` and exec, then mxbuild failed `[CE0161] \"Error(s) in XPath constraint.\" at Retrieve object(s) activity 'Retrieve User from database'` — whenever $Order's entity stores no owner (11.12.5). #641's `[System.owner = '[%CurrentUser%]']` on an entity declared in the same script passed the same way", "cause": "Nothing typed a VARIABLE in a constraint: #641's rule judged System.owner only on the retrieved entity, and only for a STORED entity (`ent == nil -> continue`), so the ordinary one-script shape (entity + microflow) was never judged", "file": "`mdl/executor/validate_retrieve_system_member_path.go` (wired beside validateRetrieveOperandTypes for microflows and nanoflows), `mdl/executor/validate.go` (`scriptSystemMemberRoot`, `systemMemberStoreFix`, validateRetrieveConstraints takes sc), `mdl/executor/flow_object_types.go` (MDL-SET01's variable typing, extracted), MDL-RETRIEVE02 suggestion names the precondition", "insight": "A rule's SUGGESTION is a code path too: MDL-RETRIEVE02's rewrite was measured clean on an entity that stores owner and its precondition never measured — run a suggested rewrite through the inverse case before shipping it. Measured matrix (11.12.5): `[id = $o/System.owner|changedBy]` is clean iff the generalization ROOT stores the member (own entity, FileDocument specialization, specialization of an owning root), CE0161 otherwise (plain entity, its specialization, a `create`-bound variable). An entity the script ALTERS must be unknown, not 'does not store': `alter entity X add attribute owner: AutoOwner` is exactly what the user does after reading the message, and it builds clean. Point the fix at the root (`alter entity …`) — the specialization refuses Auto* members", "refs": ["ako/mxcli#1306", "mendixlabs/mxcli#1358", "mdl-examples/bug-tests/1358-system-owner-path-needs-stored-owner.mdl"], "ce": ["CE0161"], "rules": ["MDL-RETRIEVE02"]} diff --git a/mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl b/mdl-examples/bug-tests/1358-system-owner-path-needs-stored-owner.mdl similarity index 100% rename from mdl-examples/bug-tests/1306-system-owner-path-needs-stored-owner.mdl rename to mdl-examples/bug-tests/1358-system-owner-path-needs-stored-owner.mdl diff --git a/mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl b/mdl-examples/bug-tests/1612-create-change-system-owner.fail.mdl similarity index 94% rename from mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl rename to mdl-examples/bug-tests/1612-create-change-system-owner.fail.mdl index 70ced0df8b..a1e4b1699d 100644 --- a/mdl-examples/bug-tests/1540-create-change-system-owner.fail.mdl +++ b/mdl-examples/bug-tests/1612-create-change-system-owner.fail.mdl @@ -11,7 +11,7 @@ mdl 1; -- (measured on 11.12.5). exec used to refuse it with advice to create a System -- association, which no project can do, while `check -p` passed it. -- `mxcli check` must refuse it as MDL-CHANGE01. See the companion --- 1540-create-change-system-owner.mdl for the form that builds. +-- 1612-create-change-system-owner.mdl for the form that builds. -- -- This file is expected to FAIL `mxcli check`. -- ============================================================================ diff --git a/mdl-examples/bug-tests/1540-create-change-system-owner.mdl b/mdl-examples/bug-tests/1612-create-change-system-owner.mdl similarity index 100% rename from mdl-examples/bug-tests/1540-create-change-system-owner.mdl rename to mdl-examples/bug-tests/1612-create-change-system-owner.mdl From 712ba4e47edae19abfefb09554817d4e76cc56d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:59:31 +0000 Subject: [PATCH 31/50] fix: alter settings webui sets OptimizedClient, and describe emits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Web UI setting UseOptimizedClient — which client the app runs on — was shown by `show settings` but missing from `describe settings`, and `alter settings webui (OptimizedClient: 'Yes')` failed with "unknown settings section: webui". - `alter settings webui (OptimizedClient: 'Yes' | 'No' | 'MigrationMode')` matches case-insensitively, stores Mendix's spelling, and refuses a non-member (MDL-SET03 at check time, and in exec). - UpdateProjectSettings overlays UseOptimizedClient onto the stored Forms$WebUIProjectSettingsPart. It used to pass that part through as read, so an executor case alone would have reported "Updated" and written nothing. Presence-gated: the property arrived in 9.10, and exec refuses a part that does not carry it. - DESCRIBE SETTINGS emits the statement, so the setting round-trips. Measured on a blank 11.14.0 app: mx check reports 0 errors after `OptimizedClient: 'No'`, and also 0 errors with 'React' forced into the property, so the non-member refusal is the only thing that catches one. Part of mendixlabs/mxcli#1549 (ako/mxcli#1447). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- ...alter-settings-webui-optimized-client.json | 1 + .../skills/mendix/project-settings/SKILL.md | 28 +++- cmd/mxcli/syntax/features_misc.go | 12 +- .../src/reference/settings/alter-settings.md | 17 ++ .../settings/list-describe-settings.md | 1 + .../bug-tests/1549-alter-settings-webui.mdl | 34 ++++ mdl/backend/modelsdk/settings_write.go | 13 +- .../modelsdk/settings_write_webui_test.go | 99 +++++++++++ mdl/executor/cmd_settings.go | 66 +++++++- mdl/executor/cmd_settings_validation_test.go | 6 +- mdl/executor/cmd_settings_webui_test.go | 155 ++++++++++++++++++ mdl/executor/validate_settings.go | 3 + mdl/settingsoverlay/settingsoverlay.go | 18 ++ mdl/settingsoverlay/webui_settings_test.go | 47 ++++++ 14 files changed, 488 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-webui-optimized-client.json create mode 100644 mdl-examples/bug-tests/1549-alter-settings-webui.mdl create mode 100644 mdl/backend/modelsdk/settings_write_webui_test.go create mode 100644 mdl/executor/cmd_settings_webui_test.go create mode 100644 mdl/settingsoverlay/webui_settings_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-webui-optimized-client.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-webui-optimized-client.json new file mode 100644 index 0000000000..d499a0beb4 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-alter-settings-webui-optimized-client.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-22", "symptom": "`alter settings webui (OptimizedClient: 'Yes')` → `Error: unknown settings section: webui (expected model, configuration, constant, LANGUAGE, or workflows)`; `show settings` printed `Web UI Settings | OptimizedClient: Yes` but `describe settings` had no Web UI statement, so the setting could be neither set nor round-tripped from MDL", "cause": "Read-only half-feature: the reader filled model.WebUISettings and listSettings printed it, but the executor had no `webui` section and UpdateProjectSettings passed the Forms$WebUIProjectSettingsPart through as read — so an executor case alone would have printed \"Updated webui settings\" and written nothing (the backend test failed with `stored UseOptimizedClient = \"Yes\", want No` until the overlay case existed)", "file": "mdl/executor/cmd_settings.go (alterSettings `webui` case, storedWebUISettings, useOptimizedClientValues, describeSettings); mdl/settingsoverlay/settingsoverlay.go (SetWebUISettings, IsWebUIPart); mdl/backend/modelsdk/settings_write.go (UpdateProjectSettings Web UI case); mdl/executor/validate_settings.go (typedSettingsKeys webui); tests mdl/executor/cmd_settings_webui_test.go, mdl/backend/modelsdk/settings_write_webui_test.go, mdl/settingsoverlay/webui_settings_test.go", "insight": "A settings section that `show` can display is not one that can be written: UpdateProjectSettings only overlays the parts it has a case for and passes every other part through byte-for-byte, so adding an alter section needs a backend case too, and only a test on the stored BSON tells the two apart (an executor test with a mock backend passes either way). Storage name is Forms$WebUIProjectSettingsPart, not Settings$ (measured on 11.6.6, 11.13.0, 11.14.0 fixtures); Model SDK says useOptimizedClient introduced 9.10.0 with no per-member gates, so the overlay is presence-gated and exec refuses a part without it. Measured on a blank 11.14.0 app: `mx check` = 0 errors after `webui No` + ExtraJvmParameters, and 0 errors with 'React' forced into UseOptimizedClient — mxbuild does not catch a non-member, so the settingsEnum refusal (MDL-SET03) is load-bearing. Key spelled `OptimizedClient` to match what `list settings` prints and the create-page skill / run-local docs already point at", "refs": ["mendixlabs/mxcli#1549", "ako/mxcli#1447"]} diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index 4fddcf8731..de440828d8 100644 --- a/.claude/skills/mendix/project-settings/SKILL.md +++ b/.claude/skills/mendix/project-settings/SKILL.md @@ -10,11 +10,11 @@ description: "Read and change project settings in MDL — database configuration | lines | section | |---|---| -| 22-31 | When to Use This Skill | -| 32-319 | Commands | -| 320-347 | Common Patterns | -| 348-356 | Checklist | -| 357-361 | AfterStartupMicroflow Must Return Boolean | +| 22-32 | When to Use This Skill | +| 33-337 | Commands | +| 338-365 | Common Patterns | +| 366-374 | Checklist | +| 375-379 | AfterStartupMicroflow Must Return Boolean | # Project Settings @@ -28,6 +28,7 @@ Use this skill when the user wants to: - Modify hash algorithms, Java versions, or rounding modes - View or modify language settings - Configure workflow settings (user entity, parallelism, groups) +- Switch between the React and the classic web client (Web UI settings) ## Commands @@ -317,6 +318,23 @@ Four things worth knowing: for. What it does mean is that the removed group's runtime row stops being maintained while the user tasks already assigned to it keep their association. +### Web UI Settings (which client the app runs on) + +```sql +mdl 1; +alter settings webui (OptimizedClient: 'Yes'); -- the React client +alter settings webui (OptimizedClient: 'No'); -- the classic (Dojo) client +``` + +- **Values:** `Yes`, `No`, `MigrationMode`, matched case-insensitively. Anything + else is refused by `mxcli check` (MDL-SET03) and by exec — measured on a blank + 11.14.0 app, `mx check` reports **0 errors** for an out-of-range value, so the + refusal is the only thing that catches it. +- Stored as `Forms$WebUIProjectSettingsPart.UseOptimizedClient` (Mendix 9.10+); a + project that does not store it is refused rather than told "Updated". +- Widgets differ per client — check `list settings` → `OptimizedClient` before + choosing one. `describe settings` emits this statement, so it round-trips. + ## Common Patterns ### PostgreSQL Configuration diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index e8f8e09b10..5189b5a65e 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -571,7 +571,7 @@ ALTER NAVIGATION Responsive { Register(SyntaxFeature{ Path: "settings", - Summary: "Project settings — model, configuration, constants, language, workflows", + Summary: "Project settings — model, configuration, constants, language, workflows, web UI", Keywords: []string{ "settings", "project settings", "configuration", "startup", "shutdown", "hash algorithm", "java version", @@ -667,7 +667,7 @@ create or modify translations in Administration for nl_NL ( Register(SyntaxFeature{ Path: "settings.alter", - Summary: "Modify project settings — model, configuration, constants, language, workflows", + Summary: "Modify project settings — model, configuration, constants, language, workflows, web UI", Keywords: []string{ "alter settings", "modify settings", "change settings", "after startup", "before shutdown", "hash algorithm", @@ -675,7 +675,7 @@ create or modify translations in Administration for nl_NL ( "add language", "remove language", "enable language", "translations", "optimistic locking", "concurrency", "lost update", "workflow group", "workflow groups", "add group", "task assignment", - "jvm parameters", "proxy", + "web ui", "webui", "react client", "optimized client", "classic client", "jvm parameters", "proxy", }, Syntax: `ALTER SETTINGS RUNTIME (: , ...); ALTER SETTINGS CONFIGURATION '' (: , ...); @@ -690,6 +690,7 @@ ALTER SETTINGS WORKFLOWS (UserEntity: ''); ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP '' [(Description: '')]; ALTER SETTINGS WORKFLOWS MODIFY GROUP '' (Description: ''); ALTER SETTINGS WORKFLOWS DROP GROUP ''; +ALTER SETTINGS WEBUI (OptimizedClient: 'Yes' | 'No' | 'MigrationMode'); CREATE [OR MODIFY] CONFIGURATION '' [(: , ...)]; DROP CONFIGURATION ''; @@ -714,6 +715,11 @@ ALTER SETTINGS CONFIGURATION 'Default' ( ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888' ); +-- WEBUI picks the client the app runs on: Yes is the React client, No the +-- classic (Dojo) one, MigrationMode Mendix's mixed mode. Widgets differ per +-- client, so check it (LIST SETTINGS) before choosing one. +ALTER SETTINGS WEBUI (OptimizedClient: 'Yes'); + -- LANGUAGE ADD/REMOVE change the ENABLED languages — the list under App -- Settings > Languages, and the only languages a build emits anything for. A -- translation written for a language that is not enabled is stored, passes every diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index c339ca517e..d659aba12a 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -24,6 +24,8 @@ ALTER SETTINGS WORKFLOWS MODIFY GROUP 'name' ( Description: 'text' ) ALTER SETTINGS WORKFLOWS DROP GROUP 'name' + ALTER SETTINGS WEBUI ( OptimizedClient: 'Yes' | 'No' | 'MigrationMode' ) + ## Description Modifies project settings by category. Each category has its own syntax and available keys. Properties are written `Key: value` in a parenthesised list, as in every other MDL statement; the older `key = value, ...` without parentheses still runs and warns `MDL-DEPR060`, and `mxcli fmt --upgrade` rewrites it. @@ -42,6 +44,11 @@ used for workflow tasks and default task parallelism, and the **workflow groups** — the named buckets under App Settings ▸ Workflows ▸ Groups that a user task's group targeting selects from. Groups need Mendix **11.2** or later. +**WEBUI** settings choose the client the app runs on: `OptimizedClient: 'Yes'` is +the React client, `'No'` the classic (Dojo) client, `'MigrationMode'` Mendix's +mixed mode. The value is matched case-insensitively; anything else is rejected by +`mxcli check` (MDL-SET03) and by the executor. + ## Parameters **key** @@ -188,6 +195,16 @@ List the groups with [`LIST WORKFLOW GROUPS`](list-describe-settings.md). ALTER SETTINGS RUNTIME ( JavaVersion: '17' ); ``` +### Switch to the classic web client + +```sql +ALTER SETTINGS WEBUI ( OptimizedClient: 'No' ); +``` + +Stored as `UseOptimizedClient` on the Web UI settings part (Mendix 9.10 and +later). `DESCRIBE SETTINGS` emits the statement, and `LIST SETTINGS` shows the +value as `OptimizedClient`. + ### Remove a constant override from a configuration ```sql diff --git a/docs-site/src/reference/settings/list-describe-settings.md b/docs-site/src/reference/settings/list-describe-settings.md index 6ccd71a8ff..097abb606f 100644 --- a/docs-site/src/reference/settings/list-describe-settings.md +++ b/docs-site/src/reference/settings/list-describe-settings.md @@ -23,6 +23,7 @@ The available settings categories are: | `CONSTANT` | Constant value overrides per configuration. Shows which constants have non-default values in each configuration. An override whose value is private — stored on the developer's workstation rather than in the shared model — is reported as `(private)`; its value is not in the project and mxcli cannot show it. | | `LANGUAGE` | Localization settings: DefaultLanguageCode and available languages. | | `WORKFLOWS` | Workflow engine settings: UserEntity, DefaultTaskParallelism, etc. | +| `WEBUI` | Web UI settings: `OptimizedClient` — `Yes` (React client), `No` (classic client) or `MigrationMode`. | ## Parameters diff --git a/mdl-examples/bug-tests/1549-alter-settings-webui.mdl b/mdl-examples/bug-tests/1549-alter-settings-webui.mdl new file mode 100644 index 0000000000..e833bd03f2 --- /dev/null +++ b/mdl-examples/bug-tests/1549-alter-settings-webui.mdl @@ -0,0 +1,34 @@ +mdl 1; +-- Bug mendixlabs/mxcli#1549 (ako/mxcli#1447): Web UI settings (OptimizedClient) +-- cannot be described or altered +-- +-- Symptom: +-- $ mxcli -p App.mpr -c "list settings" +-- | Web UI Settings | OptimizedClient: Yes | +-- $ mxcli -p App.mpr -c "describe settings" -- no Web UI section +-- $ mxcli exec webui.mdl -p App.mpr +-- Error: unknown settings section: webui (expected model, configuration, constant, LANGUAGE, or workflows) +-- +-- UseOptimizedClient picks the client the app runs on — Yes the React client, No +-- the classic (Dojo) one, MigrationMode Mendix's mixed mode — and `run --local` +-- and the create-page skill both depend on it, so an agent setting up an app from +-- MDL could not set it, and one reading `describe settings` could not see it. +-- +-- Fix: `alter settings webui (OptimizedClient: …)` writes +-- Forms$WebUIProjectSettingsPart.UseOptimizedClient (the backend used to pass that +-- part through as read, so adding only the executor case would have reported +-- "Updated" and written nothing), and DESCRIBE SETTINGS emits it. +-- +-- Measured on a blank 11.14.0 app: `mx check` reports 0 errors after +-- `alter settings webui (OptimizedClient: 'No')` wrote the property, and ALSO +-- 0 errors with an out-of-range value ('React') forced into it +-- — so the MDL-SET03 refusal of a non-member is the only thing that catches one. +-- +-- Manual verification: +-- mxcli exec 1549-alter-settings-webui.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe settings" → alter settings webui ( OptimizedClient: 'Yes' ); + +alter settings webui (OptimizedClient: 'No'); + +-- Matched case-insensitively, stored in Mendix's spelling. +alter settings webui (OptimizedClient: 'yes'); diff --git a/mdl/backend/modelsdk/settings_write.go b/mdl/backend/modelsdk/settings_write.go index 86f7599df7..636c3691e3 100644 --- a/mdl/backend/modelsdk/settings_write.go +++ b/mdl/backend/modelsdk/settings_write.go @@ -15,8 +15,8 @@ import ( // UpdateProjectSettings rewrites the Settings$ProjectSettings unit using the // raw-part overlay strategy (ADR-0005 guard-don't-drop): the Settings array is // rebuilt from ps.RawParts (captured on read), and only the parsed-and-modified -// parts (Model / Configuration / Language / Workflows) have their fields overlaid -// onto the preserved raw part. Every other part (WebUI, Convention, Integration, +// parts (Model / Configuration / Language / Workflows / WebUI) have their fields +// overlaid onto the preserved raw part. Every other part (Convention, Integration, // Certificate, JarDeployment, Distribution, …) passes through byte-for-byte. func (b *Backend) UpdateProjectSettings(ps *model.ProjectSettings) error { if ps == nil { @@ -77,6 +77,15 @@ func (b *Backend) UpdateProjectSettings(ps *model.ProjectSettings) error { } else { settings = append(settings, rawPart) } + case "Forms$WebUIProjectSettingsPart", "Settings$WebUIProjectSettingsPart": + // Without this the part passed through as read, so `alter settings + // webui` would report success and write nothing + // (mendixlabs/mxcli#1549). + if ps.WebUI != nil { + settings = append(settings, settingsoverlay.SetWebUISettings(ps.WebUI, rawPart)) + } else { + settings = append(settings, rawPart) + } default: // Preserve raw part as-is. settings = append(settings, rawPart) diff --git a/mdl/backend/modelsdk/settings_write_webui_test.go b/mdl/backend/modelsdk/settings_write_webui_test.go new file mode 100644 index 0000000000..b3da3f334e --- /dev/null +++ b/mdl/backend/modelsdk/settings_write_webui_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "reflect" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/settingsoverlay" + "github.com/mendixlabs/mxcli/modelsdk/bsoncompat" + mmpr "github.com/mendixlabs/mxcli/modelsdk/mpr" +) + +// readWebUIPart returns the raw Web UI settings part from disk. Studio Pro stores +// it as Forms$WebUIProjectSettingsPart (measured on 11.6, 11.13 and 11.14). +func readWebUIPart(t *testing.T, proj string) map[string]any { + t.Helper() + r, err := mmpr.Open(proj) + if err != nil { + t.Fatalf("open: %v", err) + } + defer r.Close() + refs, err := r.ListUnitsByType("Settings$ProjectSettings") + if err != nil || len(refs) != 1 { + t.Fatalf("ListUnitsByType = %v, %v", refs, err) + } + raw, err := r.GetRawUnitBytes(refs[0].ID) + if err != nil { + t.Fatalf("GetRawUnitBytes: %v", err) + } + var doc bson.M + if err := bsoncompat.UnmarshalDocM(raw, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, part := range settingsoverlay.ArrayElements(doc["Settings"]) { + if part["$Type"] == "Forms$WebUIProjectSettingsPart" { + return part + } + } + t.Fatalf("no Forms$WebUIProjectSettingsPart on disk") + return nil +} + +// mendixlabs/mxcli#1549: `alter settings webui (OptimizedClient: 'No')` has to +// reach the stored part. Before, UpdateProjectSettings passed the Web UI part +// through as read, so the value set on the model was dropped on the floor. +func TestUpdateProjectSettings_WebUIOptimizedClient(t *testing.T) { + proj := copyFixture(t) + before := readWebUIPart(t, proj) + if before["UseOptimizedClient"] != "Yes" { + t.Fatalf("fixture precondition: UseOptimizedClient = %#v, want Yes", before["UseOptimizedClient"]) + } + + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + ps, err := b.GetProjectSettings() + if err != nil { + t.Fatalf("GetProjectSettings: %v", err) + } + if ps.WebUI == nil { + t.Fatal("Web UI settings not read") + } + ps.WebUI.UseOptimizedClient = "No" + if err := b.UpdateProjectSettings(ps); err != nil { + t.Fatalf("UpdateProjectSettings: %v", err) + } + if err := b.Disconnect(); err != nil { + t.Fatalf("disconnect: %v", err) + } + + after := readWebUIPart(t, proj) + if after["UseOptimizedClient"] != "No" { + t.Errorf("stored UseOptimizedClient = %#v, want No", after["UseOptimizedClient"]) + } + // Only that property changed: the rest of the part — its identity included — + // is as Studio Pro wrote it. + delete(before, "UseOptimizedClient") + delete(after, "UseOptimizedClient") + if !reflect.DeepEqual(before, after) { + t.Errorf("the write changed more than UseOptimizedClient:\nbefore %#v\nafter %#v", before, after) + } + + b2 := New() + if err := b2.Connect(proj); err != nil { + t.Fatalf("reconnect: %v", err) + } + t.Cleanup(func() { _ = b2.Disconnect() }) + ps2, err := b2.GetProjectSettings() + if err != nil { + t.Fatalf("GetProjectSettings(2): %v", err) + } + if ps2.WebUI == nil || ps2.WebUI.UseOptimizedClient != "No" { + t.Errorf("UseOptimizedClient read back as %+v, want No", ps2.WebUI) + } +} diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index 73135dcff6..b7634a155c 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -269,6 +269,15 @@ func describeSettings(ctx *ExecContext, configName string) error { } } + // Web UI settings: which client the app runs on. Emitted only when the + // project stores the property, so the output replays (ALTER refuses one the + // part does not carry). + if ps.WebUI != nil && ps.WebUI.UseOptimizedClient != "" { + if stored := storedWebUISettings(ps); stored == nil || settingsoverlay.Has(stored, "UseOptimizedClient") { + fmt.Fprintf(ctx.Output, "alter settings webui (\n OptimizedClient: %s\n);\n\n", mdlQuoted(ps.WebUI.UseOptimizedClient)) + } + } + return nil } @@ -343,11 +352,27 @@ var sslCertificateAlgorithmValues = []string{ string(metamodel.SettingsSslCertificateAlgorithmSunX509), } +// useOptimizedClientValues are the members of Settings.UseOptimizedClient, the +// Web UI setting that picks the client the app runs on: Yes is the React client, +// No the classic (Dojo) one, MigrationMode Mendix's mixed mode. In the Model SDK's +// declaration order; no member carries a version gate of its own. +var useOptimizedClientValues = []string{ + string(metamodel.SettingsUseOptimizedClientNo), + string(metamodel.SettingsUseOptimizedClientYes), + string(metamodel.SettingsUseOptimizedClientMigrationMode), +} + +// webUISettingKeys names every property ALTER SETTINGS WEBUI assigns. +// OptimizedClient is the spelling `show settings` prints and the skills point to; +// it is stored as UseOptimizedClient. +var webUISettingKeys = []string{"OptimizedClient"} + // settingsEnumValues maps "
/" to the enumeration members accepted for // it, for the properties validated as settingsKindEnum. var settingsEnumValues = map[string][]string{ "model/FirstDayOfWeek": firstDayOfWeekValues, "model/SslCertificateAlgorithm": sslCertificateAlgorithmValues, + "webui/OptimizedClient": useOptimizedClientValues, } // settingsEnum canonicalises an enumeration-typed settings value to the member @@ -376,6 +401,17 @@ func storedModelSettings(ps *model.ProjectSettings) map[string]any { return nil } +// storedWebUISettings returns the raw Web UI settings part, or nil when it is not +// in RawParts — the same contract as storedModelSettings. +func storedWebUISettings(ps *model.ProjectSettings) map[string]any { + for _, part := range ps.RawParts { + if t, _ := part["$Type"].(string); settingsoverlay.IsWebUIPart(t) { + return part + } + } + return nil +} + // refuseIfNotStored rejects an ALTER naming a property this project's Mendix // version does not store. The overlay is presence-gated (it will not introduce a // property the type may not define), so without this check the statement would @@ -634,6 +670,34 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { } } + case "webui": + if ps.WebUI == nil { + return mdlerrors.NewNotFound("settings section", "webui") + } + storedWebUI := storedWebUISettings(ps) + for key, val := range stmt.Properties { + valStr := settingsValueToString(val) + switch key { + case "OptimizedClient": + // The overlay is presence-gated (the property arrived in 9.10), so + // refuse rather than report a success that changes nothing. + if storedWebUI != nil && !settingsoverlay.Has(storedWebUI, "UseOptimizedClient") { + return mdlerrors.NewUnsupported( + "this project does not store the Web UI setting UseOptimizedClient\n" + + " Mendix added it in 9.10; mxcli will not introduce a property the project's " + + "Mendix version does not define.") + } + v, err := settingsEnum(key, valStr, useOptimizedClientValues) + if err != nil { + return err + } + ps.WebUI.UseOptimizedClient = v + default: + return mdlerrors.NewUnsupported(fmt.Sprintf( + "unknown webui setting: %s\n valid keys: %s", key, strings.Join(webUISettingKeys, ", "))) + } + } + case "configuration": return alterSettingsConfiguration(ctx, ps, stmt) @@ -641,7 +705,7 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { return alterSettingsConstant(ctx, ps, stmt) default: - return mdlerrors.NewUnsupported(fmt.Sprintf("unknown settings section: %s (expected model, configuration, constant, LANGUAGE, or workflows)", section)) + return mdlerrors.NewUnsupported(fmt.Sprintf("unknown settings section: %s (expected model, configuration, constant, LANGUAGE, workflows, or webui)", section)) } // Write updated settings diff --git a/mdl/executor/cmd_settings_validation_test.go b/mdl/executor/cmd_settings_validation_test.go index 9ae4736235..4e64ed95cf 100644 --- a/mdl/executor/cmd_settings_validation_test.go +++ b/mdl/executor/cmd_settings_validation_test.go @@ -36,8 +36,12 @@ func settingsBackend(wrote *bool) *mock.MockBackend { ServerPortNumber: 8090, }}, }, + WebUI: &model.WebUISettings{UseOptimizedClient: "Yes"}, + } + ps.RawParts = []map[string]any{ + {"$Type": "Settings$ConfigurationSettings"}, + {"$Type": "Forms$WebUIProjectSettingsPart", "UseOptimizedClient": "Yes"}, } - ps.RawParts = []map[string]any{{"$Type": "Settings$ConfigurationSettings"}} return ps, nil }, UpdateProjectSettingsFunc: func(*model.ProjectSettings) error { diff --git a/mdl/executor/cmd_settings_webui_test.go b/mdl/executor/cmd_settings_webui_test.go new file mode 100644 index 0000000000..1b73a8e6d8 --- /dev/null +++ b/mdl/executor/cmd_settings_webui_test.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#1549 (ako/mxcli#1447): the Web UI setting UseOptimizedClient +// (Yes / No / MigrationMode — which client the app runs on) was visible in +// `show settings` but missing from `describe settings`, and `alter settings +// webui (OptimizedClient: 'Yes')` answered "unknown settings section: webui". + +// webUICtx returns an executor over a project whose Web UI part stores +// UseOptimizedClient = current, and a pointer to the settings the last write +// handed the backend. stored=false models a project whose Mendix version does not +// store the property (it was introduced in 9.10). +func webUICtx(t *testing.T, current string, stored bool) (*ExecContext, *bytes.Buffer, **model.ProjectSettings) { + t.Helper() + ps := &model.ProjectSettings{WebUI: &model.WebUISettings{UseOptimizedClient: current}} + raw := map[string]any{"$Type": "Forms$WebUIProjectSettingsPart", "UrlPrefix": "p"} + if stored { + raw["UseOptimizedClient"] = current + } + ps.RawParts = []map[string]any{raw} + var written *model.ProjectSettings + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { return ps, nil }, + UpdateProjectSettingsFunc: func(p *model.ProjectSettings) error { written = p; return nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb)) + return ctx, buf, &written +} + +func parseAlterSettings(t *testing.T, src string) *ast.AlterSettingsStmt { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse %q: %v", src, errs) + } + return findStmt[*ast.AlterSettingsStmt](t, prog, src) +} + +// The statement as reported, through the real parser. +func TestAlterSettingsWebUI_AsReported(t *testing.T) { + ctx, _, written := webUICtx(t, "Yes", true) + stmt := parseAlterSettings(t, "mdl 1;\nalter settings webui (OptimizedClient: 'No');") + if err := alterSettings(ctx, stmt); err != nil { + t.Fatalf("alter settings webui refused: %v", err) + } + if *written == nil { + t.Fatal("nothing was written") + } + if got := (*written).WebUI.UseOptimizedClient; got != "No" { + t.Errorf("UseOptimizedClient = %q, want No", got) + } +} + +// Matched case-insensitively and stored in Mendix's spelling, like every other +// enumeration-typed setting. +func TestAlterSettingsWebUI_CanonicalisesValue(t *testing.T) { + ctx, _, written := webUICtx(t, "Yes", true) + if err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "webui", Properties: map[string]any{"OptimizedClient": "migrationmode"}, + }); err != nil { + t.Fatalf("alter settings webui refused: %v", err) + } + if got := (*written).WebUI.UseOptimizedClient; got != "MigrationMode" { + t.Errorf("UseOptimizedClient = %q, want MigrationMode", got) + } +} + +// A value outside the enumeration is the mendixlabs/mxcli#759 shape — Studio Pro +// cannot resolve it — so it is refused, and the refusal names what is accepted. +func TestAlterSettingsWebUI_RejectsNonMember(t *testing.T) { + ctx, _, written := webUICtx(t, "Yes", true) + err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "webui", Properties: map[string]any{"OptimizedClient": "React"}, + }) + if err == nil { + t.Fatal("alter settings webui accepted OptimizedClient 'React'") + } + for _, want := range []string{"No", "Yes", "MigrationMode"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not name %q: %v", want, err) + } + } + if *written != nil { + t.Error("a refused statement still wrote the settings") + } +} + +// The overlay will not introduce a property the stored part does not carry, so a +// project that does not store it is refused rather than told "Updated". +func TestAlterSettingsWebUI_RefusesWhenNotStored(t *testing.T) { + ctx, _, written := webUICtx(t, "Yes", false) + err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "webui", Properties: map[string]any{"OptimizedClient": "No"}, + }) + if err == nil || !strings.Contains(err.Error(), "does not store") { + t.Fatalf("expected a refusal naming the missing property, got %v", err) + } + if *written != nil { + t.Error("a refused statement still wrote the settings") + } +} + +func TestAlterSettingsWebUI_UnknownKeyListsValidKeys(t *testing.T) { + ctx, _, written := webUICtx(t, "Yes", true) + err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: "webui", Properties: map[string]any{"UseReactClient": "Yes"}, + }) + if err == nil || !strings.Contains(err.Error(), "OptimizedClient") { + t.Fatalf("expected the unknown key refused with the valid one named, got %v", err) + } + if *written != nil { + t.Error("a refused statement still wrote the settings") + } +} + +// DESCRIBE prints the setting as a statement that re-executes: replayed onto a +// project on the other client, it moves that project to the described one. +func TestDescribeSettings_WebUIRoundTrips(t *testing.T) { + ctx, buf, _ := webUICtx(t, "No", true) + assertNoError(t, describeSettings(ctx, "")) + out := buf.String() + if !strings.Contains(out, "alter settings webui (") { + t.Fatalf("describe settings has no Web UI statement:\n%s", out) + } + + stmt := findStmt[*ast.AlterSettingsStmt](t, reparse(t, out), out) + target, _, written := webUICtx(t, "Yes", true) + assertNoError(t, alterSettings(target, stmt)) + if *written == nil || (*written).WebUI.UseOptimizedClient != "No" { + t.Errorf("replaying describe output did not set OptimizedClient to No:\n%s", out) + } +} + +// A project whose version does not store the property describes no statement for +// it: one that names it would be refused on replay. +func TestDescribeSettings_WebUINotStoredNotPrinted(t *testing.T) { + ctx, buf, _ := webUICtx(t, "Yes", false) + assertNoError(t, describeSettings(ctx, "")) + if out := buf.String(); strings.Contains(out, "webui") { + t.Errorf("describe printed a Web UI statement the project cannot replay:\n%s", out) + } +} diff --git a/mdl/executor/validate_settings.go b/mdl/executor/validate_settings.go index 560f8b6b1c..2e07b5bc37 100644 --- a/mdl/executor/validate_settings.go +++ b/mdl/executor/validate_settings.go @@ -48,6 +48,9 @@ var typedSettingsKeys = map[string]map[string]settingsValueKind{ "ServerPortNumber": settingsKindInt, "DatabaseType": settingsKindDatabaseType, }, + "webui": { + "OptimizedClient": settingsKindEnum, + }, } // ValidateSettings reports ALTER SETTINGS values that will not parse as their diff --git a/mdl/settingsoverlay/settingsoverlay.go b/mdl/settingsoverlay/settingsoverlay.go index 29b9aee7f7..c84437f66a 100644 --- a/mdl/settingsoverlay/settingsoverlay.go +++ b/mdl/settingsoverlay/settingsoverlay.go @@ -290,6 +290,24 @@ func SetModelSettings(ms *model.ModelSettings, raw map[string]any) map[string]an return raw } +// IsWebUIPart reports whether a settings part's $Type is the Web UI part. Studio +// Pro stores it as Forms$WebUIProjectSettingsPart (measured on 11.6, 11.13 and +// 11.14); the codec also registers the Settings$ spelling, and a part the reader +// decodes as Web UI settings has to be written back as one. +func IsWebUIPart(typeName string) bool { + return typeName == "Forms$WebUIProjectSettingsPart" || typeName == "Settings$WebUIProjectSettingsPart" +} + +// SetWebUISettings overlays the Web UI settings MDL can change onto the part they +// were read from. That is UseOptimizedClient alone — which client the app runs +// on — so every other property of the part passes through as stored. Presence- +// gated like SetModelSettings: the property was introduced in Mendix 9.10, and +// the executor refuses an ALTER on a part that does not carry it. +func SetWebUISettings(ws *model.WebUISettings, raw map[string]any) map[string]any { + setIfPresent(raw, "UseOptimizedClient", ws.UseOptimizedClient) + return raw +} + // ConstantValues rebuilds a configuration's ConstantValues list, updating each // override in the slot it is already stored in so its value shape survives. // Studio Pro and mxbuild only read the nested SharedOrPrivateValue; a flat "Value" diff --git a/mdl/settingsoverlay/webui_settings_test.go b/mdl/settingsoverlay/webui_settings_test.go new file mode 100644 index 0000000000..f06a403a0e --- /dev/null +++ b/mdl/settingsoverlay/webui_settings_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +package settingsoverlay + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" +) + +func TestSetWebUISettings_UpdatesStoredProperty(t *testing.T) { + raw := map[string]any{ + "$Type": "Forms$WebUIProjectSettingsPart", + "UseOptimizedClient": "Yes", + "UrlPrefix": "p", + } + SetWebUISettings(&model.WebUISettings{UseOptimizedClient: "No", UrlPrefix: "changed"}, raw) + if raw["UseOptimizedClient"] != "No" { + t.Errorf("UseOptimizedClient = %#v, want No", raw["UseOptimizedClient"]) + } + // MDL does not author the other Web UI properties; they pass through. + if raw["UrlPrefix"] != "p" { + t.Errorf("UrlPrefix = %#v, want the stored p", raw["UrlPrefix"]) + } +} + +// UseOptimizedClient arrived in Mendix 9.10; a part from before it must not gain +// the property — the mendixlabs/mxcli#759 shape, a key the type does not define. +func TestSetWebUISettings_DoesNotIntroduceAbsentProperty(t *testing.T) { + raw := map[string]any{"$Type": "Forms$WebUIProjectSettingsPart"} + SetWebUISettings(&model.WebUISettings{UseOptimizedClient: "Yes"}, raw) + if _, ok := raw["UseOptimizedClient"]; ok { + t.Errorf("UseOptimizedClient introduced onto a part that did not store it: %#v", raw) + } +} + +func TestIsWebUIPart(t *testing.T) { + for typeName, want := range map[string]bool{ + "Forms$WebUIProjectSettingsPart": true, + "Settings$WebUIProjectSettingsPart": true, + "Settings$ModelSettings": false, + } { + if got := IsWebUIPart(typeName); got != want { + t.Errorf("IsWebUIPart(%q) = %t, want %t", typeName, got, want) + } + } +} From 66a6001c6b9f623b7d3606828ad001acaafad768 Mon Sep 17 00:00:00 2001 From: Ako Date: Fri, 23 Oct 2026 01:05:59 +0200 Subject: [PATCH 32/50] fix(alter page): a column SET keeps the dynamicText template it hides or shows (#1617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `set (ShowContentAs: attribute) on dg column(Name)` on a column created as dynamicText left its dynamicText template filled ('Item {1}', one parameter). Data grid 2 hides it unless showContentAs is "dynamicText" (`"dynamicText"!==n.showContentAs&&hidePropertyIn(…,"columns",o, "dynamicText")`), CREATE stores a hidden template null, and the project as stored failed with CE0463 at the grid. Measured on a blank 11.13.0 app with `docker check --no-update-widgets`: created as dynamicText 0 errors … then set (ShowContentAs: attribute) 1 error (CE0463) -> 0 now control: created as attribute 0 errors The column reset (#1519) handled primitives only. It now gets the same template handling as object-list items: a hidden template is nulled, and a revealed one is refused unless the statement sets its text, since the schema declares no default. SetColumnProperty creates a template stored null before writing its text, so `set (ShowContentAs: dynamicText, dynamicText: 'Item') on dg column(Name)` works and checks clean. Control: the three new tests fail before the change with the measured symptoms (template still filled; no refusal; "has no text template to update"); the #1519 column tests still pass. Left as is (pre-existing, recorded in the finding): in a column SET `Content:` names the custom-content slot rather than the dynamicText alias CREATE uses, and `ContentParams` does not parse. Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 Co-authored-by: Claude --- ...dg2-column-set-leaves-hidden-template.json | 1 + ...lumn-set-showcontentas-hidden-template.mdl | 28 ++++ .../pagemutator/column_hidden_reset.go | 8 +- .../column_hidden_template_test.go | 134 ++++++++++++++++++ mdl/backend/pagemutator/mutator.go | 4 + 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-22-dg2-column-set-leaves-hidden-template.json create mode 100644 mdl-examples/bug-tests/dg2-column-set-showcontentas-hidden-template.mdl create mode 100644 mdl/backend/pagemutator/column_hidden_template_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-dg2-column-set-leaves-hidden-template.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-dg2-column-set-leaves-hidden-template.json new file mode 100644 index 0000000000..d7d3d488f8 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-dg2-column-set-leaves-hidden-template.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-22", "symptom": "`set (ShowContentAs: attribute) on dg column(Name)` on a column created as dynamicText: the project as stored fails with CE0463 at the Data grid 2 (0 errors before the SET, and for a column created as attribute)", "cause": "ResetHiddenColumnProperties reset hidden PRIMITIVE column properties only (#1519); the column's dynamicText TextTemplate, hidden unless showContentAs is \"dynamicText\", stayed filled where CREATE stores null", "file": "`mdl/backend/pagemutator/column_hidden_reset.go` (column path now passes the template constructor), `mutator.go` (`SetColumnProperty` creates a null template before writing its text)", "insight": "Same defect as the Accordion group's headerText, found by measuring rather than by analogy: the column-vs-control diff (TypePointer -> PropertyKey in the JSON dump) showed only dynamicText differing ('Item {1}', one param vs null). Only `docker check --no-update-widgets` shows it. The reverse switch is refused unless the text is given, because the schema DefaultValue is \"\". Side quirk left as is: in a column SET `Content:` resolves to the custom-content SLOT (schema key `content`, case-insensitive) before the `dynamicText` alias CREATE uses, so describe prints `Content: 'Item'` that a SET cannot write back — use `dynamicText:`; and `ContentParams` does not parse in a SET at all", "refs": [], "ce": ["CE0463"]} diff --git a/mdl-examples/bug-tests/dg2-column-set-showcontentas-hidden-template.mdl b/mdl-examples/bug-tests/dg2-column-set-showcontentas-hidden-template.mdl new file mode 100644 index 0000000000..81bee517b4 --- /dev/null +++ b/mdl-examples/bug-tests/dg2-column-set-showcontentas-hidden-template.mdl @@ -0,0 +1,28 @@ +mdl 1; +-- `alter page … set (ShowContentAs: attribute) on dg column(Name)` on a column +-- created as dynamicText left the column's dynamicText template filled. Data +-- grid 2 hides it unless showContentAs is "dynamicText", CREATE stores a hidden +-- template null, and the project as stored failed with CE0463 at the grid +-- (11.13.0, `mxcli docker check --no-update-widgets`; the default check +-- normalises widgets and hides it). +-- +-- Expected: the describe below shows the Name column without ShowContentAs / +-- Content, and `docker check --no-update-widgets` reports 0 errors. Switching +-- back needs the text in the same statement: +-- set (ShowContentAs: dynamicText, dynamicText: 'Item') on dg column(Name) + +create module BugDg2Tmpl; + +create persistent entity BugDg2Tmpl.Item (Name: string(50)); + +create page BugDg2Tmpl.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + datagrid dg (DataSource: database BugDg2Tmpl.Item) { + column (Attribute: Name, Caption: 'Name', ShowContentAs: dynamicText, Content: 'Item {1}', ContentParams: ({1} = Name)) + } +}; + +alter page BugDg2Tmpl.P { + set (ShowContentAs: attribute) on dg column(Name) +}; + +describe page BugDg2Tmpl.P; diff --git a/mdl/backend/pagemutator/column_hidden_reset.go b/mdl/backend/pagemutator/column_hidden_reset.go index 6a62984595..f2feb15352 100644 --- a/mdl/backend/pagemutator/column_hidden_reset.go +++ b/mdl/backend/pagemutator/column_hidden_reset.go @@ -29,7 +29,9 @@ import ( // 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 +// hides is reset to its declared default, a text template it hides is nulled, +// and one it reveals is given its default text or, with none declared, refused +// unless the statement sets it. 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. // @@ -53,7 +55,7 @@ func (m *Mutator) ResetHiddenColumnProperties(gridRef, columnRef string, rules [ } } return resetHiddenItemProps(col.widget, col.colPropKeys, col.colPropKinds, defaults, - primitiveValuesByKey(grid.widget, buildPropKeyMap(grid.widget)), listKey, rules, explicit, nil) + primitiveValuesByKey(grid.widget, buildPropKeyMap(grid.widget)), listKey, rules, explicit, m.newTemplate) } // ResetHiddenItemProperties is ResetHiddenColumnProperties for an object-list @@ -116,7 +118,7 @@ func resetHiddenItemProps(item bson.D, itemKeys, itemKinds, defaults, widgetValu // for it, in the statement that reveals it. if defaults[key] == "" { return nil, fmt.Errorf( - "property %q is shown under this item's new configuration but holds no text — set it in the "+ + "property %q is shown under its new configuration but holds no text — set it in the "+ "same statement, e.g. `set (…, %s: '…') on `", key, key) } if tmpl := newTemplate(); tmpl != nil { diff --git a/mdl/backend/pagemutator/column_hidden_template_test.go b/mdl/backend/pagemutator/column_hidden_template_test.go new file mode 100644 index 0000000000..6fc0a55228 --- /dev/null +++ b/mdl/backend/pagemutator/column_hidden_template_test.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// `set (ShowContentAs: attribute) on dg column(Name)` on a column created as +// dynamicText left the column's dynamicText template filled ('Item {1}', one +// parameter). Data grid 2 hides it unless showContentAs is "dynamicText" +// (editorConfig: `"dynamicText"!==n.showContentAs&&hidePropertyIn(…,"columns", +// o,"dynamicText")`), CREATE stores a hidden template null, and the project as +// stored failed with CE0463 at the grid — measured on 11.13.0 with +// `docker check --no-update-widgets`: 0 errors before the SET, 1 after, and 0 +// for the control column created as attribute. The column reset handled +// primitives only; templates are now brought in line as they are for an +// object-list item. + +var ( + tpDGCols = idBin(0x40) + tpDGAttr = idBin(0x41) + tpDGShowAs = idBin(0x42) + tpDGDynText = idBin(0x43) + dynTextRule = []types.WidgetVisibilityRule{{PropertyKey: "dynamicText", ListPropertyKey: "columns", + HiddenWhen: &types.WidgetVisibilityCondition{PropertyKey: "showContentAs", Operator: "ne", Value: "dynamicText", Scope: types.ConditionScopeItem}}} +) + +// dynTextGrid is grid dg with one column over Name, in mode showAs, its +// dynamicText template filled when filled is set and null otherwise. +func dynTextGrid(showAs string, filled bool) bson.D { + var tmpl any + if filled { + tmpl = textTemplate("Item {1}") + } + pt := func(id bson.Binary, key, kind string) bson.D { + return bson.D{{Key: "$ID", Value: id}, {Key: "PropertyKey", Value: key}, + {Key: "ValueType", Value: bson.D{{Key: "Type", Value: kind}, {Key: "DefaultValue", Value: ""}}}} + } + prop := func(id bson.Binary, value bson.D) bson.D { + return bson.D{{Key: "TypePointer", Value: id}, {Key: "Value", Value: value}} + } + col := bson.D{{Key: "$Type", Value: "CustomWidgets$WidgetObject"}, {Key: "Properties", Value: bson.A{int32(2), + prop(tpDGAttr, bson.D{{Key: "AttributeRef", Value: bson.D{{Key: "Attribute", Value: "G.Item.Name"}}}}), + prop(tpDGShowAs, bson.D{{Key: "PrimitiveValue", Value: showAs}}), + prop(tpDGDynText, bson.D{{Key: "TextTemplate", Value: tmpl}}), + }}} + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: "dg"}, + {Key: "Type", Value: bson.D{ + {Key: "WidgetId", Value: "com.mendix.widget.web.datagrid.Datagrid"}, + {Key: "ObjectType", Value: bson.D{{Key: "PropertyTypes", Value: bson.A{int32(2), bson.D{ + {Key: "$ID", Value: tpDGCols}, + {Key: "PropertyKey", Value: "columns"}, + {Key: "ValueType", Value: bson.D{{Key: "Type", Value: "Object"}, {Key: "IsList", Value: true}, + {Key: "ObjectType", Value: bson.D{{Key: "PropertyTypes", Value: bson.A{int32(2), + pt(tpDGAttr, "attribute", "Attribute"), + pt(tpDGShowAs, "showContentAs", "Enumeration"), + pt(tpDGDynText, "dynamicText", "TextTemplate"), + }}}}}}, + }}}}}, + }}, + {Key: "Object", Value: bson.D{{Key: "Properties", Value: bson.A{int32(2), + prop(tpDGCols, bson.D{{Key: "Objects", Value: bson.A{int32(2), col}}}), + }}}}, + } +} + +func dynTextTemplate(t *testing.T, m *Mutator) any { + t.Helper() + col, err := findBsonColumn(m.rawData, "dg", "Name", m.widgetFinder) + if err != nil { + t.Fatal(err) + } + return bsonnav.DGet(itemPropValue(col.widget, col.colPropKeys, "dynamicText"), "TextTemplate") +} + +func TestColumnSetNullsTheTemplateItHides(t *testing.T) { + m := New(makeRawPage(dynTextGrid("dynamicText", true)), model.ID("u"), &templateDeps{}) + col, err := findBsonColumn(m.rawData, "dg", "Name", m.widgetFinder) + if err != nil { + t.Fatal(err) + } + if err := setColumnPropertyMut(col.widget, col.colPropKeys, col.colPropKinds, "showContentAs", "attribute"); err != nil { + t.Fatal(err) + } + if _, err := m.ResetHiddenColumnProperties("dg", "Name", dynTextRule, []string{"ShowContentAs"}); err != nil { + t.Fatalf("ResetHiddenColumnProperties: %v", err) + } + if tmpl := dynTextTemplate(t, m); tmpl != nil { + t.Errorf("dynamicText is hidden in attribute mode and must be null, got %v", tmpl) + } +} + +// The other way round: switching to dynamicText reveals a null template, and +// the stored schema has no default text for it, so the SET must say so. +func TestColumnSetRefusesARevealedTemplateWithNoText(t *testing.T) { + m := New(makeRawPage(dynTextGrid("attribute", false)), model.ID("u"), &templateDeps{}) + col, _ := findBsonColumn(m.rawData, "dg", "Name", m.widgetFinder) + if err := setColumnPropertyMut(col.widget, col.colPropKeys, col.colPropKinds, "showContentAs", "dynamicText"); err != nil { + t.Fatal(err) + } + _, err := m.ResetHiddenColumnProperties("dg", "Name", dynTextRule, []string{"ShowContentAs"}) + if err == nil || !strings.Contains(err.Error(), "dynamicText") { + t.Fatalf("want a refusal asking for the text, got %v", err) + } +} + +// With the text in the same statement it works: `set (ShowContentAs: dynamicText, +// dynamicText: 'Item {1}')` creates the template and writes the text. (`Content:` in a +// column SET names the custom-content slot, not this text — a separate quirk.) +func TestColumnSetWithTextCreatesTheTemplate(t *testing.T) { + m := New(makeRawPage(dynTextGrid("attribute", false)), model.ID("u"), &templateDeps{}) + if err := m.SetColumnProperty("dg", "Name", "ShowContentAs", "dynamicText"); err != nil { + t.Fatal(err) + } + if err := m.SetColumnProperty("dg", "Name", "dynamicText", "Item {1}"); err != nil { + t.Fatalf("set dynamicText on a null template: %v", err) + } + if _, err := m.ResetHiddenColumnProperties("dg", "Name", dynTextRule, []string{"ShowContentAs", "dynamicText"}); err != nil { + t.Fatalf("ResetHiddenColumnProperties: %v", err) + } + if dynTextTemplate(t, m) == nil { + t.Error("dynamicText template was not created") + } +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 8517c02fab..15dd14d5f1 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -374,6 +374,10 @@ func (m *Mutator) SetColumnProperty(gridRef string, columnRef string, prop strin if err != nil { return err } + // A template CREATE stored null (the column was not in dynamicText mode) + // gets one before its text is written; whether it may be shown is the + // reset pass's to decide afterwards. + m.ensureItemTemplate(result, prop) return setColumnPropertyMut(result.widget, result.colPropKeys, result.colPropKinds, prop, value) } From 83787981624046435f1f3f5d3799d604cc44e584 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:11:06 +0000 Subject: [PATCH 33/50] docs: legacy table cell addresses in ALTER PAGE reference, skill and proposal The reference gains a "Legacy Table Cell Operations" section, the skill one line, and the proposal moves table cells from deferred to done with the shape measured through `mx convert`. One finding records how that shape was obtained and the #1463 re-check on the converted table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019httGxDh8UXt1Zoyh3Gh52 --- .../2026-10-22-legacy-table-cell-address.json | 1 + .claude/skills/mendix/alter-page/SKILL.md | 12 +++-- docs-site/src/reference/page/alter-page.md | 34 +++++++++++++- ...PROPOSAL_alter_object_list_item_address.md | 46 ++++++++++++++----- 4 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-22-legacy-table-cell-address.json diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-legacy-table-cell-address.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-legacy-table-cell-address.json new file mode 100644 index 0000000000..335d6a74f0 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-22-legacy-table-cell-address.json @@ -0,0 +1 @@ +{"area": "mdl/backend/pagemutator", "date": "2026-10-22", "symptom": "an EMPTY legacy table cell could not be filled by ALTER PAGE — the cell stores no Name, so there was nothing to address; describe printed the table as one NOT re-executable line and gave no way to see which cell held what", "cause": "Forms$DbTableCell is positional (TopRowIndex/LeftColumnIndex, 0-based, Width/Height span) and no address form reached it", "file": "`mdl/backend/pagemutator/tablecells.go` (resolveTableCell, setCellWidgets, refuseBesideTableCell), `mdl/backend/tablecell_selector.go`, `mutator.go` (findNearestDataSourceDoc maps a cell to its table), grammar `alterTarget` CELL, describe `parseTableCells`", "insight": "gen models the table wrong for Mendix 11 (Forms$TableCell, Columns) and mx convert refuses Forms$TableCell — the real shape came from injecting a table into a blank 11.13.0 app and letting `mx convert -p` rewrite it (0 errors): Cells of Forms$DbTableCell, ColumnWidths, Rows, WidthUnit 'Weight'. Fixture from the converted document, never from gen. Inject IDs must be bson.Binary subtype 0 or mx reports the baffling \"Expected '$ID' but got '$ID'\". A cell is not a data container, so the first e2e insert was refused as 'in no data container' until the cell mapped to its table for the context walk. #1463 (widgets inside cells by name) re-verified on the same modeler-converted table: insert after / drop on tInCell, 0 errors", "refs": ["mendixlabs/mxcli#1453", "ako/mxcli#1326", "ako/mxcli#1463"], "ce": []} diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index 250b5f3915..65de9e4a11 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -17,10 +17,10 @@ description: "Modify an existing page or snippet's widget tree in place with ALT | 482-560 | Examples | | 561-651 | DataGrid 2 columns: how to address them, and what you can set | | 652-663 | Common Mistakes | -| 664-696 | Limitations — prefer binding at page creation (ledger finding #45) | -| 697-704 | Validation Checklist | -| 705-714 | Related Commands | -| 715-719 | Related Skills | +| 664-698 | Limitations — prefer binding at page creation (ledger finding #45) | +| 699-706 | Validation Checklist | +| 707-716 | Related Commands | +| 717-721 | Related Skills | # ALTER PAGE / ALTER SNIPPET - Modify Existing Pages and Snippets @@ -92,7 +92,7 @@ alter page Module.PageName { A `` on a page is a widget name, a DataGrid 2 column `grid column(Attr)` (or `grid column('Caption')`), a navigation list item `nav item(t1)` / -`nav item('Text')` / `nav item@n`, or a layout region `layoutContainer.top`. Properties go in parentheses with `:`, exactly as in +`nav item('Text')` / `nav item@n`, a table cell `table1 cell(1,2)`, or a layout region `layoutContainer.top`. Properties go in parentheses with `:`, exactly as in `create page`. The older spellings `set Key = value on w`, `set Key: value` (no parentheses) and `drop widget w` still run, and warn with MDL-DEPR101, MDL-DEPR102 and MDL-DEPR103 — write the form above. @@ -682,6 +682,8 @@ page and bind the buttons at creation time instead of rewiring afterwards: `acc1 item@2`, `acc1 item('One')` or `acc1 item(btnIn)`; `.headercontent` is its header slot, refused until `set (headerRenderMode: 'custom') on acc1 item@1`. +**Nor a legacy table cell**: `insert into table1 cell(1,2) { … }` (1-based, as describe lists them) appends; `replace` swaps, `drop` empties. + **A pluggable widget's slot has no name either.** A Data grid 2's control bar (`controlbar { … }` in describe) is a property of the grid, so address it by the grid: `insert into dataGrid1.controlbar { actionbutton … }`, `replace diff --git a/docs-site/src/reference/page/alter-page.md b/docs-site/src/reference/page/alter-page.md index 39caf9304c..8c7cffd35b 100644 --- a/docs-site/src/reference/page/alter-page.md +++ b/docs-site/src/reference/page/alter-page.md @@ -16,8 +16,8 @@ ALTER SNIPPET module.Name { generic ALTER — `set` / `insert` / `replace` / `drop` against a target — which every document type shares; a page's targets are widget names, DataGrid 2 columns `grid column(Attr)` / `grid column('Caption')`, navigation list items -`nav item(widget)` / `nav item('Text')` / `nav item@n`, or a layout region -`container.top`. +`nav item(widget)` / `nav item('Text')` / `nav item@n`, a legacy table cell +`table1 cell(row, col)`, or a layout region `container.top`. The older spellings still run and warn with a deprecation code: @@ -199,6 +199,36 @@ alter page Shop.Faq { - `INSERT BEFORE` / `AFTER` an item (adding a new group) and `REPLACE` of a whole item are not supported; use `drop` and `create or modify page`. +### Legacy Table Cell Operations + +A legacy (Studio Pro) table's cell stores no name, only its position, so it is +addressed as `table1 cell(row, column)`, 1-based from the top-left. `describe` +lists the cells under the table's line, with what each holds: + +``` +-- Forms$Table (table1) -- NOT re-executable: … +-- cell(1,1): txtName +-- cell(1,2): (empty) +``` + +```sql +mdl 1; +alter page Shop.Customer_Edit { + insert into table1 cell(1,2) { textbox tbName (Attribute: Name) }; + replace table1 cell(1,1) with { dynamictext txtLabel (Content: 'Name') }; + drop table1 cell(2,1); +}; +``` + +- `insert into` appends to the cell; `replace` swaps its content; `drop` + empties it — the cell itself stays, the table's grid needs it. +- Widgets in a cell bind in the data context around the table, as anywhere else. +- A position covered by a spanning cell is refused, naming the cell to address. +- `INSERT BEFORE` / `AFTER` a cell is refused: rows and columns cannot be added. + A widget already in a cell is reachable by its own name. +- MDL cannot create a table, so these statements work only on a page that + already has one. + ### DROP Removes one or more widgets by name. The widget and all its children are removed from the tree. diff --git a/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md b/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md index 96f3475174..add473c43d 100644 --- a/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md +++ b/docs/11-proposals/PROPOSAL_alter_object_list_item_address.md @@ -99,7 +99,7 @@ Rules, decided with the maintainer: property (`defaultItemSlotKey`, `widget_engine.go`). Another slot needs `.slot`, spelled as its property key (case-insensitive) or the item-slot keyword CREATE accepts for it (`headercontent`). -3. **Legacy table cells are deferred** (`table1 cell(r, c)` sketched below). +3. **Legacy table cells** get their own positional form, `table1 cell(r, c)` (below). One rule follows from the widget's own definition: **a slot the item's configuration hides is refused, naming the property that enables it.** The @@ -129,9 +129,12 @@ hidden-slot refusal, Open Question 2 answered as "refuse"). Measuring the SET path found that a mode switch must also keep the texts it hides or shows as CREATE stores them, and does. +Legacy table cells: done (`table1 cell(row, col)` — INSERT INTO appends, +REPLACE swaps, DROP empties; describe lists the cells). See "Legacy Table +Cells" below for the measured shape. + Not done: inserting a NEW item (`insert after acc1 item@1 { group … }`) is -refused; step 4's selector rename was not needed; legacy table cells stay -deferred. +refused; step 4's selector rename was not needed. ## Implementation Plan @@ -202,15 +205,36 @@ schema (`ObjectType.PropertyTypes`), not a version table. - Pop-up menu: one test that the same address reaches a `basicItem`, proving the generalisation rather than an Accordion special case. -## Deferred: Legacy Table Cells +## Legacy Table Cells + +A `Forms$Table` cell is positional, so its address is `table1 cell(row, +column)`, 1-based — not `item@n`, which would depend on storage order. + +The shape was measured, not taken from gen: a table injected into a blank +11.13.0 app and run through `mx convert -p` (0 errors) is stored as + +``` +Forms$Table { Name, Appearance, TabIndex, WidthUnit: "Weight", + ConditionalVisibilitySettings, + Rows [2]: Forms$TableRow, ColumnWidths [2]: Forms$TableColumn { Value }, + Cells [2]: Forms$DbTableCell { TopRowIndex, LeftColumnIndex (0-based), + Width, Height (span), IsHeader, + Appearance, Widgets [2] } } +``` -A `Forms$Table` cell is positional (`TopRowIndex`, `LeftColumnIndex`), so its -address would be `table1 cell(row, column)`, 1-based — not `item@n`, which -would depend on storage order. Deferred until (a) a Studio Pro-authored table is -available to confirm the stored shape (a Mendix 11 cell is `Forms$DbTableCell`, -which gen does not model, and `mx convert` refuses `Forms$TableCell`), and (b) -describe prints tables at all. #1463 already lets ALTER reach widgets inside -cells by name. +gen's `Forms$TableCell` and `Columns` are not what Mendix 11 stores (`mx +convert` refuses `Forms$TableCell`). Rules: + +- INSERT INTO a cell appends; REPLACE a cell swaps its widgets; DROP a cell + empties it — the cell stays, the grid needs it. +- A position under a spanning cell is refused, naming the spanning cell's + address; a miss lists the table's cells. +- INSERT BEFORE / AFTER a cell is refused (no rows or columns are added); a + widget inside a cell is reached by its name (#1463). +- A cell is not a data container: inserted widgets bind in the table's + enclosing context. +- describe prints the table as NOT re-executable (MDL cannot create one) and + lists `-- cell(r,c): ` beneath it, so the address is discoverable. ## Open Questions From fad28b63b5ca71bdb94bd015853345bd12712852 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:16:05 +0000 Subject: [PATCH 34/50] fix: describe no longer prints a view entity's association as a statement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `describe module` printed the association a view entity's `t.ID as Name` OQL column declares as `create or modify association M.VE_Target from M.VE to M.Target`, which check and exec then refuse as CE6771 — so the module's own description did not re-execute (mendixlabs/mxcli#1627, ako/mxcli#1559). The column IS the declaration and the view entity's description already carries it. `describe module` now skips associations whose Source is OqlViewAssociationSource, and `describe association` on one prints a pointer to the view entity instead of a statement. Measured on 10.24.25: pre-fix the description is refused by check --references and exec (nothing written); fixed, it passes, rebuilds the view entity and its association in a fresh project, and mx check reports 0 errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017jEcJZDio1jrTrWtVzagjY --- ...-module-emits-view-entity-association.json | 1 + mdl/executor/cmd_associations.go | 36 ++++++ mdl/executor/cmd_modules.go | 13 ++- .../view_association_describe_1559_test.go | 109 ++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-describe-module-emits-view-entity-association.json create mode 100644 mdl/executor/view_association_describe_1559_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-describe-module-emits-view-entity-association.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-describe-module-emits-view-entity-association.json new file mode 100644 index 0000000000..9032907259 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-describe-module-emits-view-entity-association.json @@ -0,0 +1 @@ +{"date": "2026-10-22", "area": "mdl/executor", "symptom": "mendixlabs/mxcli#1627 (ako/mxcli#1559): `describe module` of a module with a view entity whose OQL selects `t.ID as VE_Target` printed `create or modify association M.VE_Target from M.VE to M.Target`; `check --references` and `exec` then refused the module's own description as CE6771, nothing written", "cause": "describeModule listed every dm.Associations/CrossAssociations entry, and describeAssociation rendered each as a statement. A view entity's association carries Source DomainModels$OqlViewAssociationSource and is DECLARED by the OQL column, which the view entity's description already prints; there is no statement that can state it (a plain one is the CE6771 refusal itself)", "file": "mdl/executor/cmd_modules.go (skip view associations), mdl/executor/cmd_associations.go (describeViewAssociation, isViewAssociationSource)", "fix": "describe module skips associations whose Source is OqlViewAssociationSource; describe association on one prints a two-line `--` pointer to the view entity (zero statements), the plain association stays a statement", "test": "TestDescribeModule_ViewAssociationIsItsOQLColumn, TestDescribeAssociation_ViewAssociationPointsAtTheView (PedApp fixture); mdl-examples/bug-tests/1559-view-entity-association-describe.mdl", "insight": "**When a feature derives a member from another statement, every describer of that member kind needs to know** — the 2026-09-12 fix taught create/check/exec that the column IS the declaration, and the one emitter it did not touch was describe, so the platform-correct model described as a refused script. Grep the emitter's CALL SITES (here only describeModule and the `describe association` dispatch) rather than the reported command. The assertion that matters is not 'no association line' but `assertAgree(described, \"\")` — check AND exec accept the description — plus describe → exec → describe being a fixed point; the first alone would pass a describer that dropped the OQL column too. Controls: pre-fix the same tests fail with the CE6771 refusal text from both check and exec; a plain association in the same module must still describe as a statement. Measured 10.24.25: pre-fix check --references refuses and exec writes nothing; fixed, check passes, a fresh project gets the view entity AND its association from the OQL, mx check 0 errors, in-place re-run 'already in sync'. The report's 'adds a grant read line' did NOT reproduce on an mxcli-authored model with read *, read (Code) and read (Code, VE_Target) grants — it needs the Studio Pro-stored rule to diagnose", "ce": ["CE6771"], "refs": ["mendixlabs/mxcli#1627", "ako/mxcli#1559"]} diff --git a/mdl/executor/cmd_associations.go b/mdl/executor/cmd_associations.go index c657c7e24d..c42329f650 100644 --- a/mdl/executor/cmd_associations.go +++ b/mdl/executor/cmd_associations.go @@ -800,6 +800,11 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { fromEntity := entityNames[assoc.ParentID] toEntity := entityNames[assoc.ChildID] + if isViewAssociationSource(assoc.Source) { + describeViewAssociation(ctx, module.Name+"."+assoc.Name, fromEntity, toEntity, assoc.ViewSourceReference) + return nil + } + if assoc.Documentation != "" { outputJavadocIndented(ctx.Output, assoc.Documentation, "") } @@ -818,6 +823,11 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { fromEntity = string(ca.ParentID) } + if isViewAssociationSource(ca.Source) { + describeViewAssociation(ctx, module.Name+"."+ca.Name, fromEntity, ca.ChildRef, ca.ViewSourceReference) + return nil + } + if ca.Documentation != "" { outputJavadocIndented(ctx.Output, ca.Documentation, "") } @@ -832,6 +842,32 @@ func describeAssociation(ctx *ExecContext, name ast.QualifiedName) error { return mdlerrors.NewNotFound("association", name.String()) } +// describeViewAssociation describes an association a view entity's OQL +// declares — a `.ID as ` select column — as a pointer to that +// view entity, never as a statement. +// +// There is no statement that can say it: a plain `create association` with a +// view entity at either end is CE6771, and check and exec both refuse one. The +// column IS the declaration, and the view entity's own description carries it, +// so re-executing that rebuilds the association (syncViewEntityAssociations). +// Printing `create or modify association` here made `describe module` output +// that its own check refused (mendixlabs/mxcli#1627). +func describeViewAssociation(ctx *ExecContext, qualifiedName, fromEntity, toEntity, column string) { + if column == "" { + column = shortAssociationName(qualifiedName) + } + fmt.Fprintf(ctx.Output, "-- association %s (%s -> %s) is declared by view entity %s's OQL column `as %s`;\n", + qualifiedName, fromEntity, toEntity, fromEntity, column) + fmt.Fprintf(ctx.Output, "-- it has no statement of its own: describe entity %s to reproduce it\n", fromEntity) +} + +// isViewAssociationSource reports whether an association's Source makes it a +// view entity's OQL-declared association, which describes with the view entity +// rather than on its own. +func isViewAssociationSource(source string) bool { + return source == domainmodel.OqlViewAssociationSource +} + // statedStorageFormat maps an authored storage clause onto the stored value, and // reports whether the statement stated one. An unstated storage is not a request // for the default: on `create or modify` it means "leave it", because the diff --git a/mdl/executor/cmd_modules.go b/mdl/executor/cmd_modules.go index 185e748caa..e968d9fb82 100644 --- a/mdl/executor/cmd_modules.go +++ b/mdl/executor/cmd_modules.go @@ -709,12 +709,21 @@ func describeModule(ctx *ExecContext, moduleName string, withAll bool) error { } // Cross-module associations are stored in a separate collection but are // owned by this module (the FROM entity lives here); emit them too. + // + // A view entity's association is not emitted: its `.ID as ` + // OQL column declares it, the view entity's description above carries + // that column, and no statement can — a plain one is CE6771, refused by + // check and exec alike (mendixlabs/mxcli#1627). var assocNames []string for _, a := range dm.Associations { - assocNames = append(assocNames, a.Name) + if !isViewAssociationSource(a.Source) { + assocNames = append(assocNames, a.Name) + } } for _, ca := range dm.CrossAssociations { - assocNames = append(assocNames, ca.Name) + if !isViewAssociationSource(ca.Source) { + assocNames = append(assocNames, ca.Name) + } } for _, name := range assocNames { qn := ast.QualifiedName{Module: moduleName, Name: name} diff --git a/mdl/executor/view_association_describe_1559_test.go b/mdl/executor/view_association_describe_1559_test.go new file mode 100644 index 0000000000..78f3074ebb --- /dev/null +++ b/mdl/executor/view_association_describe_1559_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// viewAssociationSetup gives a module a view entity whose `t.ID as VE_Target` +// column declares an association, and — the control — a plain association +// between two persistent entities. +const viewAssociationSetup = `mdl 1; +create module VEA; +create persistent entity VEA.Target (Code: String(20)); +create persistent entity VEA.Other (Code: String(20)); +create association VEA.Other_Target from VEA.Other to VEA.Target; +create view entity VEA.VE ( + Code: String(20) +) as ( + from VEA.Target as t + select t.ID as VE_Target, + t.Code as Code +);` + +// mendixlabs/mxcli#1627 (ako/mxcli#1559): `describe module` printed a view +// entity's association as `create or modify association M.VE_Target from M.VE +// to M.Target`, which check and exec then refuse as CE6771 — so the module's +// own description did not re-execute. The association is declared by the OQL +// column, which the view entity's description already carries. +func TestDescribeModule_ViewAssociationIsItsOQLColumn(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + if err := agreeExec(t, exec, viewAssociationSetup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeModule, Name: ast.QualifiedName{Module: "VEA"}, WithAll: true}); err != nil { + t.Fatalf("describe module: %v\n%s", err, out.String()) + } + described := out.String() + + if strings.Contains(described, "association VEA.VE_Target") { + t.Errorf("describe module printed the view entity's association as a statement:\n%s", described) + } + // The column that declares it is still there… + if !strings.Contains(described, "select t.ID as VE_Target") { + t.Errorf("describe module lost the OQL column that declares the association:\n%s", described) + } + // …and a plain association is still described (the control). + if !strings.Contains(described, "create or modify association VEA.Other_Target") { + t.Errorf("describe module dropped a plain association:\n%s", described) + } + + // The description re-executes: check and exec both accept it. + assertAgree(t, exec, dir, described, "") + + // And the association survives the replay, still owned by the view. + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeModule, Name: ast.QualifiedName{Module: "VEA"}, WithAll: true}); err != nil { + t.Fatalf("second describe: %v", err) + } + if again := out.String(); again != described { + t.Errorf("describe → exec → describe is not a fixed point\nfirst:\n%s\nsecond:\n%s", described, again) + } +} + +// `describe association` on a view entity's association names where it is +// declared rather than printing a statement every check refuses. The output +// holds no statement at all, so a script built from it has nothing to refuse; +// the plain association, the control, still describes as one. +func TestDescribeAssociation_ViewAssociationPointsAtTheView(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, viewAssociationSetup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeAssociation, Name: ast.QualifiedName{Module: "VEA", Name: "VE_Target"}}); err != nil { + t.Fatalf("describe association: %v", err) + } + got := out.String() + if strings.Contains(got, "create or modify association") { + t.Errorf("describe association printed a statement for a view entity's association:\n%s", got) + } + for _, want := range []string{"VEA.VE", "VE_Target", "describe entity VEA.VE"} { + if !strings.Contains(got, want) { + t.Errorf("describe association should name %q, got:\n%s", want, got) + } + } + prog, errs := visitor.Build(got) + if len(errs) > 0 { + t.Fatalf("describe association output does not parse: %v\n%s", errs[0], got) + } + if n := len(prog.Statements); n != 0 { + t.Errorf("describe association output holds %d statement(s), want none:\n%s", n, got) + } + + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeAssociation, Name: ast.QualifiedName{Module: "VEA", Name: "Other_Target"}}); err != nil { + t.Fatalf("describe association (control): %v", err) + } + if !strings.Contains(out.String(), "create or modify association VEA.Other_Target") { + t.Errorf("control: a plain association must still describe as a statement:\n%s", out.String()) + } +} From ace4d5d50334ad3f9e205b9699646ce932409779 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:16:05 +0000 Subject: [PATCH 35/50] =?UTF-8?q?fix:=20describe=20=E2=86=92=20exec=20no?= =?UTF-8?q?=20longer=20re-indents=20a=20view=20entity's=20stored=20OQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser keeps a view query from its first token to its last and dedents the shared indentation, so describe → exec rewrote stored text it cannot carry: an indentation on every line, a leading blank line, a trailing newline, a comment outside the tokens — on a statement that changed nothing (mendixlabs/mxcli#1627, second symptom). `create or modify` / `create or replace` of an existing view entity now keeps the stored query when the statement's query is exactly what the stored one describes to, computed with the describer's own rendering parsed back by the real parser. A real edit is written as authored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017jEcJZDio1jrTrWtVzagjY --- ...ntity-oql-reindented-by-describe-exec.json | 1 + .../1559-view-entity-association-describe.mdl | 47 ++++++++ mdl/executor/cmd_entities.go | 19 ++- mdl/executor/cmd_entities_describe.go | 8 +- mdl/executor/view_entity_oql_carry.go | 70 ++++++++++++ mdl/executor/view_entity_oql_carry_test.go | 108 ++++++++++++++++++ 6 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-view-entity-oql-reindented-by-describe-exec.json create mode 100644 mdl-examples/bug-tests/1559-view-entity-association-describe.mdl create mode 100644 mdl/executor/view_entity_oql_carry.go create mode 100644 mdl/executor/view_entity_oql_carry_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-view-entity-oql-reindented-by-describe-exec.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-view-entity-oql-reindented-by-describe-exec.json new file mode 100644 index 0000000000..4fb6900683 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-view-entity-oql-reindented-by-describe-exec.json @@ -0,0 +1 @@ +{"date": "2026-10-22", "area": "mdl/executor", "symptom": "mendixlabs/mxcli#1627, second symptom: describe → exec of a view entity re-indented its OQL. Stored text whose every line was indented, or tab-indented, came back flush; a leading blank line, a trailing newline, and a comment before the first or after the last token were dropped — on a statement the user did not change", "cause": "The parser stores the query from its first token to its last and dedents the shared prefix (#653). Describe indents every stored line by two, so a stored indentation on all lines is indistinguishable from the script's own, and anything outside the token span (whitespace, a blank first line, a hidden-channel comment) has no carrier at all", "file": "mdl/executor/view_entity_oql_carry.go (describedViewQuery, viewQueryAsReexecuted, carriedViewQuery), mdl/executor/cmd_entities_describe.go, mdl/executor/cmd_entities.go (execCreateViewEntity)", "fix": "execCreateViewEntity keeps the STORED query when the statement's query equals what the stored one describes to — computed by rendering it with the describer's own function and parsing that back with visitor.Build — and writes the authored text otherwise", "test": "TestCreateOrModifyViewEntity_KeepsStoredOQLItDescribes (5 stored shapes on PedApp, each with an edit control that must still land)", "insight": "**The span of a parse is a lossy channel, so make the carry decision with the real parser, not a model of it**: viewQueryAsReexecuted parses `describedViewQuery(stored)` exactly as a script would and compares, so a future change to dedentOQL or the token span cannot desynchronise the carry. A text-only fix is impossible here — ` select…\\n from…` stored and a script indenting its query by four are the same bytes after describe — which is why it is a carry (statement denotes stored) and not a better dedent. The scratch probe that found the shapes was cheap: feed candidate stored strings through describeEntityToString + parseViewEntityQuery and diff; 7 of 9 drifted. Comments outside the span are still lost on a describe → exec into a FRESH project (no stored text to carry) — a grammar question, not fixed here", "refs": ["mendixlabs/mxcli#1627", "ako/mxcli#653"]} diff --git a/mdl-examples/bug-tests/1559-view-entity-association-describe.mdl b/mdl-examples/bug-tests/1559-view-entity-association-describe.mdl new file mode 100644 index 0000000000..ae002bb4d2 --- /dev/null +++ b/mdl-examples/bug-tests/1559-view-entity-association-describe.mdl @@ -0,0 +1,47 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1627 (ako/mxcli#1559) — `describe` emitted a view entity's +-- association as a statement that check and exec then refuse (CE6771). +-- +-- Selecting a persistent entity's ID under an alias gives a view entity an +-- association named after the alias; the OQL column IS the declaration. The +-- module's description printed it a second time, on its own: +-- +-- create or modify association VeRt.VE_Target +-- from VeRt.VE to VeRt.Target; +-- +-- and `check`/`exec` refused the description with "VeRt.VE is a view entity — +-- Mendix does not allow a plain association to or from one (CE6771)", so the +-- module's own describe output did not re-execute. `describe module` now leaves +-- it to the view entity's OQL, and `describe association` on one prints a +-- pointer to the view entity instead of a statement. +-- +-- The report's second symptom, a re-indented OQL, came from stored text the +-- describe → exec span cannot carry (an indented first line, a comment before +-- the first token, a trailing newline): a `create or modify view entity` whose +-- query is exactly what the stored query describes to now keeps the stored +-- bytes. Its third, an added `grant read` line, did not reproduce on an +-- mxcli-authored 10.24.25 model with three grant shapes. +-- +-- Measured on 10.24.25: pre-fix, the description of this module is refused by +-- `check --references` and by `exec` (nothing written); fixed, it passes, +-- rebuilds the view entity and its association in a fresh project, re-runs +-- in place as "already in sync", and mx check reports 0 errors. +-- +-- This file is a describe fixed point: `describe module VeRt with all` prints +-- these statements back, with no association statement for VE_Target. +-- ============================================================================ + +create module VeRt; + +create persistent entity VeRt.Target ( + Code: String(20) +); + +create view entity VeRt.VE ( + Code: String(20) +) as ( + from VeRt.Target as t + select t.ID as VE_Target, + t.Code as Code +); diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index 62df98c2e1..c4e6324026 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -1048,6 +1048,15 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { return mdlerrors.NewAlreadyExistsMsg("entity", s.Name.Module+"."+s.Name.Name, fmt.Sprintf("entity already exists: %s.%s (use create or modify to update, or create or replace to drop and recreate)", s.Name.Module, s.Name.Name)) } + // The query to store. A statement whose query is what the stored one + // describes to keeps the stored bytes: describe cannot carry whitespace or + // comments outside the query's tokens, and replaying it re-indented the + // stored OQL (mendixlabs/mxcli#1627). + query := s.Query.RawQuery + if existingEntity != nil { + query = carriedViewQuery(query, existingEntity.OqlQuery) + } + // CREATE OR REPLACE: delete existing entity and source doc, then recreate if existingEntity != nil && s.CreateOrReplace { // Preserve location from old entity if no explicit position @@ -1091,7 +1100,7 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { module.ID, s.Name.Module, s.Name.Name, - s.Query.RawQuery, + query, s.Documentation, ) if err != nil { @@ -1108,7 +1117,7 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { } // A DateTime column's LocalizeDate is the source attribute's, not Mendix's // default: a mismatch is CE6770 (#1297). - localize := viewDateTimeLocalize(ctx, s.Query.RawQuery, s.Attributes) + localize := viewDateTimeLocalize(ctx, query, s.Attributes) var attrs []*domainmodel.Attribute for _, a := range s.Attributes { attr := &domainmodel.Attribute{ @@ -1148,7 +1157,7 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { Attributes: attrs, Source: "DomainModels$OqlViewEntitySource", SourceDocumentRef: sourceDocRef, - OqlQuery: s.Query.RawQuery, + OqlQuery: query, ExportLevel: authoredExportLevel(s.ExportLevel), } if autoPlace { @@ -1214,11 +1223,11 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { created = stored } } - if nameErrors := validateViewAssociationNames(ctx, s.Name.Module, s.Name.Name, s.Query.RawQuery, nil); len(nameErrors) > 0 { + if nameErrors := validateViewAssociationNames(ctx, s.Name.Module, s.Name.Name, query, nil); len(nameErrors) > 0 { return mdlerrors.NewValidationf("view entity '%s':\n - %s", s.Name.String(), strings.Join(nameErrors, "\n - ")) } - if err := syncViewEntityAssociations(ctx, module.ID, s.Name.Module, created, s.Query.RawQuery); err != nil { + if err := syncViewEntityAssociations(ctx, module.ID, s.Name.Module, created, query); err != nil { return err } invalidateHierarchy(ctx) diff --git a/mdl/executor/cmd_entities_describe.go b/mdl/executor/cmd_entities_describe.go index 3dab13c812..7bb31c3a40 100644 --- a/mdl/executor/cmd_entities_describe.go +++ b/mdl/executor/cmd_entities_describe.go @@ -393,13 +393,7 @@ func describeEntityWith(ctx *ExecContext, name ast.QualifiedName, withGrants boo // For VIEW entities, output the OQL query if entityType == "view" && entity.OqlQuery != "" { - fmt.Fprint(ctx.Output, " as (\n") - // Indent OQL query lines - oqlLines := strings.SplitSeq(entity.OqlQuery, "\n") - for line := range oqlLines { - fmt.Fprintf(ctx.Output, " %s\n", line) - } - fmt.Fprint(ctx.Output, ")") + fmt.Fprint(ctx.Output, describedViewQuery(entity.OqlQuery)) } // Build attribute name map diff --git a/mdl/executor/view_entity_oql_carry.go b/mdl/executor/view_entity_oql_carry.go new file mode 100644 index 0000000000..9fc3e44679 --- /dev/null +++ b/mdl/executor/view_entity_oql_carry.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// describedViewQuery renders a view entity's stored OQL as DESCRIBE prints it: +// ` as (`, each stored line indented by two, then `)`. It is the one rendering — +// the describer prints it, and carriedViewQuery re-parses it — so the carry +// cannot drift from what describe actually emits. +func describedViewQuery(oql string) string { + var b strings.Builder + b.WriteString(" as (\n") + for line := range strings.SplitSeq(oql, "\n") { + b.WriteString(" ") + b.WriteString(line) + b.WriteString("\n") + } + b.WriteString(")") + return b.String() +} + +// viewQueryAsReexecuted is the query `exec` stores for a describe of oql: the +// description parsed back, exactly as a script holding it would be. +// +// It is not the stored text. The parser keeps the query from its first token to +// its last and strips the indentation the lines share (ako/mxcli#653), so a +// describe cannot carry what lies outside that span — leading or trailing +// whitespace, a blank first line, a comment before the first token or after the +// last — nor tell an indentation the stored query has on every line from the +// two spaces describe added. ok is false when the description does not parse. +func viewQueryAsReexecuted(oql string) (string, bool) { + src := "create view entity M.V (\n A: Integer\n)" + describedViewQuery(oql) + ";" + prog, errs := visitor.Build(src) + if len(errs) > 0 || prog == nil { + return "", false + } + for _, stmt := range prog.Statements { + if s, ok := stmt.(*ast.CreateViewEntityStmt); ok { + return s.Query.RawQuery, true + } + } + return "", false +} + +// carriedViewQuery returns the query a `create or modify` / `create or replace` +// of an existing view entity should store: the STORED text when the statement's +// query is exactly what that stored text describes to, and the statement's own +// otherwise. +// +// Without it, describe → exec rewrote any query Studio Pro had stored with an +// indented first line, a comment outside the tokens or a trailing newline — the +// "re-indents the OQL" of mendixlabs/mxcli#1627 — and did it on a statement that +// changes nothing. When the two are equal the statement denotes the stored +// query, so keeping it loses nothing; a real edit differs from the re-executed +// description and is written as authored. +func carriedViewQuery(authored, stored string) string { + if stored == "" || authored == stored { + return authored + } + if reexecuted, ok := viewQueryAsReexecuted(stored); ok && reexecuted == authored { + return stored + } + return authored +} diff --git a/mdl/executor/view_entity_oql_carry_test.go b/mdl/executor/view_entity_oql_carry_test.go new file mode 100644 index 0000000000..cbc9013ed1 --- /dev/null +++ b/mdl/executor/view_entity_oql_carry_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// storeViewOQL overwrites a view entity's stored OQL — both the source document +// and the entity's copy — the way a Studio Pro edit leaves it, so a test can +// start from text mxcli would never have written. +func storeViewOQL(t *testing.T, exec *Executor, module, name, oql string) { + t.Helper() + ctx := exec.newExecContext(context.Background()) + mod, err := findModule(ctx, module) + if err != nil { + t.Fatal(err) + } + dm, err := ctx.Backend.GetDomainModel(mod.ID) + if err != nil { + t.Fatal(err) + } + ent := dm.FindEntityByName(name) + if ent == nil { + t.Fatalf("entity %s.%s not found", module, name) + } + if _, err := ctx.Backend.WriteViewEntitySourceDocument(mod.ID, module, name, oql, ""); err != nil { + t.Fatal(err) + } + ent.OqlQuery = oql + if err := ctx.Backend.UpdateEntity(dm.ID, ent); err != nil { + t.Fatal(err) + } +} + +// storedViewOQL reads a view entity's OQL back from the project. +func storedViewOQL(t *testing.T, exec *Executor, module, name string) string { + t.Helper() + ctx := exec.newExecContext(context.Background()) + mod, err := findModule(ctx, module) + if err != nil { + t.Fatal(err) + } + dm, err := ctx.Backend.GetDomainModel(mod.ID) + if err != nil { + t.Fatal(err) + } + ent := dm.FindEntityByName(name) + if ent == nil { + t.Fatalf("entity %s.%s not found", module, name) + } + return ent.OqlQuery +} + +// mendixlabs/mxcli#1627, second symptom: the view entity round trip re-indented +// the OQL. The script's query is the text between its first and last token, +// dedented — so describe → exec cannot tell a stored indentation from the +// script's own, and drops whitespace and comments outside the tokens. Each +// shape here is stored text that describe → `create or modify` rewrote. +// +// The fix keeps the stored bytes when the statement's query is exactly what the +// stored query describes to; the control is a real edit, which must still land. +func TestCreateOrModifyViewEntity_KeepsStoredOQLItDescribes(t *testing.T) { + shapes := map[string]string{ + "indented first line": " SELECT t.ID AS VE_Target, t.Code AS Code\n FROM VEA.Target AS t", + "tab indented": "\tSELECT t.ID AS VE_Target, t.Code AS Code\n\tFROM VEA.Target AS t", + "leading comment": "-- view over targets\nSELECT t.ID AS VE_Target, t.Code AS Code\nFROM VEA.Target AS t", + "trailing newline": "SELECT t.ID AS VE_Target, t.Code AS Code\r\nFROM VEA.Target AS t\r\n", + "leading blank line": "\nSELECT t.ID AS VE_Target, t.Code AS Code\nFROM VEA.Target AS t", + } + for name, stored := range shapes { + t.Run(name, func(t *testing.T) { + exec, out, _ := openPedAppCopy(t) + if err := agreeExec(t, exec, viewAssociationSetup); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + storeViewOQL(t, exec, "VEA", "VE", stored) + + out.Reset() + if err := exec.Execute(&ast.DescribeStmt{ObjectType: ast.DescribeEntity, Name: ast.QualifiedName{Module: "VEA", Name: "VE"}}); err != nil { + t.Fatalf("describe: %v", err) + } + described := out.String() + if err := agreeExec(t, exec, described); err != nil { + t.Fatalf("exec of the description: %v\n%s", err, described) + } + if got := storedViewOQL(t, exec, "VEA", "VE"); got != stored { + t.Errorf("describe → exec rewrote the stored OQL\nwant %q\n got %q\ndescribed:\n%s", stored, got, described) + } + + // Control: an edit to the query is not a re-description, and lands. + edited := strings.Replace(described, "t.Code AS Code", "t.Code AS Code -- edited", 1) + if edited == described { + t.Fatalf("control could not edit the description:\n%s", described) + } + if err := agreeExec(t, exec, edited); err != nil { + t.Fatalf("exec of the edited description: %v", err) + } + if got := storedViewOQL(t, exec, "VEA", "VE"); !strings.Contains(got, "-- edited") { + t.Errorf("control: an edited query must be written, stored %q", got) + } + }) + } +} From bc75ffc9c30c649772d32ae7ccd08e01c3a996d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:16:33 +0000 Subject: [PATCH 36/50] fix: replaying an XML-schema mapping's description no longer drops its root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `describe module` → `exec` of a module holding an import mapping over an XML schema rewrote it with XsdRootElementName "" (CE0270 "No root element could be found in the schema") and an empty XmlPath on every element (mendixlabs/mxcli#1623, ako/mxcli#1555). MDL states only `with xml schema M.S`; exec has no XSD reader to derive the rest, and both mapping writers hardcoded the root. gen also read the root under the SDK name RootElementName instead of the storage key XsdRootElementName, so mxcli could not see the root it was deleting. - gen: storage-name override for XsdRootElementName on both mapping types (confirmed in mxbuild's own metadata), struck from keyaudit. - model/backend: RootElementName read and written instead of "". - executor: `create or modify` of an existing XML-schema mapping that stores a root or element paths reports Unchanged and writes nothing when the statement is the mapping's own description; any other rewrite is refused, naming what it would delete. Mappings with no stored XML state — what mxcli writes — rewrite as before. Import and export alike. Measured on 10.24.25 with a mapping carrying a root and five XmlPaths: pre-fix the replay printed "Modified import mapping" and emptied all six; fixed, "already in sync" and the stored mapping is byte-identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017jEcJZDio1jrTrWtVzagjY --- ...apping-replay-drops-root-and-xmlpaths.json | 1 + .../integration/create-export-mapping.md | 9 + .../integration/create-import-mapping.md | 9 + ...555-xml-schema-mapping-describe-replay.mdl | 65 +++++ mdl/backend/modelsdk/mapping_read.go | 2 + .../modelsdk/mapping_root_element_test.go | 53 ++++ mdl/backend/modelsdk/mapping_write.go | 8 +- mdl/executor/cmd_export_mappings.go | 13 + mdl/executor/cmd_import_mappings.go | 13 + .../import_mapping_xml_schema_1555_test.go | 276 ++++++++++++++++++ mdl/executor/mapping_xml_schema_guard.go | 185 ++++++++++++ model/types.go | 12 +- modelsdk/gen/exportmappings/types.go | 7 +- modelsdk/gen/importmappings/types.go | 7 +- modelsdk/gen/keyaudit_test.go | 2 - 15 files changed, 655 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-xml-schema-mapping-replay-drops-root-and-xmlpaths.json create mode 100644 mdl-examples/bug-tests/1555-xml-schema-mapping-describe-replay.mdl create mode 100644 mdl/backend/modelsdk/mapping_root_element_test.go create mode 100644 mdl/executor/import_mapping_xml_schema_1555_test.go create mode 100644 mdl/executor/mapping_xml_schema_guard.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-xml-schema-mapping-replay-drops-root-and-xmlpaths.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-xml-schema-mapping-replay-drops-root-and-xmlpaths.json new file mode 100644 index 0000000000..d21d13005c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-xml-schema-mapping-replay-drops-root-and-xmlpaths.json @@ -0,0 +1 @@ +{"date": "2026-10-22", "area": "mdl/executor", "symptom": "mendixlabs/mxcli#1623 (ako/mxcli#1555): `describe module M with all` → `exec` of a module holding an import mapping over an XML schema printed 'Modified import mapping' and left the mapping with XsdRootElementName \"\" (CE0270 'No root element could be found in the schema') and an empty XmlPath on every element — re-executing the mapping's own description", "cause": "MDL states only `with xml schema M.S`; exec has no XSD reader, so buildImportMappingElementModel never sets XmlPath (only the message-definition builder does) and fabricates JsonPaths, and both mapping writers hardcoded XsdRootElementName \"\". The root was also INVISIBLE: gen bound the SDK name RootElementName instead of the storage key XsdRootElementName (it sat in keyaudit's knownWrongKeys), so nothing could have noticed it being deleted", "file": "mdl/executor/mapping_xml_schema_guard.go (new), mdl/executor/cmd_import_mappings.go + cmd_export_mappings.go (wired after the SOAP check), modelsdk/gen/{import,export}mappings/types.go (STORAGE-NAME OVERRIDE, struck from keyaudit), model/types.go (RootElementName on both mapping types), mdl/backend/modelsdk/mapping_{read,write}.go", "fix": "A create-or-modify of an existing XML-schema mapping that stores a root or any XmlPath: if the statement deep-equals the parsed description of the stored mapping (folder/doc/@excluded omissions normalised to 'keep'), report Unchanged and write nothing; otherwise refuse naming the root and the number of element paths. Mappings with no stored XML state (what mxcli writes) rewrite as before. Writers now write the model's RootElementName", "test": "TestCreateOrModifyImportMapping_XmlSchema{RedescriptionIsUnchanged,EditIsRefused,WithoutStoredPathsStillRewrites}, TestCreateOrModifyExportMapping_XmlSchemaRedescriptionIsUnchanged, TestMappingRootElementName_RoundTrips; mdl-examples/bug-tests/1555-xml-schema-mapping-describe-replay.mdl", "insight": "**Before trusting a guard on a stored value, check the reader can see it**: the first probe read root=\"\" from a unit holding XsdRootElementName \"Order\" — gen's known-wrong key. mxbuild's own assemblies settle storage names without Studio Pro: Mendix.Modeler.Integration.dll carries ` … SdkName ` pairs in its metadata (python byte search), which is how XsdRootElementName/rootElementName and the XmlSchema entry's storage `$Type` XmlSchemas$XmlSchemaContents (list key SchemaContentss; SDK name XmlSchemaEntry) were confirmed — a planted XmlSchemas$XmlSchemaEntry crashes mx with TypeCacheUnknownTypeException. **A planted XML schema is NOT a usable oracle for XML mappings**: with the right type and keys it loads at 0 errors alone, but any mapping over it is CE0271 'The selected source is not valid' under 9 variants (no XML declaration, absolute/file/http Location, target namespace, '{0}' location format, bogus root, root-only mapping, JsonPath kept) — so the XML-element shape cannot be measured here; the evidence is the stored BSON byte-identical after replay (pre-fix: root and 5 XmlPaths emptied). The 'microflow key becomes find … or create' (CE0250) half did NOT reproduce: an XML mapping with `by MF(Key: parent)` replayed kept ObjectHandling Custom even pre-fix. Unverified lead for whoever has the stored document: the reader recovers a handler parameter's source from JsonValueElementPath only, and by the JSON corpus's own rule ('only an explicit value path is JSON-only') an XML value path would sit in XmlValueElementPath with the JSON one empty — which sourceFromStoredPath's default arm reads as `parent`", "ce": ["CE0270", "CE0271", "CE0292", "CE0250"], "refs": ["mendixlabs/mxcli#1623", "ako/mxcli#1555", "ako/mxcli#365"]} diff --git a/docs-site/src/reference/integration/create-export-mapping.md b/docs-site/src/reference/integration/create-export-mapping.md index e4a84956ca..68661c8eee 100644 --- a/docs-site/src/reference/integration/create-export-mapping.md +++ b/docs-site/src/reference/integration/create-export-mapping.md @@ -77,6 +77,15 @@ If `OR MODIFY` is specified and the mapping already exists, it is updated in pla only ever something imported into the project by hand, and having none is the ordinary case. The same resolution applies to `WITH JSON STRUCTURE`. + MDL cannot state the XML half of such a mapping — the schema element it is + rooted at, or the schema path of each element — and mxcli does not read the + XSD to derive them. So `CREATE OR MODIFY` of an existing XML-schema mapping + that carries them is accepted only when the statement is the mapping's own + `DESCRIBE` output, which reports `Unchanged` and writes nothing; any other + rewrite is refused rather than written without its root element (which + mxbuild reports as CE0270 "No root element could be found in the schema"). + Edit such a mapping in Studio Pro. + `NULL VALUES LeaveOutElement | SendAsNil` : Controls null serialisation. Defaults to `LeaveOutElement`. diff --git a/docs-site/src/reference/integration/create-import-mapping.md b/docs-site/src/reference/integration/create-import-mapping.md index d412b34674..7d83681c99 100644 --- a/docs-site/src/reference/integration/create-import-mapping.md +++ b/docs-site/src/reference/integration/create-import-mapping.md @@ -100,6 +100,15 @@ If `OR MODIFY` is specified and the mapping already exists, it is updated in pla only ever something imported into the project by hand, and having none is the ordinary case. The same resolution applies to `WITH JSON STRUCTURE`. + MDL cannot state the XML half of such a mapping — the schema element it is + rooted at, or the schema path of each element — and mxcli does not read the + XSD to derive them. So `CREATE OR MODIFY` of an existing XML-schema mapping + that carries them is accepted only when the statement is the mapping's own + `DESCRIBE` output, which reports `Unchanged` and writes nothing; any other + rewrite is refused rather than written without its root element (which + mxbuild reports as CE0270 "No root element could be found in the schema"). + Edit such a mapping in Studio Pro. + `CREATE | FIND | FIND OR CREATE` : Object handling strategy for each entity block. diff --git a/mdl-examples/bug-tests/1555-xml-schema-mapping-describe-replay.mdl b/mdl-examples/bug-tests/1555-xml-schema-mapping-describe-replay.mdl new file mode 100644 index 0000000000..cf9e102937 --- /dev/null +++ b/mdl-examples/bug-tests/1555-xml-schema-mapping-describe-replay.mdl @@ -0,0 +1,65 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1623 (ako/mxcli#1555) — a round trip of an import mapping +-- over an XML schema lost its root element (CE0270) and its microflow key. +-- +-- `with xml schema M.S` names the schema and nothing else of the XML half. A +-- Studio Pro mapping over one also stores the schema element it is rooted at +-- (XsdRootElementName) and each element's schema path (XmlPath). MDL cannot +-- state either and mxcli does not read the XSD, so `create or modify` rebuilt +-- the mapping with XsdRootElementName "" and an empty XmlPath on every element +-- — from the user's own describe output, which changes nothing. Worse, gen read +-- the root under the SDK name `RootElementName` instead of the storage key, so +-- mxcli could not even see the root it was deleting. +-- +-- Now a statement that IS the stored mapping's description reports Unchanged +-- and writes nothing; any other rewrite of an XML-schema mapping that carries a +-- root or XML paths is refused, naming them. A mapping with neither — what +-- mxcli itself writes — is rewritten as before. +-- +-- Measured on 10.24.25 against a mapping carrying XsdRootElementName "Order" +-- and five XmlPaths: pre-fix `describe module` → `exec` printed "Modified +-- import mapping" and left XsdRootElementName "" and every XmlPath ""; fixed, +-- the module is "already in sync" and the stored mapping is byte-identical. +-- (MDL cannot create the XML schema itself, so the measurement planted one; +-- see the finding for why that schema could not be taken to 0 errors.) +-- +-- The "microflow key becomes `find … or create`" half did not reproduce: an +-- XML-schema mapping with `find … by M.MF(Key: parent)`, described and +-- replayed, kept ObjectHandling Custom even on the pre-fix binary. +-- +-- What runs here is the shape that is still authorable — a JSON-structure +-- mapping with a microflow key — and must stay a describe fixed point. +-- ============================================================================ + +create module XmlRt; + +create persistent entity XmlRt.OrderRec ( + Number: String(50) +); + +create persistent entity XmlRt.CustomerRec ( + Name: String(100) +); + +create association XmlRt.CustomerRec_OrderRec from XmlRt.CustomerRec to XmlRt.OrderRec; + +create microflow XmlRt.MF_GetByKey ($Key: XmlRt.OrderRec) +returns XmlRt.CustomerRec +begin + return empty; +end; + +create json structure XmlRt.JSON_Order + sample '{"number": "1", "customer": {"name": "n"}}'; + +create import mapping XmlRt.IMM_Order + with json structure XmlRt.JSON_Order +{ + create XmlRt.OrderRec { + Number = number, + find XmlRt.CustomerRec_OrderRec/XmlRt.CustomerRec by XmlRt.MF_GetByKey(Key: parent) = customer { + Name = name + } + } +}; diff --git a/mdl/backend/modelsdk/mapping_read.go b/mdl/backend/modelsdk/mapping_read.go index e42e8ee60e..5a01b22d10 100644 --- a/mdl/backend/modelsdk/mapping_read.go +++ b/mdl/backend/modelsdk/mapping_read.go @@ -48,6 +48,7 @@ func (b *Backend) ListImportMappings() ([]*model.ImportMapping, error) { OperationName: g.OperationName(), RootElementName: g.RootElementName(), }, + RootElementName: g.RootElementName(), } im.ID = model.ID(g.ID()) im.TypeName = "ImportMappings$ImportMapping" @@ -93,6 +94,7 @@ func (b *Backend) ListExportMappings() ([]*model.ExportMapping, error) { ParameterName: g.ParameterName(), IsHeader: g.IsHeader(), }, + RootElementName: g.RootElementName(), } em.ID = model.ID(g.ID()) em.TypeName = "ExportMappings$ExportMapping" diff --git a/mdl/backend/modelsdk/mapping_root_element_test.go b/mdl/backend/modelsdk/mapping_root_element_test.go new file mode 100644 index 0000000000..7f5b3f523b --- /dev/null +++ b/mdl/backend/modelsdk/mapping_root_element_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/codec" + "github.com/mendixlabs/mxcli/modelsdk/element" + genExp "github.com/mendixlabs/mxcli/modelsdk/gen/exportmappings" + genImp "github.com/mendixlabs/mxcli/modelsdk/gen/importmappings" +) + +// An XML-schema mapping's root element is stored as XsdRootElementName +// (mendixlabs/mxcli#1623). Two things lost it: gen read the SDK name +// RootElementName, so the stored root read back as "", and the writers wrote +// XsdRootElementName: "" whatever the model held. Encoding a root and decoding +// it again must give it back, under the key Mendix reads. +func TestMappingRootElementName_RoundTrips(t *testing.T) { + im := &model.ImportMapping{Name: "IMM", XmlSchema: "XM.XS_Order", RootElementName: "Order"} + em := &model.ExportMapping{Name: "EXM", XmlSchema: "XM.XS_Order", RootElementName: "Order"} + for kind, g := range map[string]element.Element{ + "import": importMappingToGen(im), + "export": exportMappingToGen(em), + } { + raw, err := (&codec.Encoder{}).Encode(g) + if err != nil { + t.Fatalf("%s: encode: %v", kind, err) + } + if got, _ := bson.Raw(raw).Lookup("XsdRootElementName").StringValueOK(); got != "Order" { + t.Errorf("%s mapping: XsdRootElementName = %q, want %q", kind, got, "Order") + } + decoded, err := codec.NewDecoder(codec.DefaultRegistry).Decode(bson.Raw(raw)) + if err != nil { + t.Fatalf("%s: decode: %v", kind, err) + } + var root string + switch d := decoded.(type) { + case *genImp.ImportMapping: + root = d.RootElementName() + case *genExp.ExportMapping: + root = d.RootElementName() + default: + t.Fatalf("%s: decoded as %T", kind, decoded) + } + if root != "Order" { + t.Errorf("%s mapping: the stored root element reads back as %q, want %q", kind, root, "Order") + } + } +} diff --git a/mdl/backend/modelsdk/mapping_write.go b/mdl/backend/modelsdk/mapping_write.go index 7ac1a39e09..d3846d9c4c 100644 --- a/mdl/backend/modelsdk/mapping_write.go +++ b/mdl/backend/modelsdk/mapping_write.go @@ -163,7 +163,9 @@ func importMappingToGen(im *model.ImportMapping) element.Element { addBool(g, "UseSubtransactionsForMicroflows", false) addStr(g, "PublicName", "") - addStr(g, "XsdRootElementName", "") + // The XML schema root is carried, never derived: writing "" deleted it + // from every rewritten XML-schema mapping (CE0270, mendixlabs/mxcli#1623). + addStr(g, "XsdRootElementName", im.RootElementName) // ParameterType is a required sub-document even when unused; without it Studio // Pro fails to render the schema source and mapping elements correctly. A // mapping declaring an input object stores a DataTypes$ObjectType naming it @@ -380,7 +382,9 @@ func exportMappingToGen(em *model.ExportMapping) element.Element { addPartList(g, "Elements", elems) addStr(g, "PublicName", "") - addStr(g, "XsdRootElementName", "") + // The XML schema root is carried, never derived: writing "" deleted it + // from every rewritten XML-schema mapping (CE0270, mendixlabs/mxcli#1623). + addStr(g, "XsdRootElementName", em.RootElementName) addBool(g, "IsHeaderParameter", false) addStr(g, "ParameterName", "") addStr(g, "OperationName", "") diff --git a/mdl/executor/cmd_export_mappings.go b/mdl/executor/cmd_export_mappings.go index 7be3d3fa25..e0e7088e52 100644 --- a/mdl/executor/cmd_export_mappings.go +++ b/mdl/executor/cmd_export_mappings.go @@ -293,6 +293,19 @@ func execCreateExportMapping(ctx *ExecContext, s *ast.CreateExportMappingStmt) e if err := checkNoWebServiceSource("export", s.Name.String(), existing.WebServiceSource); err != nil { return err } + // Nor are an XML-schema mapping's root element and element paths: its + // own description replays as Unchanged, and any other rewrite is refused + // rather than written without them (mendixlabs/mxcli#1623). + unchanged, err := exportMappingXmlSchemaRewrite(ctx, s, existing) + if err != nil { + return err + } + if unchanged { + if !ctx.Quiet { + reportUnchanged(ctx, "export mapping "+s.Name.String()) + } + return nil + } } module, err := findModule(ctx, s.Name.Module) diff --git a/mdl/executor/cmd_import_mappings.go b/mdl/executor/cmd_import_mappings.go index 9dd996e296..b441f2652f 100644 --- a/mdl/executor/cmd_import_mappings.go +++ b/mdl/executor/cmd_import_mappings.go @@ -394,6 +394,19 @@ func execCreateImportMapping(ctx *ExecContext, s *ast.CreateImportMappingStmt) e if err := checkNoWebServiceSource("import", s.Name.String(), existing.WebServiceSource); err != nil { return err } + // Nor are an XML-schema mapping's root element and element paths: its + // own description replays as Unchanged, and any other rewrite is refused + // rather than written without them (mendixlabs/mxcli#1623). + unchanged, err := importMappingXmlSchemaRewrite(ctx, s, existing) + if err != nil { + return err + } + if unchanged { + if !ctx.Quiet { + reportUnchanged(ctx, "import mapping "+s.Name.String()) + } + return nil + } } module, err := findModule(ctx, s.Name.Module) diff --git a/mdl/executor/import_mapping_xml_schema_1555_test.go b/mdl/executor/import_mapping_xml_schema_1555_test.go new file mode 100644 index 0000000000..d7b0600cbc --- /dev/null +++ b/mdl/executor/import_mapping_xml_schema_1555_test.go @@ -0,0 +1,276 @@ +// 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/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// xmlOrderMapping is an import mapping over an XML schema as a Studio Pro +// import leaves it: a root element name and an XmlPath on every element, with +// a microflow resolving the nested object — the shape of mendixlabs/mxcli#1623. +// MDL can state none of the XML half, and exec has no XSD to derive it from. +func xmlOrderMapping(containerID model.ID) *model.ImportMapping { + value := func(attr, name, path string) *model.ImportMappingElement { + return &model.ImportMappingElement{ + BaseElement: model.BaseElement{ID: nextID("ve"), TypeName: "ImportMappings$ValueMappingElement"}, + Kind: "Value", Attribute: attr, DataType: "String", + ExposedName: name, XmlPath: path, MinOccurs: 1, MaxOccurs: 1, + } + } + return &model.ImportMapping{ + BaseElement: model.BaseElement{ID: nextID("im")}, + ContainerID: containerID, + Name: "IMM_Order", + XmlSchema: "XM.XS_Order", + RootElementName: "Order", + Elements: []*model.ImportMappingElement{{ + BaseElement: model.BaseElement{ID: nextID("oe"), TypeName: "ImportMappings$ObjectMappingElement"}, + Kind: "Object", Entity: "XM.Order", ObjectHandling: "Create", ObjectHandlingBackup: "Create", + ExposedName: "Order", XmlPath: "Order", MinOccurs: 1, MaxOccurs: 1, + Children: []*model.ImportMappingElement{ + value("XM.Order.Number", "Number", "Order|Number"), + { + BaseElement: model.BaseElement{ID: nextID("oe"), TypeName: "ImportMappings$ObjectMappingElement"}, + Kind: "Object", Entity: "XM.Customer", Association: "XM.Customer_Order", + ObjectHandling: "Custom", ObjectHandlingBackup: "Create", + CustomHandler: &model.MappingMicroflowCall{ + Microflow: "XM.MF_GetByKey", + Parameters: []*model.MappingMicroflowParameter{ + {Parameter: "XM.MF_GetByKey.Key", Source: "parent", LevelOfParent: -1}, + }, + }, + ExposedName: "Customer", XmlPath: "Order|Customer", MinOccurs: 0, MaxOccurs: 1, + Children: []*model.ImportMappingElement{ + value("XM.Customer.Key", "Key", "Order|Customer|Key"), + value("XM.Customer.Name", "Name", "Order|Customer|Name"), + }, + }, + }, + }}, + } +} + +// xmlMappingBackend serves one stored import mapping and records every write. +func xmlMappingBackend(mod *model.Module, im *model.ImportMapping, written *[]*model.ImportMapping) *mock.MockBackend { + return &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetImportMappingByQualifiedNameFunc: func(module, name string) (*model.ImportMapping, error) { + return im, nil + }, + ListImportMappingsFunc: func() ([]*model.ImportMapping, error) { return []*model.ImportMapping{im}, nil }, + UpdateImportMappingFunc: func(w *model.ImportMapping) error { + *written = append(*written, w) + return nil + }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { + return []*microflows.Microflow{{ContainerID: mod.ID, Name: "MF_GetByKey"}}, nil + }, + } +} + +// droppedXmlState names what a written mapping lost against the stored one. +func droppedXmlState(stored, written *model.ImportMapping) []string { + var lost []string + if stored.RootElementName != "" && written.RootElementName != stored.RootElementName { + lost = append(lost, "root element "+stored.RootElementName) + } + want, got := importXmlPathsOf(stored.Elements), importXmlPathsOf(written.Elements) + if strings.Join(want, ",") != strings.Join(got, ",") { + lost = append(lost, "XmlPaths "+strings.Join(want, ",")+" became "+strings.Join(got, ",")) + } + return lost +} + +func importXmlPathsOf(es []*model.ImportMappingElement) []string { + var out []string + for _, e := range es { + out = append(out, e.XmlPath) + out = append(out, importXmlPathsOf(e.Children)...) + } + return out +} + +// execImportMappingScript parses src and runs its one import mapping statement. +func execImportMappingScript(t *testing.T, ctx *ExecContext, src string) error { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + for _, stmt := range prog.Statements { + if s, ok := stmt.(*ast.CreateImportMappingStmt); ok { + return execCreateImportMapping(ctx, s) + } + } + t.Fatalf("no import mapping statement in:\n%s", src) + return nil +} + +// mendixlabs/mxcli#1623 (ako/mxcli#1555): describe → exec of an XML-schema +// import mapping rebuilt it from the statement, and the statement cannot carry +// the root element (CE0270 afterwards) or any element's XmlPath. Re-executing +// the mapping's own description must not write at all. +func TestCreateOrModifyImportMapping_XmlSchemaRedescriptionIsUnchanged(t *testing.T) { + mod := mkModule("XM") + im := xmlOrderMapping(mod.ID) + h := mkHierarchy(mod) + withContainer(h, im.ContainerID, mod.ID) + var written []*model.ImportMapping + ctx, buf := newMockCtx(t, withBackend(xmlMappingBackend(mod, im, &written)), withHierarchy(h)) + + assertNoError(t, describeImportMapping(ctx, ast.QualifiedName{Module: "XM", Name: "IMM_Order"})) + described := buf.String() + if !strings.Contains(described, "by XM.MF_GetByKey(Key: parent)") { + t.Fatalf("description lost the microflow key:\n%s", described) + } + + buf.Reset() + if err := execImportMappingScript(t, ctx, described); err != nil { + t.Fatalf("re-executing the description: %v\n%s", err, described) + } + for _, w := range written { + t.Errorf("re-executing the description rewrote the mapping, losing: %s", + strings.Join(droppedXmlState(im, w), "; ")) + } + if out := buf.String(); !strings.Contains(out, "Unchanged import mapping XM.IMM_Order") { + t.Errorf("want an Unchanged report, got:\n%s", out) + } +} + +// An edit to an XML-schema mapping cannot be written faithfully — the rebuild +// has no XmlPath for any element and no root element name — so it is refused, +// naming what would be lost, rather than written as a mapping mxbuild rejects. +func TestCreateOrModifyImportMapping_XmlSchemaEditIsRefused(t *testing.T) { + mod := mkModule("XM") + im := xmlOrderMapping(mod.ID) + h := mkHierarchy(mod) + withContainer(h, im.ContainerID, mod.ID) + var written []*model.ImportMapping + ctx, buf := newMockCtx(t, withBackend(xmlMappingBackend(mod, im, &written)), withHierarchy(h)) + + assertNoError(t, describeImportMapping(ctx, ast.QualifiedName{Module: "XM", Name: "IMM_Order"})) + edited := strings.Replace(buf.String(), "Name = Name", "Name = Key", 1) + + err := execImportMappingScript(t, ctx, edited) + if err == nil { + t.Fatalf("an edited XML-schema mapping was accepted:\n%s", edited) + } + for _, want := range []string{"XM.IMM_Order", "xml schema", "root element", "CE0270"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal should mention %q, got: %v", want, err) + } + } + for _, w := range written { + t.Errorf("the edit was written, losing: %s", strings.Join(droppedXmlState(im, w), "; ")) + } +} + +// Control: an XML-schema mapping that carries no XML state — the shape mxcli +// itself writes — loses nothing on a rebuild, so its rewrite is not refused. +func TestCreateOrModifyImportMapping_XmlSchemaWithoutStoredPathsStillRewrites(t *testing.T) { + mod := mkModule("XM") + im := xmlOrderMapping(mod.ID) + im.RootElementName = "" + var clear func([]*model.ImportMappingElement) + clear = func(es []*model.ImportMappingElement) { + for _, e := range es { + e.XmlPath = "" + clear(e.Children) + } + } + clear(im.Elements) + if storesXmlSchemaState(im.RootElementName, importXmlPaths(im.Elements)) { + t.Fatal("control: a mapping with no root and no XmlPath reports stored XML state") + } + if !storesXmlSchemaState("Order", nil) || !storesXmlSchemaState("", []string{"Order"}) { + t.Fatal("a stored root or XmlPath must count as XML state") + } +} + +// The export twin: its writer hardcoded the same empty root, so the same replay +// of a Studio Pro export mapping over an XML schema lost it too. +func TestCreateOrModifyExportMapping_XmlSchemaRedescriptionIsUnchanged(t *testing.T) { + mod := mkModule("XM") + em := &model.ExportMapping{ + BaseElement: model.BaseElement{ID: nextID("em")}, + ContainerID: mod.ID, + Name: "EXM_Order", + XmlSchema: "XM.XS_Order", + RootElementName: "Order", + Elements: []*model.ExportMappingElement{{ + BaseElement: model.BaseElement{ID: nextID("oe"), TypeName: "ExportMappings$ObjectMappingElement"}, + Kind: "Object", Entity: "XM.Order", ObjectHandling: "Parameter", + ExposedName: "Order", XmlPath: "Order", MinOccurs: 1, MaxOccurs: 1, + Children: []*model.ExportMappingElement{{ + BaseElement: model.BaseElement{ID: nextID("ve"), TypeName: "ExportMappings$ValueMappingElement"}, + Kind: "Value", Attribute: "XM.Order.Number", DataType: "String", + ExposedName: "Number", XmlPath: "Order|Number", MinOccurs: 1, MaxOccurs: 1, + }}, + }}, + } + h := mkHierarchy(mod) + withContainer(h, em.ContainerID, mod.ID) + var written []*model.ExportMapping + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetExportMappingByQualifiedNameFunc: func(module, name string) (*model.ExportMapping, error) { + return em, nil + }, + ListExportMappingsFunc: func() ([]*model.ExportMapping, error) { return []*model.ExportMapping{em}, nil }, + UpdateExportMappingFunc: func(w *model.ExportMapping) error { + written = append(written, w) + return nil + }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + + assertNoError(t, describeExportMapping(ctx, ast.QualifiedName{Module: "XM", Name: "EXM_Order"})) + described := buf.String() + + run := func(src string) error { + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + for _, stmt := range prog.Statements { + if s, ok := stmt.(*ast.CreateExportMappingStmt); ok { + return execCreateExportMapping(ctx, s) + } + } + t.Fatalf("no export mapping statement in:\n%s", src) + return nil + } + + buf.Reset() + if err := run(described); err != nil { + t.Fatalf("re-executing the description: %v\n%s", err, described) + } + if len(written) != 0 { + t.Errorf("re-executing the description rewrote the export mapping (root %q -> %q)", + em.RootElementName, written[0].RootElementName) + } + if out := buf.String(); !strings.Contains(out, "Unchanged export mapping XM.EXM_Order") { + t.Errorf("want an Unchanged report, got:\n%s", out) + } + + edited := strings.Replace(described, "Number", "Total", 1) + if edited == described { + t.Fatalf("could not edit the description:\n%s", described) + } + if err := run(edited); err == nil || !strings.Contains(err.Error(), "CE0270") { + t.Errorf("an edited XML-schema export mapping must be refused, got: %v", err) + } + if len(written) != 0 { + t.Errorf("a refused export statement wrote the mapping") + } +} diff --git a/mdl/executor/mapping_xml_schema_guard.go b/mdl/executor/mapping_xml_schema_guard.go new file mode 100644 index 0000000000..a6cd9db946 --- /dev/null +++ b/mdl/executor/mapping_xml_schema_guard.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Guard-don't-drop for a mapping over an XML SCHEMA. +// +// `with xml schema M.S` names the schema and nothing else of the XML half. A +// mapping Studio Pro built over one also stores the schema element it is rooted +// at (XsdRootElementName) and, on every element, the schema path it binds to +// (XmlPath). MDL has no words for either, and mxcli does not read an XSD, so +// exec has nothing to derive them from: a rebuild wrote XsdRootElementName "" +// and an empty XmlPath on every element, with a JsonPath the schema does not +// have. Replaying a module's description therefore turned a working XML-schema +// mapping into CE0270 "No root element could be found in the schema" +// (mendixlabs/mxcli#1623) — the statement the user ran was their own describe +// output, which changes nothing. +// +// So there are two answers, and neither is a rebuild: +// +// - a statement that IS the stored mapping's description is Unchanged, and +// nothing is written — the describe → exec of a module; +// - anything else is refused, naming what a rewrite would delete. The +// precedent is the imported-web-service source (checkNoWebServiceSource): +// carrying the XML half through a rebuild would mean claiming the rebuilt +// elements still line up with the stored paths, which nothing here can +// check without the XSD. +// +// A mapping with no stored XML state — the shape mxcli itself writes, root and +// paths empty — loses nothing on a rebuild and is rewritten as before. +package executor + +import ( + "bytes" + "fmt" + "reflect" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// storesXmlSchemaState reports whether a mapping carries XML state a rebuild +// would delete: a root element name, or any element's XmlPath. +func storesXmlSchemaState(root string, xmlPaths []string) bool { + return root != "" || len(xmlPaths) > 0 +} + +// importXmlPaths lists the non-empty XmlPaths of an import mapping's elements. +func importXmlPaths(es []*model.ImportMappingElement) []string { + var out []string + for _, e := range es { + if e.XmlPath != "" { + out = append(out, e.XmlPath) + } + out = append(out, importXmlPaths(e.Children)...) + } + return out +} + +// exportXmlPaths is importXmlPaths for an export mapping. +func exportXmlPaths(es []*model.ExportMappingElement) []string { + var out []string + for _, e := range es { + if e.XmlPath != "" { + out = append(out, e.XmlPath) + } + out = append(out, exportXmlPaths(e.Children)...) + } + return out +} + +// importMappingXmlSchemaRewrite decides a `create or modify` of an existing +// import mapping over an XML schema. unchanged is true when the statement is +// the stored mapping's own description, and the caller writes nothing; a +// non-nil error refuses any other rewrite of one that carries XML state. +func importMappingXmlSchemaRewrite(ctx *ExecContext, s *ast.CreateImportMappingStmt, + existing *model.ImportMapping, +) (unchanged bool, err error) { + paths := importXmlPaths(existing.Elements) + if existing.XmlSchema == "" || !storesXmlSchemaState(existing.RootElementName, paths) { + return false, nil + } + if described, ok := describedMappingStmt(ctx, func() error { return describeImportMapping(ctx, s.Name) }); ok { + if d, ok := described.(*ast.CreateImportMappingStmt); ok && sameImportMappingStmt(s, d) { + return true, nil + } + } + return false, xmlSchemaRewriteRefusal("import", s.Name.String(), existing.XmlSchema, existing.RootElementName, len(paths)) +} + +// exportMappingXmlSchemaRewrite is importMappingXmlSchemaRewrite for an export +// mapping, whose writer hardcoded the same empty root. +func exportMappingXmlSchemaRewrite(ctx *ExecContext, s *ast.CreateExportMappingStmt, + existing *model.ExportMapping, +) (unchanged bool, err error) { + paths := exportXmlPaths(existing.Elements) + if existing.XmlSchema == "" || !storesXmlSchemaState(existing.RootElementName, paths) { + return false, nil + } + if described, ok := describedMappingStmt(ctx, func() error { return describeExportMapping(ctx, s.Name) }); ok { + if d, ok := described.(*ast.CreateExportMappingStmt); ok && sameExportMappingStmt(s, d) { + return true, nil + } + } + return false, xmlSchemaRewriteRefusal("export", s.Name.String(), existing.XmlSchema, existing.RootElementName, len(paths)) +} + +// describedMappingStmt runs a mapping describer in the script's own language +// and parses what it printed, so the stored side and the declared side are read +// by the same rules — the approach describedFlowStmt takes for a flow. ok is +// false when the description cannot be produced or does not parse, which the +// caller treats as "not the same statement". +func describedMappingStmt(ctx *ExecContext, describe func() error) (ast.Statement, bool) { + var buf bytes.Buffer + prevOut, prevIn := ctx.Output, ctx.describeIn + script := ctx.LanguageVersion + ctx.Output, ctx.describeIn = &buf, &script + err := describe() + src := describedSource(ctx, buf.String()) + ctx.Output, ctx.describeIn = prevOut, prevIn + if err != nil { + return nil, false + } + prog, errs := visitor.Build(src) + if len(errs) > 0 { + return nil, false + } + for _, st := range prog.Statements { + switch st.(type) { + case *ast.CreateImportMappingStmt, *ast.CreateExportMappingStmt: + return st, true + } + } + return nil, false +} + +// sameImportMappingStmt reports whether a statement states exactly what the +// stored mapping's description does. A clause the statement leaves out that +// means "keep what is stored" — folder, doc comment, @excluded — is taken from +// the description, so omitting it is not a difference. +func sameImportMappingStmt(s, described *ast.CreateImportMappingStmt) bool { + a, b := *s, *described + a.CreateGuard, a.CreateOrModify = b.CreateGuard, b.CreateOrModify + if a.Folder == "" { + a.Folder = b.Folder + } + if !a.DocumentationSet { + a.Documentation, a.DocumentationSet = b.Documentation, b.DocumentationSet + } + if !a.Excluded { + a.Excluded = b.Excluded + } + return reflect.DeepEqual(a, b) +} + +// sameExportMappingStmt is sameImportMappingStmt for an export mapping. +func sameExportMappingStmt(s, described *ast.CreateExportMappingStmt) bool { + a, b := *s, *described + a.CreateGuard, a.CreateOrModify = b.CreateGuard, b.CreateOrModify + if a.Folder == "" { + a.Folder = b.Folder + } + if !a.DocumentationSet { + a.Documentation, a.DocumentationSet = b.Documentation, b.DocumentationSet + } + if !a.Excluded { + a.Excluded = b.Excluded + } + return reflect.DeepEqual(a, b) +} + +// xmlSchemaRewriteRefusal is the message for a rewrite that would delete an +// XML-schema mapping's XML half. +func xmlSchemaRewriteRefusal(kind, qualifiedName, schema, root string, paths int) error { + what := fmt.Sprintf("the schema path of its %d element(s)", paths) + if root != "" { + what = fmt.Sprintf("its root element %q and %s", root, what) + } + return mdlerrors.NewValidation(fmt.Sprintf( + "%s mapping %s is over the xml schema %s, and MDL cannot state the XML half of it: "+ + "%s. A rewrite from this statement would write none of them — mxcli does not read the "+ + "XSD to derive them — and mxbuild then reports CE0270 \"No root element could be found "+ + "in the schema\". Re-executing the mapping's own describe output is accepted and changes "+ + "nothing; to change the mapping itself, edit it in Studio Pro", + kind, qualifiedName, schema, what)) +} diff --git a/model/types.go b/model/types.go index a075ca33fb..e09aaa7cf3 100644 --- a/model/types.go +++ b/model/types.go @@ -1237,6 +1237,13 @@ type ImportMapping struct { // one: the properties are read so a rewrite can be refused rather than // silently dropping the binding (ako/mxcli#365). WebServiceSource WebServiceMappingSource `json:"webServiceSource,omitempty"` + // RootElementName is the schema element an XML-schema (or SOAP) mapping is + // rooted at, stored as XsdRootElementName. MDL cannot state it and mxcli does + // not read an XSD, so it is carried, never derived: a rewrite that wrote "" + // left the mapping with CE0270 "No root element could be found in the + // schema" (mendixlabs/mxcli#1623). Empty for a JSON or message-definition + // mapping. + RootElementName string `json:"rootElementName,omitempty"` // ParameterEntity is the entity of the mapping's INPUT object, stored as // ParameterType — a DataTypes$ObjectType naming it. Empty means the mapping // takes none, which Mendix stores as the DataTypes$UnknownType marker rather @@ -1495,7 +1502,10 @@ type ExportMapping struct { // one: the properties are read so a rewrite can be refused rather than // silently dropping the binding (ako/mxcli#365). WebServiceSource WebServiceMappingSource `json:"webServiceSource,omitempty"` - Elements []*ExportMappingElement `json:"elements,omitempty"` + // RootElementName is the XML schema element the mapping produces, stored + // as XsdRootElementName — see ImportMapping.RootElementName. + RootElementName string `json:"rootElementName,omitempty"` + Elements []*ExportMappingElement `json:"elements,omitempty"` } // GetName returns the export mapping's name. diff --git a/modelsdk/gen/exportmappings/types.go b/modelsdk/gen/exportmappings/types.go index 420ebe70f7..910a7189da 100644 --- a/modelsdk/gen/exportmappings/types.go +++ b/modelsdk/gen/exportmappings/types.go @@ -863,7 +863,12 @@ func initExportMapping() *ExportMapping { o.xmlSchema.Bind(&o.Base, 5) o.jsonStructure = property.NewByNameRef[element.Element]("JsonStructure", "JsonStructures$JsonStructure") o.jsonStructure.Bind(&o.Base, 6) - o.rootElementName = property.NewPrimitive[string]("RootElementName", property.DecodeString) + // STORAGE-NAME OVERRIDE: BSON key is "XsdRootElementName", not the SDK name + // "RootElementName" — mxbuild's own metadata pairs StorageName + // XsdRootElementName with SdkName rootElementName. Read under the SDK name, an + // XML-schema mapping's root was invisible, so nothing could notice a rewrite + // deleting it (CE0270, mendixlabs/mxcli#1623). + o.rootElementName = property.NewPrimitive[string]("XsdRootElementName", property.DecodeString) o.rootElementName.Bind(&o.Base, 7) o.importedWebService = property.NewByNameRef[element.Element]("ImportedWebService", "WebServices$ImportedWebService") o.importedWebService.Bind(&o.Base, 8) diff --git a/modelsdk/gen/importmappings/types.go b/modelsdk/gen/importmappings/types.go index 0c43e42681..520ea0e124 100644 --- a/modelsdk/gen/importmappings/types.go +++ b/modelsdk/gen/importmappings/types.go @@ -853,7 +853,12 @@ func initImportMapping() *ImportMapping { o.xmlSchema.Bind(&o.Base, 5) o.jsonStructure = property.NewByNameRef[element.Element]("JsonStructure", "JsonStructures$JsonStructure") o.jsonStructure.Bind(&o.Base, 6) - o.rootElementName = property.NewPrimitive[string]("RootElementName", property.DecodeString) + // STORAGE-NAME OVERRIDE: BSON key is "XsdRootElementName", not the SDK name + // "RootElementName" — mxbuild's own metadata pairs StorageName + // XsdRootElementName with SdkName rootElementName. Read under the SDK name, an + // XML-schema mapping's root was invisible, so nothing could notice a rewrite + // deleting it (CE0270, mendixlabs/mxcli#1623). + o.rootElementName = property.NewPrimitive[string]("XsdRootElementName", property.DecodeString) o.rootElementName.Bind(&o.Base, 7) o.importedWebService = property.NewByNameRef[element.Element]("ImportedWebService", "WebServices$ImportedWebService") o.importedWebService.Bind(&o.Base, 8) diff --git a/modelsdk/gen/keyaudit_test.go b/modelsdk/gen/keyaudit_test.go index 7d3707fd99..0d1a6e42e0 100644 --- a/modelsdk/gen/keyaudit_test.go +++ b/modelsdk/gen/keyaudit_test.go @@ -129,11 +129,9 @@ var knownWrongKeys = []keyMismatch{ {"DomainModels$Attribute", "DataStorageGuid", "GUID"}, {"DomainModels$CrossAssociation", "DataStorageGuid", "GUID"}, {"DomainModels$EqualsToRuleInfo", "EqualsToValue", "Value"}, - {"ExportMappings$ExportMapping", "RootElementName", "XsdRootElementName"}, {"ExportMappings$ExportMapping", "ImportedWebService", "WsdlFile"}, {"ExportMappings$ExportMapping", "IsHeader", "IsHeaderParameter"}, {"Images$Image", "ImageData", "Image"}, - {"ImportMappings$ImportMapping", "RootElementName", "XsdRootElementName"}, {"ImportMappings$ImportMapping", "ImportedWebService", "WsdlFile"}, {"JavaActions$JavaAction", "ActionTypeParameters", "TypeParameters"}, {"JavaActions$JavaAction", "ActionReturnType", "JavaReturnType"}, From 1dd785594e764bc94b274d49601b405dfb2d2341 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:16:33 +0000 Subject: [PATCH 37/50] =?UTF-8?q?docs(wiki):=20describe-round-trip-gaps=20?= =?UTF-8?q?=E2=80=94=20replaying=20a=20description=20as=20a=20no-op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two shapes of mendixlabs/mxcli#1623 and #1627 that a field-level carry cannot handle, and the rule for a member another statement declares. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017jEcJZDio1jrTrWtVzagjY --- docs-wiki/bug-patterns/describe-round-trip-gaps.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs-wiki/bug-patterns/describe-round-trip-gaps.md b/docs-wiki/bug-patterns/describe-round-trip-gaps.md index f10f8a40a1..ca35ed72c1 100644 --- a/docs-wiki/bug-patterns/describe-round-trip-gaps.md +++ b/docs-wiki/bug-patterns/describe-round-trip-gaps.md @@ -145,6 +145,18 @@ wired into `create or modify` alone was missed by `alter … modify attribute` rebuilding the same element through the same converter. Grep the **converter's** call sites, not the reported statement. +**Where a field-by-field carry cannot be trusted, make replaying the description +a no-op.** If the statement parses to exactly what the stored document's own +description parses to, it denotes the stored document — keep the stored bytes +and report `Unchanged` — and refuse any other rewrite that would have to invent +what the language cannot say. An XML-schema mapping's root element and element +paths (no XSD reader to rebuild them) and a view query's whitespace outside its +token span both take this form. Compute "what the description parses to" with +the real describer and parser, never a model of them, or the two drift. And a +member another statement *declares* — a view entity's association, declared by +its OQL column — must be skipped by every describer of its kind, or the +description re-declares it in a form the platform refuses. + **Present-but-empty is a stored value, and a readability fallback is a write.** A describer that falls back to another language when the preferred one is present but empty prints the Dutch caption into the English slot, and `exec` writes From a609edb91e29bd2a13f35537faa59f6aa719c425 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:32:09 +0000 Subject: [PATCH 38/50] fix: check refuses an unknown alter settings section or key, as exec does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mxcli check -p` passed `alter settings nosuchsection (Foo: 1)`, any misspelled key in a known section, and a verb the section does not define (`workflows add 'x'`, `language add group 'g'`); only exec refused them, after the statements before had run. One project-independent validator, settingsStatementErrors, now decides which sections and keys exist (settingsSectionKeys). exec calls it before reading the project, and check reports the same errors as MDL-SET04 with exec's message and the valid keys as the suggestion — so the two cannot disagree. CREATE CONFIGURATION shares the configuration key list and message ("unknown configuration setting", not "property"). Control: with settingsStatementErrors stubbed to nil, the PedApp check/exec agreement test reports check silent and exec refusing; both new .fail.mdl scripts pass `check` on the previous build. Part of mendixlabs/mxcli#1549 (ako/mxcli#1447). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- ...passes-unknown-alter-settings-section.json | 1 + .../skills/mendix/project-settings/SKILL.md | 12 +- .../src/reference/settings/alter-settings.md | 4 + docs-site/src/tools/builtin-rules.md | 1 + .../1549-alter-settings-unknown-key.fail.mdl | 20 +++ ...49-alter-settings-unknown-section.fail.mdl | 25 ++++ ...eck_exec_agreement_settings_pedapp_test.go | 67 +++++++++ mdl/executor/cmd_settings.go | 55 +++++--- mdl/executor/cmd_settings_validation_test.go | 33 ++++- mdl/executor/validate_settings.go | 133 ++++++++++++++++-- 10 files changed, 315 insertions(+), 36 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-passes-unknown-alter-settings-section.json create mode 100644 mdl-examples/bug-tests/1549-alter-settings-unknown-key.fail.mdl create mode 100644 mdl-examples/bug-tests/1549-alter-settings-unknown-section.fail.mdl create mode 100644 mdl/executor/check_exec_agreement_settings_pedapp_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-passes-unknown-alter-settings-section.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-passes-unknown-alter-settings-section.json new file mode 100644 index 0000000000..d994d61b7b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-22-check-passes-unknown-alter-settings-section.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-22", "symptom": "`mxcli check nosuchsection.mdl -p App.mpr` → `Check passed!` for `alter settings nosuchsection (Foo: 1);` (and `alter settings webui (…)`, and any misspelled key such as `ExtraJvmParams`), then `mxcli exec` → `Error: unknown settings section: nosuchsection (expected model, configuration, constant, LANGUAGE, or workflows)` — a script passed the gate and stopped halfway through", "cause": "The grammar takes any IDENTIFIER as a settings section, and the only refusals of an unknown section, an unknown key, or a verb the section does not define (`workflows add 'x'`, `language add group`) lived in alterSettings' switch defaults. ValidateSettings only checked typed values and returned nil for a section without typed keys, so check had nothing to report", "file": "mdl/executor/validate_settings.go (settingsSectionKeys, settingsStatementErrors, unknownSettingsSection/Key/Keys, settingsStatementViolations — MDL-SET04; ValidateSettings, ValidateCreateConfiguration); mdl/executor/cmd_settings.go (alterSettings and createConfiguration call it first; configurationSettingKeys); tests mdl/executor/check_exec_agreement_settings_pedapp_test.go, TestSettingsSectionKeys_AllAccepted", "insight": "Make the key list the gate, not a mirror: exec calls the same project-independent function check reports (settingsStatementErrors) BEFORE reading the project, so a key given a switch case but not listed is refused by exec too and fails the feature's own test instead of a user's script — a mirrored table (like typedSettingsKeys) only stays honest via a drift test. The violation message is exec's first line verbatim; the valid-keys tail becomes the suggestion. Control: stubbing settingsStatementErrors to nil brings back `check` silent / exec refusing on the PedApp agreement test, and both .fail.mdl scripts pass `check` on the main build. createConfiguration said \"unknown configuration property\" where alter said \"setting\" — one message now. Section list in the message names `runtime` (MODEL is the deprecated alias) and webui. Scanned docs/skills/examples for statements using a key outside the lists: none (check-mdl 990 pass, check-skill-mdl pass). Check/exec disagreement recurs (~47 findings mention it) with no bug-patterns page of its own", "refs": ["mendixlabs/mxcli#1549", "ako/mxcli#1447"], "rules": ["MDL-SET04"]} diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index de440828d8..6020e8701b 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-32 | When to Use This Skill | -| 33-337 | Commands | -| 338-365 | Common Patterns | -| 366-374 | Checklist | -| 375-379 | AfterStartupMicroflow Must Return Boolean | +| 33-339 | Commands | +| 340-367 | Common Patterns | +| 368-376 | Checklist | +| 377-381 | AfterStartupMicroflow Must Return Boolean | # Project Settings @@ -154,7 +154,9 @@ stored in Mendix's own spelling (MDL-SET03 rejects a non-member rather than writ it through — an unresolvable enum value is what makes Studio Pro throw "Sequence contains no matching element"). An unparseable value is rejected by `mxcli check` (MDL-SET01 / MDL-SET02) -and by the write itself — it is no longer silently ignored. Quoted numbers are +and by the write itself — it is no longer silently ignored. A section or key that +does not exist is rejected by `mxcli check` too (MDL-SET04), with exec's message +and the list of valid keys. Quoted numbers are fine: `HttpPortNumber = '8080'` and `HttpPortNumber = 8080` are equivalent. ### Constant Overrides diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index d659aba12a..6a7af79777 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -49,6 +49,10 @@ the React client, `'No'` the classic (Dojo) client, `'MigrationMode'` Mendix's mixed mode. The value is matched case-insensitively; anything else is rejected by `mxcli check` (MDL-SET03) and by the executor. +A section or key that does not exist is rejected by `mxcli check` (MDL-SET04) with +the message the executor gives, so a script that cannot run does not pass the +check. + ## Parameters **key** diff --git a/docs-site/src/tools/builtin-rules.md b/docs-site/src/tools/builtin-rules.md index 8a7f499bde..259ded72d6 100644 --- a/docs-site/src/tools/builtin-rules.md +++ b/docs-site/src/tools/builtin-rules.md @@ -100,5 +100,6 @@ These rules run with `mxcli check` (and the LSP, for real-time diagnostics) rath | **MDL-SET01** | `mxcli check` + LSP | Non-integer value for an Integer-typed project setting (`HttpPortNumber`, `ServerPortNumber`, `BcryptCost`, `DefaultTaskParallelism`, `WorkflowEngineParallelism`). These used to be skipped silently while the statement still reported success. | | **MDL-I18N01** | `mxcli check -p` | A script that changes `DefaultLanguageCode` leaves a tab page caption — stored in the project, or created by the script before the change — with no text in the new default. MxBuild reports CE4899 "Empty caption". See [Error Messages](../appendixes/error-messages.md#mdl-i18n01-a-tab-page-caption-without-the-default-language-ce4899). | | **MDL-SET02** | `mxcli check` + LSP | Value other than `true` / `false` for a Boolean-typed project setting (`AllowUserMultipleSessions`). Anything else was silently stored as `false`. | +| **MDL-SET04** | `mxcli check` + LSP | `ALTER SETTINGS` section or key that does not exist (`alter settings nosuchsection (…)`, a misspelled `ExtraJvmParams`), or a verb the section does not define. Reported with the message `exec` refuses it with, and the valid keys; `check` used to pass these and leave `exec` to stop halfway through the script. | Run `mxcli check --help` for usage. See [Error Messages → MDL-WIDGET01 / MDL-WIDGET02](../appendixes/error-messages.md#mdl-widget01-unknown-pluggable-widget-property) for cause-and-solution detail. diff --git a/mdl-examples/bug-tests/1549-alter-settings-unknown-key.fail.mdl b/mdl-examples/bug-tests/1549-alter-settings-unknown-key.fail.mdl new file mode 100644 index 0000000000..c22bd45ab2 --- /dev/null +++ b/mdl-examples/bug-tests/1549-alter-settings-unknown-key.fail.mdl @@ -0,0 +1,20 @@ +mdl 1; +-- Bug mendixlabs/mxcli#1549 (ako/mxcli#1447): `check` passed any key in a known +-- `alter settings` section that `exec` then refused +-- +-- NEGATIVE TEST (.fail.mdl) — this script is EXPECTED to fail `mxcli check`. +-- `make check-mdl` inverts the exit code for .fail.mdl files: an unexpected pass +-- is a regression of MDL-SET04. +-- +-- The same check/exec mismatch as an unknown section, one level down: a +-- misspelled key passed `check` and exec answered "unknown configuration +-- setting". It is how mendixlabs/mxcli#131 presented, with a key mxcli did not +-- know yet. +-- +-- Expected: `mxcli check` exits non-zero with +-- unknown configuration setting: ExtraJvmParams [MDL-SET04] +-- → valid keys: DatabaseType, …, ExtraJvmParameters + +alter settings configuration 'Default' ( + ExtraJvmParams: '-Dhttps.proxyHost=127.0.0.1' +); diff --git a/mdl-examples/bug-tests/1549-alter-settings-unknown-section.fail.mdl b/mdl-examples/bug-tests/1549-alter-settings-unknown-section.fail.mdl new file mode 100644 index 0000000000..57192015d3 --- /dev/null +++ b/mdl-examples/bug-tests/1549-alter-settings-unknown-section.fail.mdl @@ -0,0 +1,25 @@ +mdl 1; +-- Bug mendixlabs/mxcli#1549 (ako/mxcli#1447): `check` passed any `alter settings` +-- section that `exec` then refused +-- +-- NEGATIVE TEST (.fail.mdl) — this script is EXPECTED to fail `mxcli check`. +-- `make check-mdl` inverts the exit code for .fail.mdl files: an unexpected pass +-- is a regression of MDL-SET04. +-- +-- Symptom: +-- $ mxcli check nosuchsection.mdl -p App.mpr +-- Check passed! +-- $ mxcli exec nosuchsection.mdl -p App.mpr +-- Error: unknown settings section: nosuchsection (expected model, configuration, constant, LANGUAGE, or workflows) +-- +-- The grammar takes any identifier as a settings section, and ValidateSettings +-- returned nil for a section it had no typed keys for, so the only refusal was +-- exec's — after the statements before it had run. +-- +-- Fix: one project-independent validator (settingsStatementErrors) that exec +-- calls first and check reports as MDL-SET04, so the two cannot disagree. +-- +-- Expected: `mxcli check` exits non-zero with +-- unknown settings section: nosuchsection (expected runtime, configuration, constant, language, workflows, or webui) [MDL-SET04] + +alter settings nosuchsection (Foo: 1); diff --git a/mdl/executor/check_exec_agreement_settings_pedapp_test.go b/mdl/executor/check_exec_agreement_settings_pedapp_test.go new file mode 100644 index 0000000000..938ec15e75 --- /dev/null +++ b/mdl/executor/check_exec_agreement_settings_pedapp_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// mendixlabs/mxcli#1549 (ako/mxcli#1447): `check -p` passed `alter settings +// ` — and any key in a section it did know — and only exec refused +// it, so a script could pass the gate and then stop halfway through. Each case +// must now be refused by both, with the message exec gives. + +func TestCheckExecAgree_AlterSettingsUnknownSection(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + // The two scripts as reported. + assertAgree(t, exec, dir, "mdl 1;\nalter settings nosuchsection (Foo: 1);", + "unknown settings section: nosuchsection") + // Control: the section the report asked for now exists. + assertAgree(t, exec, dir, "mdl 1;\nalter settings webui (OptimizedClient: 'Yes');", "") +} + +func TestCheckExecAgree_AlterSettingsUnknownKey(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + for _, tc := range []struct{ src, want string }{ + {"mdl 1;\nalter settings runtime (OptimisticLocking: true);", "unknown model setting: OptimisticLocking"}, + {"mdl 1;\nalter settings configuration 'Default' (ExtraJvmParams: '-Dx=1');", "unknown configuration setting: ExtraJvmParams"}, + {"mdl 1;\nalter settings language (DefaultLanguage: 'en_US');", "unknown language setting: DefaultLanguage"}, + {"mdl 1;\nalter settings workflows (UserEntityName: 'System.User');", "unknown workflow setting: UserEntityName"}, + {"mdl 1;\nalter settings webui (UseReactClient: 'Yes');", "unknown webui setting: UseReactClient"}, + {"mdl 1;\ncreate configuration 'Agree' (Port: 8085);", "unknown configuration setting: Port"}, + // A verb the section does not define. + {"mdl 1;\nalter settings workflows add 'de_DE';", "add/remove is only defined for the LANGUAGE section"}, + {"mdl 1;\nalter settings language add group 'Approvers';", "GROUP is only defined for the WORKFLOWS section"}, + } { + t.Run(tc.want, func(t *testing.T) { assertAgree(t, exec, dir, tc.src, tc.want) }) + } + // Controls: the real keys. + assertAgree(t, exec, dir, "mdl 1;\nalter settings configuration 'Default' (ExtraJvmParameters: '-Dx=1');", "") + assertAgree(t, exec, dir, "mdl 1;\nalter settings language (DefaultLanguageCode: 'en_US');", "") +} + +// Plain `check` — no project — catches it too: neither refusal depends on the +// project, and `make check-mdl` runs without one. +func TestValidateProgram_AlterSettingsUnknownSectionWithoutProject(t *testing.T) { + prog, errs := visitor.Build("mdl 1;\nalter settings webuii (OptimizedClient: 'Yes');") + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + var got []linter.Violation + for _, v := range ValidateProgram(prog, "") { + if v.Severity == linter.SeverityError { + got = append(got, v) + } + } + if len(got) != 1 || got[0].RuleID != "MDL-SET04" { + t.Fatalf("want one MDL-SET04 error, got %+v", got) + } + if !strings.HasPrefix(got[0].Message, "unknown settings section: webuii (expected ") || + !strings.Contains(got[0].Message, "webui") { + t.Errorf("message should be exec's, naming the sections that exist: %q", got[0].Message) + } +} diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index b7634a155c..b31b855bc8 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -362,6 +362,22 @@ var useOptimizedClientValues = []string{ string(metamodel.SettingsUseOptimizedClientMigrationMode), } +// configurationSettingKeys names every property ALTER SETTINGS CONFIGURATION and +// CREATE CONFIGURATION assign. DatabaseUseIntegratedSecurity, MaxJavaHeapSize and +// the Open*Port flags are read and written back as stored, but no statement sets +// them. +var configurationSettingKeys = []string{ + "DatabaseType", + "DatabaseUrl", + "DatabaseName", + "DatabaseUserName", + "DatabasePassword", + "HttpPortNumber", + "ServerPortNumber", + "ApplicationRootUrl", + "ExtraJvmParameters", +} + // webUISettingKeys names every property ALTER SETTINGS WEBUI assigns. // OptimizedClient is the spelling `show settings` prints and the skills point to; // it is stored as UseOptimizedClient. @@ -452,6 +468,13 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } + // The refusals that need no project — an unknown section or key, a verb the + // section does not define. `mxcli check` reports the same errors from the + // same function, so a script that cannot run does not pass the check + // (mendixlabs/mxcli#1549). + if errs := settingsStatementErrors(stmt); len(errs) > 0 { + return errs[0] + } ps, err := ctx.Backend.GetProjectSettings() if err != nil { @@ -494,20 +517,12 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { // on it, so they are dispatched before the key/value sections — as the // language forms below are, and for the same reason. if stmt.AddGroup || stmt.ModifyGroup || stmt.UpsertGroup || stmt.RemoveGroup { - if section != "workflows" { - return mdlerrors.NewUnsupported(fmt.Sprintf( - "GROUP is only defined for the WORKFLOWS section, not %s", stmt.Section)) - } return alterSettingsWorkflowGroup(ctx, ps, stmt) } // ADD/REMOVE change the list of ENABLED languages rather than a key on the // settings part, so they are dispatched before the key/value sections. if stmt.AddLanguage || stmt.ModifyLanguage || stmt.UpsertLanguage || stmt.RemoveLanguage { - if section != "language" { - return mdlerrors.NewUnsupported(fmt.Sprintf( - "add/remove is only defined for the LANGUAGE section, not %s", stmt.Section)) - } switch { case stmt.UpsertLanguage: return alterSettingsLanguageUpsert(ctx, ps, stmt) @@ -533,9 +548,7 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { // project", sending the reader after a version problem that does not // exist instead of at the misspelling in front of them. if !isKnownModelSetting(key) { - return mdlerrors.NewUnsupported(fmt.Sprintf( - "unknown model setting: %s\n valid keys: %s", - key, strings.Join(modelSettingKeys, ", "))) + return unknownSettingsKey("model", key) } // The overlay will not introduce a property the stored document does // not carry, so refuse here rather than reporting a success that @@ -617,9 +630,7 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { } ps.Model.UseOQLVersion2 = v default: - return mdlerrors.NewUnsupported(fmt.Sprintf( - "unknown model setting: %s\n valid keys: %s", - key, strings.Join(modelSettingKeys, ", "))) + return unknownSettingsKey("model", key) } } @@ -640,7 +651,7 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { ps.Language.DefaultLanguageCode = valStr default: - return mdlerrors.NewUnsupported("unknown language setting: " + key) + return unknownSettingsKey("language", key) } } @@ -666,7 +677,7 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { } ps.Workflows.WorkflowEngineParallelism = v default: - return mdlerrors.NewUnsupported("unknown workflow setting: " + key) + return unknownSettingsKey("workflows", key) } } @@ -693,8 +704,7 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { } ps.WebUI.UseOptimizedClient = v default: - return mdlerrors.NewUnsupported(fmt.Sprintf( - "unknown webui setting: %s\n valid keys: %s", key, strings.Join(webUISettingKeys, ", "))) + return unknownSettingsKey("webui", key) } } @@ -705,7 +715,7 @@ func alterSettings(ctx *ExecContext, stmt *ast.AlterSettingsStmt) error { return alterSettingsConstant(ctx, ps, stmt) default: - return mdlerrors.NewUnsupported(fmt.Sprintf("unknown settings section: %s (expected model, configuration, constant, LANGUAGE, workflows, or webui)", section)) + return unknownSettingsSection(section) } // Write updated settings @@ -818,7 +828,7 @@ func alterSettingsConfiguration(ctx *ExecContext, ps *model.ProjectSettings, stm // (mendixlabs/mxcli#131). cfg.ExtraJvmParameters = valStr default: - return mdlerrors.NewUnsupported("unknown configuration setting: " + key) + return unknownSettingsKey("configuration", key) } } @@ -945,6 +955,9 @@ func createConfiguration(ctx *ExecContext, stmt *ast.CreateConfigurationStmt) er if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } + if errs := unknownSettingsKeys("configuration", stmt.Properties); len(errs) > 0 { + return errs[0] + } ps, err := ctx.Backend.GetProjectSettings() if err != nil { @@ -1016,7 +1029,7 @@ func createConfiguration(ctx *ExecContext, stmt *ast.CreateConfigurationStmt) er case "ExtraJvmParameters": newCfg.ExtraJvmParameters = valStr default: - return mdlerrors.NewUnsupported("unknown configuration property: " + key) + return unknownSettingsKey("configuration", key) } } diff --git a/mdl/executor/cmd_settings_validation_test.go b/mdl/executor/cmd_settings_validation_test.go index 4e64ed95cf..58bf562f06 100644 --- a/mdl/executor/cmd_settings_validation_test.go +++ b/mdl/executor/cmd_settings_validation_test.go @@ -330,7 +330,8 @@ func TestValidateSettings_AcceptsValidAndUnknownSections(t *testing.T) { if len(valid) != 0 { t.Errorf("valid properties reported as violations: %+v", valid) } - // A section with no typed properties (or an unknown one) is not this rule's business. + // A known section with no typed properties reports nothing for a known key. (An + // unknown section or key is MDL-SET04: check_exec_agreement_settings_pedapp_test.go.) if got := ValidateSettings(&ast.AlterSettingsStmt{ Section: "language", Properties: map[string]any{"DefaultLanguageCode": "en_US"}, @@ -423,3 +424,33 @@ func TestTypedSettingsKeys_MatchExecutor(t *testing.T) { } } } + +// TestSettingsSectionKeys_AllAccepted is the drift guard for settingsSectionKeys, +// the list that decides which keys exist for check and exec alike: a listed key +// the executor's switch has no case for would pass check and be refused by exec, +// the mendixlabs/mxcli#1549 shape in the other direction. +func TestSettingsSectionKeys_AllAccepted(t *testing.T) { + for section, keys := range settingsSectionKeys { + for _, key := range keys { + t.Run(section+"/"+key, func(t *testing.T) { + val := "1" // a string property takes any text + switch typedSettingsKeys[section][key] { + case settingsKindBool: + val = "true" + case settingsKindDatabaseType: + val = "PostgreSql" + case settingsKindEnum: + val = settingsEnumValues[section+"/"+key][0] + } + wrote := false + ctx, _ := newMockCtx(t, withBackend(settingsBackend(&wrote))) + err := alterSettings(ctx, &ast.AlterSettingsStmt{ + Section: section, ConfigName: "Default", Properties: map[string]any{key: val}, + }) + if err != nil && strings.Contains(err.Error(), "unknown") { + t.Errorf("%s is listed for %s but the executor refuses it: %v", key, section, err) + } + }) + } + } +} diff --git a/mdl/executor/validate_settings.go b/mdl/executor/validate_settings.go index 2e07b5bc37..bfbcfdd761 100644 --- a/mdl/executor/validate_settings.go +++ b/mdl/executor/validate_settings.go @@ -4,10 +4,12 @@ package executor import ( "fmt" + "slices" "sort" "strings" "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/linter" ) @@ -53,23 +55,136 @@ var typedSettingsKeys = map[string]map[string]settingsValueKind{ }, } -// ValidateSettings reports ALTER SETTINGS values that will not parse as their -// property's type. The executor rejects them too, but reporting them at check time -// means a typo surfaces before the project is opened for writing (#805). +// settingsSectionKeys names, per key/value section of ALTER SETTINGS, every +// property the executor assigns. It is the gate for which keys exist: alterSettings +// refuses any other key before it reads the project, and `mxcli check` reports +// the same refusal from the same function (settingsStatementErrors), so the two +// cannot disagree. A key given a case in an executor switch but not listed here is +// refused by exec too — so the feature's own test fails, not a user's script. +var settingsSectionKeys = map[string][]string{ + "model": modelSettingKeys, + "configuration": configurationSettingKeys, + "language": {"DefaultLanguageCode"}, + "workflows": {"UserEntity", "DefaultTaskParallelism", "WorkflowEngineParallelism"}, + "webui": webUISettingKeys, +} + +// settingsKeyNoun is how an unknown-key refusal names its section. +var settingsKeyNoun = map[string]string{ + "model": "model", + "configuration": "configuration", + "language": "language", + "workflows": "workflow", + "webui": "webui", +} + +// unknownSettingsSection is the refusal for a section ALTER SETTINGS does not +// have. The grammar takes any identifier as a section, so this is the only place +// a misspelled one is caught. The list names `runtime`, the spelling MDL writes; +// `model` is its deprecated alias. +func unknownSettingsSection(section string) error { + return mdlerrors.NewUnsupported(fmt.Sprintf( + "unknown settings section: %s (expected runtime, configuration, constant, language, workflows, or webui)", + section)) +} + +// unknownSettingsKey is the refusal for a key the section does not have, naming +// the keys it does — a bare "unknown setting" sent the mxcli-banking app through +// three wrong guesses at the optimistic-locking property. +func unknownSettingsKey(section, key string) error { + return mdlerrors.NewUnsupported(fmt.Sprintf("unknown %s setting: %s\n valid keys: %s", + settingsKeyNoun[section], key, strings.Join(settingsSectionKeys[section], ", "))) +} + +// unknownSettingsKeys refuses every property of props the section does not have, +// in key order so the first refusal — the one exec returns — is deterministic. +func unknownSettingsKeys(section string, props map[string]any) []error { + known := settingsSectionKeys[section] + var errs []error + for _, key := range sortedSettingsOptionKeys(props) { + if !slices.Contains(known, key) { + errs = append(errs, unknownSettingsKey(section, key)) + } + } + return errs +} + +// settingsStatementErrors returns the refusals alterSettings gives a statement +// without looking at the project: a verb the section does not define, a section +// that does not exist, a key the section does not have. exec returns the first; +// check reports them all (MDL-SET04). Before this, check passed `alter settings +// ` and only exec refused it, so a script could pass the gate and stop +// halfway through (mendixlabs/mxcli#1549). +// +// The options of LANGUAGE ADD/MODIFY and of a workflow GROUP are left to their +// handlers, which refuse an unknown one themselves. +func settingsStatementErrors(stmt *ast.AlterSettingsStmt) []error { + section := strings.ToLower(stmt.Section) + if stmt.AddGroup || stmt.ModifyGroup || stmt.UpsertGroup || stmt.RemoveGroup { + if section != "workflows" { + return []error{mdlerrors.NewUnsupported(fmt.Sprintf( + "GROUP is only defined for the WORKFLOWS section, not %s", stmt.Section))} + } + return nil + } + if stmt.AddLanguage || stmt.ModifyLanguage || stmt.UpsertLanguage || stmt.RemoveLanguage { + if section != "language" { + return []error{mdlerrors.NewUnsupported(fmt.Sprintf( + "add/remove is only defined for the LANGUAGE section, not %s", stmt.Section))} + } + return nil + } + if section == "constant" { + return nil // a clause the grammar fixes, not a key list + } + if _, ok := settingsSectionKeys[section]; !ok { + return []error{unknownSettingsSection(section)} + } + return unknownSettingsKeys(section, stmt.Properties) +} + +// settingsStatementViolations turns those refusals into check diagnostics. The +// message is exec's first line, so check and exec say the same thing; the valid +// keys, when the refusal lists them, become the suggestion. +func settingsStatementViolations(errs []error, what string) []linter.Violation { + var out []linter.Violation + for _, err := range errs { + msg, rest, _ := strings.Cut(err.Error(), "\n") + suggestion := strings.TrimSpace(rest) + if suggestion == "" { + suggestion = "See `mxcli syntax settings.alter` for the sections and their keys." + } + out = append(out, linter.Violation{ + RuleID: "MDL-SET04", + Severity: linter.SeverityError, + Location: linter.Location{DocumentType: "settings", DocumentName: what}, + Message: msg, + Suggestion: suggestion, + }) + } + return out +} + +// ValidateSettings reports what the executor would refuse in an ALTER SETTINGS +// statement before opening the project: an unknown section or key (MDL-SET04), +// and values that will not parse as their property's type (MDL-SET01..03, #805). func ValidateSettings(stmt *ast.AlterSettingsStmt) []linter.Violation { - keys, ok := typedSettingsKeys[strings.ToLower(stmt.Section)] + section := strings.ToLower(stmt.Section) + what := "alter settings " + section + out := settingsStatementViolations(settingsStatementErrors(stmt), what) + keys, ok := typedSettingsKeys[section] if !ok { - return nil + return out } - return validateTypedSettings(strings.ToLower(stmt.Section), keys, stmt.Properties, - "alter settings "+strings.ToLower(stmt.Section)) + return append(out, validateTypedSettings(section, keys, stmt.Properties, what)...) } // ValidateCreateConfiguration reports the same for CREATE CONFIGURATION, which // accepts the configuration properties directly. func ValidateCreateConfiguration(stmt *ast.CreateConfigurationStmt) []linter.Violation { - return validateTypedSettings("configuration", typedSettingsKeys["configuration"], stmt.Properties, - fmt.Sprintf("create configuration '%s'", stmt.Name)) + what := fmt.Sprintf("create configuration '%s'", stmt.Name) + out := settingsStatementViolations(unknownSettingsKeys("configuration", stmt.Properties), what) + return append(out, validateTypedSettings("configuration", typedSettingsKeys["configuration"], stmt.Properties, what)...) } func validateTypedSettings(section string, keys map[string]settingsValueKind, props map[string]any, what string) []linter.Violation { From 940471541038b40c60f998a953c96d428f080d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:20:10 +0000 Subject: [PATCH 39/50] docs: docker check does not download MxBuild or use Docker Seven texts said otherwise: the tutorial ("The first time you run this, it will download the MxBuild toolchain", "runs Mendix's own mx check tool inside a Docker container"), docs-site docker.md and docker-check.md ("auto-downloads mxbuild", an "Auto-Download" section), the README and CLAUDE.md ("the integrated command (auto-downloads mxbuild)"), the synced validate-project command, and `mxcli docker --help` ("MxBuild (auto-downloaded from CDN if not found)", true of build only). Check resolves an installed or cached mx and has never downloaded one: git history shows DownloadMxBuild only in `mxcli new` and `docker build`. It runs mx as a local process. Measured: with an empty HOME it fails with "mx not found"; with docker and podman replaced by stubs that fail, it passes having called neither. The texts now say so, and say that `mxcli setup mxbuild -p app.mpr` sets mx up. The error a reader of the old docs lands on names that command too; the shared ResolveMxForVersion message is unchanged, since eight callers use it. validate-project's bare `mx check` example, which writes into the project, is now `docker check --mxbuild-path`. TestDockerCheckDocs_SayItDoesNotDownload holds every section that mentions docker check and downloading to say "does not download"; it flagged every original claim. TestCheck_MissingMxNamesSetupNotADownload pins the error; it failed before the change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYmgi2rk8ZbVZ9uEGxR3Nq --- .claude/commands/mendix/validate-project.md | 11 +-- ...eck-documented-as-downloading-mxbuild.json | 1 + CLAUDE.md | 5 +- README.md | 8 +- cmd/mxcli/docker.go | 9 +- cmd/mxcli/docker/check.go | 2 +- cmd/mxcli/docker/check_readonly_test.go | 26 +++++ cmd/mxcli/docker_check_docs_test.go | 96 +++++++++++++++++++ docs-site/src/tools/docker-check.md | 22 +++-- docs-site/src/tools/docker.md | 5 +- docs-site/src/tutorial/validation.md | 18 ++-- 11 files changed, 164 insertions(+), 39 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-docker-check-documented-as-downloading-mxbuild.json create mode 100644 cmd/mxcli/docker_check_docs_test.go diff --git a/.claude/commands/mendix/validate-project.md b/.claude/commands/mendix/validate-project.md index 6cb956973b..7158e03003 100644 --- a/.claude/commands/mendix/validate-project.md +++ b/.claude/commands/mendix/validate-project.md @@ -10,7 +10,7 @@ This is the same validation that Studio Pro performs - it checks for consistency ## Prerequisites -The `mx` command must be available. In a dev container, run `mxcli setup mxbuild -p app.mpr` to auto-download it. +The `mx` command must be available; `mxcli docker check` does not download it. In a dev container, run `mxcli setup mxbuild -p app.mpr` once to download it. **Where to find `mx`:** @@ -23,14 +23,11 @@ The `mx` command must be available. In a dev container, run `mxcli setup mxbuild ## Command ```bash -# In dev container (after mxcli setup mxbuild -p app.mpr) +# After mxcli setup mxbuild -p app.mpr (docker check does not download mxbuild) mxcli docker check -p app.mpr -# Or use the integrated command (auto-downloads mxbuild) -mxcli docker check -p app.mpr - -# With explicit path -/path/to/mxbuild/modeler/mx check /path/to/project.mpr +# With an explicit MxBuild (still checks a temporary copy; bare `mx check` writes into the project) +mxcli docker check -p app.mpr --mxbuild-path /path/to/mxbuild ``` ## Example Output diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-docker-check-documented-as-downloading-mxbuild.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-docker-check-documented-as-downloading-mxbuild.json new file mode 100644 index 0000000000..33f153bbdf --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-docker-check-documented-as-downloading-mxbuild.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-23", "symptom": "On a fresh machine `mxcli docker check -p app.mpr` fails with \"mx not found; specify --mxbuild-path pointing to Mendix installation directory\", although the tutorial says \"The first time you run this, it will download the MxBuild toolchain\", docker.md and CLAUDE.md say \"auto-downloads mxbuild\", and the README calls it \"the integrated command\"; the tutorial also says it runs mx check \"inside a Docker container\"", "cause": "`Check` resolves mx with `ResolveMxForVersion` (--mxbuild-path, PATH, Studio Pro, OS locations, exact cache, any cache) and has never downloaded — git history shows `DownloadMxBuild` only in `ResolveMxForNewProject` (`mxcli new`) and `Build`. It runs mx with exec.Command, never a container. The docs generalised `docker build`'s download and the `docker` command group's name onto check", "file": "`cmd/mxcli/docker/check.go` (`Check`), `cmd/mxcli/docker.go`, docs-site `tools/docker-check.md`, `tools/docker.md`, `tutorial/validation.md`, `README.md`, `CLAUDE.md`, `.claude/commands/mendix/validate-project.md`", "insight": "Settle \"does it download / does it use Docker\" by running it, not by reading the call: an empty HOME (fails, downloads nothing) and docker/podman replaced by stubs that log and fail (passes, 0 calls) — the second matters because Docker was installed, so a passing run proved nothing. Before rewording a doc that promises behaviour, check git history for whether the code once had it (`git log -S DownloadMxBuild -- check.go`): a lost feature is fixed in code, a never-true doc in the doc. Grep beyond the reported file — the claim was in seven places, two of them synced into user projects where agents read them. The error a doc-follower lands on is part of the doc: it now names `mxcli setup mxbuild`. The test holds each section that mentions docker check and downloading to say \"does not download\", with fenced `#` comments not treated as headings; the shared `ResolveMxForVersion` error was left alone because eight callers use it", "test": "`cmd/mxcli/docker_check_docs_test.go` (`TestDockerCheckDocs_SayItDoesNotDownload`), `cmd/mxcli/docker/check_readonly_test.go` (`TestCheck_MissingMxNamesSetupNotADownload`)", "refs": ["#1561", "mendixlabs/mxcli#1629"]} diff --git a/CLAUDE.md b/CLAUDE.md index 397d1ee84d..1439eaa645 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,11 +61,8 @@ The `mx` command-line tool validates and builds Mendix projects. Location depend # Auto-download mxbuild for the project's Mendix version mxcli setup mxbuild -p app.mpr -# check/validate a Mendix project +# check/validate a Mendix project (runs mx locally; does not download mxbuild) mxcli docker check -p /path/to/app.mpr - -# or use the integrated command (auto-downloads mxbuild) -mxcli docker check -p app.mpr ``` **Devcontainer gotcha — libSkiaSharp/FreeType crash on some mxbuild releases.** Certain bundled `mx` binaries (observed on 11.10.0) abort with `symbol lookup error: .../libSkiaSharp.so: undefined symbol: FT_Get_BDF_Property`. Root cause: `mx`/mxbuild run under the Temurin JVM, whose bundled libfreetype is stripped and lacks `FT_Get_BDF_Property`, so Skia loads the *JVM's* FreeType instead of the system one (which has the symbol). Preloading the system libfreetype makes it load first and fixes `mx check`/`build`/`run` while keeping Skia working. diff --git a/README.md b/README.md index 48f77876bf..df3f50fd69 100644 --- a/README.md +++ b/README.md @@ -548,14 +548,10 @@ mxcli add-tool cursor ./mxcli # project-local mxcli binary ``` -MxBuild is auto-downloaded on first use (via `mxcli setup mxbuild -p app.mpr` or `mxcli docker build`). To validate a project: +MxBuild is downloaded by `mxcli setup mxbuild -p app.mpr`, `mxcli new` and `mxcli docker build`. `mxcli docker check` does not download it: it uses an `mx` already on the machine and stops with `mx not found` otherwise. To validate a project: ```bash -# Auto-download mxbuild and check project -mxcli setup mxbuild -p app.mpr -mxcli docker check -p app.mpr - -# or use the integrated command +mxcli setup mxbuild -p app.mpr # once per Mendix version mxcli docker check -p app.mpr ``` diff --git a/cmd/mxcli/docker.go b/cmd/mxcli/docker.go index 7ef473e08e..5414f03143 100644 --- a/cmd/mxcli/docker.go +++ b/cmd/mxcli/docker.go @@ -31,7 +31,8 @@ Subcommands: Requirements: - Mendix 11.6.1 or later for build, run and reload, which produce a Portable App Distribution; check has no floor (Mendix 10 works, tested on 10.24) - - MxBuild (auto-downloaded from CDN if not found) + - MxBuild: build, run and reload download it from the CDN when it is missing; + check does not download it ('mxcli setup mxbuild -p app.mpr' sets it up) - A JDK matching the project's JavaVersion (21 up to Mendix 11.13, 25 from 11.14; devcontainers created by 'mxcli init' ship 21) - Docker with Compose V2, or Podman 4.7+ with podman compose @@ -201,7 +202,11 @@ fetches the project's exact version. There is no Mendix version floor for check: Mendix 10 works (tested on 10.24). The 11.6.1 requirement is docker build's, for the Portable App Distribution. -The mx binary is located from the same directory as mxbuild. +check does not download MxBuild, and does not use Docker: it runs mx as a local +process. It takes the mx next to --mxbuild-path, else the first it finds on +PATH, in a Studio Pro installation or in the download cache (~/.mxcli/mxbuild/), +which need not be the project's version. With none it stops; 'mxcli setup +mxbuild -p app.mpr' sets up the project's version. Examples: mxcli docker check -p app.mpr diff --git a/cmd/mxcli/docker/check.go b/cmd/mxcli/docker/check.go index cc6d50e95e..855811d361 100644 --- a/cmd/mxcli/docker/check.go +++ b/cmd/mxcli/docker/check.go @@ -96,7 +96,7 @@ func Check(opts CheckOptions) error { mxPath, err := resolveMxForCheck(opts.MxBuildPath, projectVersion) if err != nil { - return err + return fmt.Errorf("%w\n docker check does not download MxBuild; 'mxcli setup mxbuild -p %s' sets up the project's version", err, opts.ProjectPath) } fmt.Fprintf(w, "Using mx: %s\n", mxPath) diff --git a/cmd/mxcli/docker/check_readonly_test.go b/cmd/mxcli/docker/check_readonly_test.go index cb47625813..bc7ec574fe 100644 --- a/cmd/mxcli/docker/check_readonly_test.go +++ b/cmd/mxcli/docker/check_readonly_test.go @@ -384,3 +384,29 @@ func TestCheck_FailedUpdateWidgetsIsNotReportedAsNormalised(t *testing.T) { t.Errorf("output does not say the check ran without normalisation:\n%s", got) } } + +// docker check never downloads MxBuild: it resolves an mx that is installed or +// cached, and stops when there is none. The docs said it downloaded on first use, +// so on a fresh clone the reader met "mx not found; specify --mxbuild-path +// pointing to Mendix installation directory" (measured with an empty HOME) and +// nothing naming the command that does download. +func TestCheck_MissingMxNamesSetupNotADownload(t *testing.T) { + mprPath := v1Fixture(t) + seen := stubTools(t) + resolveMxForCheck = func(string, string) (string, error) { + return "", fmt.Errorf("mx not found; specify --mxbuild-path pointing to Mendix installation directory") + } + + err := Check(CheckOptions{ProjectPath: mprPath, Stdout: io.Discard, Stderr: io.Discard}) + if err == nil { + t.Fatal("Check succeeded without an mx") + } + if len(*seen) != 0 { + t.Errorf("mx ran without having been found: %v", *seen) + } + for _, want := range []string{"does not download", "mxcli setup mxbuild -p " + mprPath} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not say %q:\n%v", want, err) + } + } +} diff --git a/cmd/mxcli/docker_check_docs_test.go b/cmd/mxcli/docker_check_docs_test.go new file mode 100644 index 0000000000..80c3212f74 --- /dev/null +++ b/cmd/mxcli/docker_check_docs_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// docker check never downloads MxBuild and never starts a container: it runs +// the mx of an MxBuild already installed or cached, as a local process, and +// stops with "mx not found" when there is none (TestCheck_MissingMxNamesSetup +// NotADownload pins that). Measured: with an empty HOME it fails rather than +// downloading, and with docker and podman replaced by stubs that fail it +// passes having called neither. The docs said otherwise in seven places — +// "auto-downloads mxbuild", "The first time you run this, it will download the +// MxBuild toolchain", "runs Mendix's own mx check tool inside a Docker +// container" — so a fresh clone met an error the docs said could not happen. +// +// The rule held here: a section that talks about docker check and about +// downloading says that check does not download; the command's own help and +// page say it whatever else they discuss. +func TestDockerCheckDocs_SayItDoesNotDownload(t *testing.T) { + read := func(rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return string(b) + } + type doc struct { + body string + help bool // a command's help: every part of it is about the docker commands + always bool // the check command's own text: must say it whatever it discusses + } + docs := map[string]doc{ + "docker --help": {body: dockerCmd.Long, help: true}, + "docker check --help": {body: dockerCheckCmd.Long, help: true, always: true}, + "docs-site/src/tools/docker-check.md": {body: read("docs-site/src/tools/docker-check.md"), always: true}, + "docs-site/src/tools/docker.md": {body: read("docs-site/src/tools/docker.md")}, + "docs-site/src/tutorial/validation.md": {body: read("docs-site/src/tutorial/validation.md")}, + "README.md": {body: read("README.md")}, + "CLAUDE.md": {body: read("CLAUDE.md")}, + // Synced into user projects, where agents read them. + ".claude/commands/mendix/validate-project.md": {body: read(".claude/commands/mendix/validate-project.md")}, + ".claude/skills/mendix/docker-workflow/SKILL.md": {body: read(".claude/skills/mendix/docker-workflow/SKILL.md")}, + } + const says = "does not download" + download := regexp.MustCompile(`(?i)download`) + container := regexp.MustCompile(`(?i)inside a docker container`) + for name, d := range docs { + if d.always && !strings.Contains(flat(d.body), says) { + t.Errorf("%s does not say that docker check %s MxBuild", name, says) + } + for _, sec := range markdownSections(d.body) { + sec = flat(sec) + if !d.help && !strings.Contains(sec, "docker check") { + continue + } + if download.MatchString(sec) && !strings.Contains(sec, says) { + t.Errorf("%s: a section about docker check mentions downloading without saying check %s it:\n%s", name, says, sec) + } + if m := container.FindString(sec); m != "" { + t.Errorf("%s: says docker check runs %q; it runs mx as a local process:\n%s", name, m, sec) + } + } + } +} + +// flat collapses whitespace, so a phrase wrapped across lines still matches. +func flat(s string) string { return strings.Join(strings.Fields(s), " ") } + +var markdownHeading = regexp.MustCompile(`^#{1,6} `) + +// markdownSections splits s at its headings. A `#` line inside a fenced code +// block is a shell comment, not a heading. Text without headings is one section. +func markdownSections(s string) []string { + var secs []string + var cur strings.Builder + fenced := false + for _, line := range strings.Split(s, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + fenced = !fenced + } else if !fenced && markdownHeading.MatchString(line) && cur.Len() > 0 { + secs = append(secs, cur.String()) + cur.Reset() + } + cur.WriteString(line) + cur.WriteByte('\n') + } + return append(secs, cur.String()) +} diff --git a/docs-site/src/tools/docker-check.md b/docs-site/src/tools/docker-check.md index b0d56bf459..66cae4f903 100644 --- a/docs-site/src/tools/docker-check.md +++ b/docs-site/src/tools/docker-check.md @@ -10,8 +10,8 @@ mxcli docker check -p app.mpr ## What It Does -1. **Resolves the `mx` binary** -- Looks for `mx` in the mxbuild directory, PATH, or cached installations at `~/.mxcli/mxbuild//modeler/mx` -2. **Runs `mx check`** -- Executes the Mendix project checker against the `.mpr` file +1. **Resolves the `mx` binary** -- Takes the `mx` next to `--mxbuild-path`, else the first it finds on PATH, in a Studio Pro installation, or in the download cache at `~/.mxcli/mxbuild//modeler/mx`. It does not download one (see [Getting mx](#getting-mx)) +2. **Runs `mx check`** -- Executes the Mendix project checker against a temporary copy of the `.mpr` file, as a local process; despite the `docker` in its name, it does not use Docker 3. **Reports results** -- Outputs errors, warnings, and deprecations to the console If the project has errors, the command exits with a non-zero status code. @@ -21,19 +21,29 @@ cannot see: an MPR v2 `ContentsHash` index that disagrees with the `.mxunit` files (`mxcli fix hashes`), and git states that make Studio Pro 11.13 fail to open the project. See [Working Outside Studio Pro](outside-studio-pro.md). -## Auto-Download +## Getting mx -If mxbuild is not installed locally, you can download it first: +`docker check` does not download MxBuild. With no `mx` to find it stops with +`mx not found` and names the command that sets one up: ```bash -# Explicit setup (downloads matching mxbuild version) +# Once per Mendix version: download MxBuild (including mx) for the project's version mxcli setup mxbuild -p app.mpr # Then check mxcli docker check -p app.mpr ``` -The downloaded mxbuild (including the `mx` binary) is cached at `~/.mxcli/mxbuild/{version}/` for reuse. +The downloaded mxbuild is cached at `~/.mxcli/mxbuild/{version}/` for reuse. +`mxcli new` and `mxcli docker build` download it too. On macOS and Windows, +`setup mxbuild` prefers Studio Pro's bundled MxBuild, because the CDN only +publishes Linux binaries. + +The `mx` found need not be the project's version: an `mx` on PATH, or any +cached version, is used when the project's own is not there. `mx check` accepts +another patch of the same minor; `mx update-widgets` does not and the check then +runs without it (see `mxcli docker check --help`). Pass `--mxbuild-path +~/.mxcli/mxbuild/` to choose one. ## Example Output diff --git a/docs-site/src/tools/docker.md b/docs-site/src/tools/docker.md index b732ed8b83..8b26e7374c 100644 --- a/docs-site/src/tools/docker.md +++ b/docs-site/src/tools/docker.md @@ -8,7 +8,7 @@ mxcli provides Docker integration for building, running, and validating Mendix a |---------|-------------| | `mxcli docker build` | Build a Mendix application with mxbuild in Docker, including PAD patching | | `mxcli docker run` | Run a Mendix application in a Docker container | -| `mxcli docker check` | Validate a project with `mx check` (auto-downloads mxbuild) | +| `mxcli docker check` | Validate a project with `mx check`, run locally (does not download mxbuild or use Docker; see [Getting mx](docker-check.md#getting-mx)) | | `mxcli test` | Run test files (uses Docker for `mx create-project` and `mx check`) | | `mxcli oql` | Query a running Mendix runtime via M2EE admin API | @@ -46,6 +46,9 @@ The mx binary location depends on the environment: ## Quick Start ```bash +# Once per Mendix version: download MxBuild (docker check does not download it) +mxcli setup mxbuild -p app.mpr + # Validate a project mxcli docker check -p app.mpr diff --git a/docs-site/src/tutorial/validation.md b/docs-site/src/tutorial/validation.md index 61bdd407f1..151ab5ce92 100644 --- a/docs-site/src/tutorial/validation.md +++ b/docs-site/src/tutorial/validation.md @@ -90,10 +90,11 @@ which is why it can safely open every script. After executing your script, validate the entire project using the Mendix toolchain: ```bash +mxcli setup mxbuild -p app.mpr # once per Mendix version mxcli docker check -p app.mpr ``` -This runs Mendix's own `mx check` tool inside a Docker container. It performs the same validation that Studio Pro does when you open a project, catching issues that mxcli's parser cannot detect: +This runs Mendix's own `mx check` tool on a temporary copy of the project. It performs the same validation that Studio Pro does when you open a project, catching issues that mxcli's parser cannot detect: - BSON serialization errors (malformed internal data) - Security configuration problems (missing access rules, CE0066 errors) @@ -101,21 +102,14 @@ This runs Mendix's own `mx check` tool inside a Docker container. It performs th - Missing required properties on widgets - Broken cross-references between documents -The first time you run this, it will download the MxBuild toolchain for your project's Mendix version. Subsequent runs reuse the cached download. +`docker check` does not download MxBuild, and does not use Docker: the `docker` in its name groups it with `docker build`. It runs the `mx` of an MxBuild already on the machine, as a local process, and stops with `mx not found` when there is none. `mxcli setup mxbuild -p app.mpr` downloads the project's version once and caches it under `~/.mxcli/mxbuild/`; `mxcli new` and `mxcli docker build` download it too. -### Without Docker +### With a local Mendix installation -If you have `mx` installed locally (e.g., from a Mendix installation), you can run the check directly: +If you have `mx` installed locally (e.g., from a Mendix installation), the check finds it on PATH or in the Studio Pro installation, and `setup mxbuild` is not needed. To choose a specific one: ```bash -mxcli docker check -p app.mpr -``` - -Or use mxcli to auto-download and run it: - -```bash -mxcli setup mxbuild -p app.mpr -mxcli docker check -p app.mpr +mxcli docker check -p app.mpr --mxbuild-path /path/to/mendix ``` ## The recommended workflow From 0fa7c77a9a8832d22a6b349536fc3c2676c80736 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:25:22 +0000 Subject: [PATCH 40/50] docs(wiki): re-sync package-operations-damage from the marketplace findings Re-synthesized from the 21 package-operation records in findings/cmd-mxcli/ (2026-07-31 to 2026-10-22), update.go, workdir.go, scratch.go and the marketplace guide. New beats: package.xml's list is the payload contract; replacing a module is a delete that cascades into other modules' associations, and a transplant arrives unreconciled; order steps so a failure is not half an install; mx's 260-character path limit makes the scratch directory mxcli's choice. Drops the quoted finding count; covers: adds cmd/mxcli/marketplace. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UDwYCiqWmrM3iXhnz2YeJT --- docs-wiki/SYNC_LOG.md | 1 + .../bug-patterns/package-operations-damage.md | 189 +++++++++++++----- 2 files changed, 141 insertions(+), 49 deletions(-) diff --git a/docs-wiki/SYNC_LOG.md b/docs-wiki/SYNC_LOG.md index 5d117ec2be..baa9e0f23a 100644 --- a/docs-wiki/SYNC_LOG.md +++ b/docs-wiki/SYNC_LOG.md @@ -87,3 +87,4 @@ not capture, because sources are upstream of the commit. | 2026-10-10 | bug-patterns/visitor-wiring-gaps.md | .claude/skills/fix-issue/findings/ (same 32 records), mdl/visitor/visitor_silent_drops.go, mdl/visitor/visitor_doc_comment_placement.go, mdl/visitor/visitor_query.go, mdl/visitor/visitor_page_v3.go, plus the sources already listed | **Re-sync** (five beats added, rest unchanged). The area's largest remaining group, and the page had the three *sizes* of gap but not the mechanisms. New: a fall-through default that is itself a legal value (`return TypeString`) is silent all the way to disk, and the guard is a table test over every keyword in the rule; three control-flow shapes swallow an alternative whole (an if/else chain with no final else, a token-presence chain where one token claims every alternative containing it, a flag read by one branch of a shared rule), none of which a "some statement was produced" test can see; one rule serving two documents and disambiguated by shape has no error path by construction, and a runtime message contradicting the source text is its tell; record what was parsed and not consumed at the rule that admits it, rather than auditing every reader; and resolve a shorthand in the visitor keyed on context, not as a grammar alternative | | 2026-10-10 | bug-patterns/expression-translation-drift.md | .claude/skills/fix-issue/findings/ (same 32 records), mdl/visitor/visitor_xpath_quotes.go, plus the sources already listed | **Re-sync** (three beats added, rest unchanged). The page already carried the string-escape and source-interval material from the fixes themselves. New: `GetText()` has the opposite defect from a source-interval slice and a worse one — it concatenates visible tokens with nothing between them, so `true and false` stored as `trueandfalse`; the grep is `Expression().*GetText()`, and the reason it recurs is that the sites are grouped by document type rather than by what they do. Precedence is part of the translation (`/` shares a level with `*`/`div`/`mod`, so a member path after one parses as division), and the fix belongs where the AST is built. And position decides whether a quote is a name or a value: Mendix XPath never compares two members, so a quoted token after a comparison operator is always a mis-quoted string — which is why refusing it applies under every language version | | 2026-10-10 | bug-patterns/misleading-diagnostics.md | .claude/skills/fix-issue/findings/ (same 32 records), mdl/visitor/visitor.go (enhanceErrorMessage), plus the sources already listed | **Re-sync** (three beats added, rest unchanged). A bare parse error reads as a missing feature and gets filed as one — `Height:` on an annotation was reported as mxcli failing to expose a property Mendix does not store, and settling that needed four sources, the generated metamodel being insufficient because it is one version's snapshot (`mx dump-mpr` and decisively `mx convert` are the cheap oracles). When the grammar is right and the mistake is predictable from the source line, the fix is a hint keyed on the line rather than a grammar change, because accepting the input would need its own resolution path. And a hint must stand down on a line that already failed, or error recovery turns into a second phantom mistake | +| 2026-10-23 | bug-patterns/package-operations-damage.md | .claude/skills/fix-issue/findings/cmd-mxcli/ (21 package-operation records, 2026-07-31 → 2026-10-22: update-widgets / module-import / rename-design-properties collapse, mx writes on check and build, bundled files ×4, widget rollback, CE0066 after transplant, cross-module association cascade, relative -p guard, diff inconclusive output, refcache action sources, 6.0.0 unpublished, newer-Studio-Pro mpk, foreign .mpr adjacency, install runs fix widgets, work-dir path too long), cmd/mxcli/marketplace/update.go, cmd/mxcli/marketplace/workdir.go, cmd/mxcli/marketplace/scratch.go, docs-site/src/guides/marketplace.md | **Re-sync** after mendixlabs/mxcli#1486/#1487. New beats: the package's `package.xml` `` list is the payload contract, and the bundled-file step guessed wrong three times before reading it (mx module-import with a before/after listing is the oracle); replacing a module is a delete whose cascade removes other modules' associations, and a transplant arrives unreconciled (reconcile only where stale); order steps so a failure is not half an install; inconclusive describe output, cache placeholders and miss-then-hit tests under "never unchanged"; mx's 260-char path limit is mx's own, so the scratch directory is mxcli's choice — shortest root the user owns, never a world-writable drive root. "Path too long" moved out of "environment failures". Dropped the quoted finding count; `covers:` adds cmd/mxcli/marketplace | diff --git a/docs-wiki/bug-patterns/package-operations-damage.md b/docs-wiki/bug-patterns/package-operations-damage.md index 85cb8e518e..9befe52d29 100644 --- a/docs-wiki/bug-patterns/package-operations-damage.md +++ b/docs-wiki/bug-patterns/package-operations-damage.md @@ -1,81 +1,172 @@ --- title: Package Operations That Damage the Project category: bug-pattern -last-synced: ced830e0 +last-synced: ee7169ba covers: - cmd/mxcli + - cmd/mxcli/marketplace sources: - .claude/skills/fix-issue/findings/cmd-mxcli/ - cmd/mxcli/marketplace/update.go + - cmd/mxcli/marketplace/workdir.go + - cmd/mxcli/marketplace/scratch.go + - docs-site/src/guides/marketplace.md --- -> **Do not duplicate**: the marketplace command surface lives in the CLI help and -> `PROPOSAL_marketplace_module_upgrade.md`; the per-operation repairs live in the -> findings. This page describes the shared hazard. +> **Do not duplicate**: what `marketplace install`, `update` and `diff` do — what +> they install, where they build references, what they cache — lives in +> `docs-site/src/guides/marketplace.md` and the CLI help; the drift-detection +> design in `PROPOSAL_marketplace_module_upgrade.md`; the per-operation repairs +> and their measurements in the findings. This page describes the shared hazard. ## What this is -Installing or updating a marketplace module, or running `mx update-widgets`, -hands the project to a tool mxcli does not control — and several of those tools -change things nobody asked them to. Eleven `cmd/mxcli` findings are damage from -that hand-off, and the damage is usually reported as success. +Installing, updating or diffing a marketplace module does two risky things. It +hands the project to tools mxcli does not control (`mx module-import`, `mx +update-widgets`, `mx check`, a build), and it copies files out of a package mxcli +did not write. Both halves have changed things nobody asked them to change, and +the damage is usually reported as success: `mx check` clean, exit 0, a summary of +what was copied. ## How it fits -**The v2 → v1 collapse is the worst of them.** Mendix's own tooling rewrites an -MPR v2 project (`.mpr` plus a `mprcontents/` tree) into a single-file v1 -database. `mx check` afterwards reports **0 errors** and looks like a clean run; -what has actually happened is that `mprcontents/` is gone, the `.mpr` has grown -from tens of kilobytes to tens of megabytes, and per-document diffing no longer -works. Nothing about the output says so. mxcli's repair commands exist because of -this: let the tool convert, read the units back, restore v2, and write only the -changed ones through mxcli's own writer. +**The v2 → v1 collapse is the worst of them.** `mx module-import`, `mx +update-widgets` and `mx rename-design-properties` each rewrite an MPR v2 project +(`.mpr` plus a `mprcontents/` tree) as a single-file v1 database. `mx check` +afterwards reports **0 errors**, while `mprcontents/` is gone, the `.mpr` has +grown from tens of kilobytes to tens of megabytes, and per-document diffing no +longer works. Two repairs exist, and which one fits depends on whether the tool's +result has to persist. If the result only has to last for one check, snapshot the +storage and restore it afterwards. If the result must persist, harvest it: let +the tool convert, read the units back, restore v2, and write only the changed +ones through mxcli's own writer. The collapse also came back once through a help +string rather than a call: a "Next:" hint told the user to run the bare tool that +every code path had been guarded against. The reverse holds too. A step every +install needs, printed as "Next" rather than run, is skipped by agents and humans +alike. **Every mx tool writes into the project it is given — a check and a build too.** -The protection was added one call site at a time and each time covered only the -files someone expected to change: a v2 snapshot around update-widgets in `docker -check` (#763), then in `docker build` (#808), while a v1 `.mpr` was rewritten -permanently and a plain `mx check` wrote `theme-cache/` and `deployment/sass/` -from `docker check`, `docker build`, the TUI checker and the eval runner alike -(#951, #961). What found them was a whole-tree hash+mtime diff before and after, -not a check of the expected files. The fix that holds is a temporary copy for -any mx run that is not *meant* to change the project (`docker.MxCheckOnCopy`, -`copyProjectToTemp`); the build reads the widget-normalised model from the copy -and produces the same package. A new `exec.Command(mx…, project)` needs that -decision made explicitly: expected write, or copy. +The protection was added one call site at a time, and each time it covered only +the files someone expected to change. A v1 `.mpr` was rewritten permanently, and +a plain `mx check` wrote `theme-cache/` and `deployment/sass/` from four different +callers. What found them was a whole-tree hash+mtime diff before and after, not a +check of the expected files. The fix that holds is a temporary copy for any mx +run that is not *meant* to change the project (`docker.MxCheckOnCopy`, +`copyProjectToTemp`). A new `exec.Command(mx…, project)` needs that decision made +explicitly: expected write, or copy. -**Version numbers are not identity.** Matching an installed module against a -marketplace release by version *number* is ambiguous — a blank project ships two -different modules that both published a 4.1.0. The module's `AppStoreGuid` is the -version UUID and is the thing to match on. Getting this wrong produces -"version not found" for a module the project demonstrably has. +**The package says what it ships — take its word, not a guess.** The +bundled-file step was wrong three times in a row, and each time it was a +plausible guess: + +1. It copied the model alone, and the old widget binaries kept running. +2. It added `widgets/`, but the module's own `design-properties.json` stayed + old, giving CE6083 that `rename-design-properties` cannot clear. +3. It copied everything except `project.mpr` and `package.xml`, which put the + publisher's `License.txt`, `manifest.json` and OSS report into the app root. + +The answer was in the package the whole time: `package.xml` lists its project +files under ``. One `mx module-import` with a listing taken before and +after confirmed that mx writes exactly that list, and that it passes over a +listed file the archive lacks. When the question is "what does Mendix do with +this input", one run of Mendix's own tool against such a listing settles it; +enumerating what seems to matter does not. One test had even used +`manifest.json` as its "legitimate entry", so it treated the third bug as +expected behaviour. Read an old fixture before trusting it as a baseline. -**An update can move a dependency backwards.** Installing one module rolled a -project's widgets from 3.11.3 to 3.4.0 because an unrelated package bundled an -older copy, and `mx check` stayed clean throughout. Anything that copies bundled -files needs to compare versions rather than overwrite, and to *report* what it -skipped — "the package wanted a different version" is exactly the fact that was -invisible. +**Within what the package ships, the project's newer copy wins.** A module +bundles a copy of every widget its pages use, pinned at its author's release +time, and different modules pin the same widget at different versions. Installing +one module rolled a project's widgets from 3.11.3 back to 3.4.0, with `mx check` +clean throughout. Anything that copies bundled files has to compare versions +before overwriting. It also has to *report* what it skipped: "the package wanted a +different version" is exactly the fact that was invisible. **A model that builds can still be running the old code.** After an update the -model referenced new widget definitions while `widgets/` held the old binaries; -`show modules` reported the new version. The model and the deployed artefacts are -separate, and only one of them is what `mx check` inspects. +model referenced new widget definitions while `widgets/` held the old binaries, +and `show modules` reported the new version. The model and the deployed artefacts +are separate, and `mx check` inspects only one of them. A module with no widgets +cannot expose this, which is how the gap survived a full end-to-end test on +Administration. + +**Replacing a module is a delete, and a delete cascades.** Dropping the old +module removes its entities. Deleting an entity also removes, in every other +module, the associations that point at it. An update therefore destroyed +associations that the user's own module owned, and no module-scoped enumeration +could see or save them. The question to ask of a replace is what deleting the +module cascades into, not what the module contains. + +The arriving half has its own gap. A transplant is the one write path that copies +units verbatim, so a package's stale access rules reach the project as CE0066. +Reconcile on arrival, but only where needed: rewriting an untouched marketplace +module makes the next `diff` read it as a local edit, and that edit makes the next +update refuse. [[access-rule-reconciliation]] covers the rule itself. + +**Order the steps so a failure is not half an install.** The model is written +before the bundled files are copied. So when a relative `-p` broke the file +step's containment guard, the command exited 1 over a module the model already +had. A guard has two inputs and both must be canonical: the entry *and* the root. +Run cheap, reversible steps first, or say in the error that the model has already +changed. + +**Version numbers are not identity.** Matching an installed module against a +marketplace release by version *number* is ambiguous: a blank project ships two +different modules that both published a 4.1.0. The module's `AppStoreGuid` is the +version UUID and is the thing to match on. **Report what could not be determined, never "unchanged".** Drift detection -compares an installed module against a freshly imported reference, and an element -that cannot be described is reported **unknown**. Reporting it as unchanged would -turn a gap in coverage into a claim about the user's project — and the same -report also has to say when "no modifications found" is not a conclusion. +compares the installed module against a freshly built reference, so the +reference's blind spots become false claims about the user's project. Three +shapes recur: + +- **A describe can succeed and say nothing.** An empty body, or a read-only + building block, describes successfully. When two such renderings differ, the + difference was reported as an edit, and the `--save-edits` file that "rescued" + it would have emptied the element on replay. Compare for equality first, then + ask whether the text says anything. +- **A placeholder is not content.** The reference cache dropped the action + sources that DESCRIBE reads, so on a cache *hit* every JavaScript action read as + edited, while the first, missed run was clean. A bug that appears only on the + second run needs a miss-then-hit test. +- **The same report must say when "no modifications found" is not a + conclusion.** + +**mx's limits are mxcli's to respect in its own scratch space.** A reference +project is built by mx, in a directory mxcli chooses. mx refuses any file path of +260 characters or more, on every platform: the check is Mendix's own, not +Windows'. Bundled `node_modules` trees run to about 200 characters, so the choice +of directory decides whether `diff` and `update` can run at all. It was filed as +a Windows environment failure. It reproduced on Linux the first time mx ran in a +deep scratch directory, and that was the tell that the directory was mxcli's +choice and the bug was mxcli's. + +The shortest directory is not automatically the right one. A folder at a Windows +drive root is modifiable by every local account, and the downloaded package sits +there between download and import. The rule is the shortest root the *user* +owns. The scratch space has a second trap: format is detected by adjacency, so a +package's `project.mpr` unpacked beside a scratch v2 project reads against the +*other* project's units. That failure is silent in both directions. Unpack every +foreign `.mpr` into a directory of its own. + +**Environment failures masquerade as project corruption.** Each of these left +output that looks like a broken install: + +- an `.mpk` built by a newer Studio Pro: the API already says + `minSupportedMendixVersion`, so check it before downloading; +- an installed version that has since been unpublished, so the baseline cannot + be built and `--force` has no finding to override; +- an `mx` resolving to a different cached Mendix version. -**Environment failures masquerade as project corruption.** A path too long, an -`.mpk` built by a newer Studio Pro, a `mx` invocation resolving to a different -cached version — each leaves output that looks like a broken install. The -remedies are mostly diagnosis: say which version, which path, which binary. +The remedies are mostly diagnosis: say which version, which path, which binary. ## See also - [fix-issue findings](../../.claude/skills/fix-issue/findings/) — the individual repairs and their measurements +- [Marketplace guide](../../docs-site/src/guides/marketplace.md) — what the + commands install, where references are built, and what is cached - [[mpr-read-write]] — what v1 and v2 actually are, and why the collapse matters +- [[access-rule-reconciliation]] — why a transplanted module's rules need + reconciling, and why only where they are stale +- [[element-identity]] — why an update carries GUIDs across the replace - `PROPOSAL_marketplace_module_upgrade.md` — the drift-detection design From 77ff9bbb7ebf32ee14df109881811be928d0fdcc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:32:38 +0000 Subject: [PATCH 41/50] test(roundtrip): strike the TestApp entries the describe fixes repaired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TestApp round-trip ratchet ("the allowlist may only shrink") failed on this branch because 17 listed documents now round-trip: - the 16 view-entity associations, whose describe printed a plain `create or modify association` that exec refuses (CE6771); describe now gives them no statement, the view's OQL column declares them; - WorkflowCommons.ConflictedWorkflowDefinitionView, a Studio Pro-authored view entity whose only loss was the stored OQL text that describe → exec re-indented. gofmt realigned the map once its longest keys were gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017jEcJZDio1jrTrWtVzagjY --- mdl/roundtrip/testapp_allowlist_test.go | 282 ++++++++++++------------ 1 file changed, 135 insertions(+), 147 deletions(-) diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 330ac976d3..42949c9da6 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -50,154 +50,142 @@ package roundtrip // WorkflowCommons.Snip_TaskDashboard_Numbers, whose only loss it was. // Writing an empty Widgets list as [2] on a new owner, where it had been left // out, struck Atlas_Core.FeedbackWidget. +// mendixlabs/mxcli#1627 struck the 16 view-entity associations, whose describe +// printed a plain `create or modify association` that exec refuses (CE6771) — +// the view's OQL column declares them, so describe now gives them no statement — +// and WorkflowCommons.ConflictedWorkflowDefinitionView, whose only loss was the +// stored OQL text describe → exec re-indented. var testAppKnownFailures = map[string]knownFailure{ - "association ViewAssociations.persistent_order": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.ConflictedWorkflowDefinitionView_WorkflowDefinition": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.ConflictedWorkflowView_ConflictReasonGroup": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.ConflictedWorkflowView_CurrentActivitiesGroup": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.ConflictedWorkflowView_ExecutionPathGroup": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.ConflictedWorkflowView_Workflow": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.ConflictedWorkflowView_WorkflowDefinition": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.MyInitiatedWorkflowView_Initiator": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.MyInitiatedWorkflowView_ParentWorkflow": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.MyInitiatedWorkflowView_Workflow": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.WorkflowGroupView_WorkflowGroup": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.WorkflowUserTaskView_Workflow": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.WorkflowUserTaskView_WorkflowEndedUserTask": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.WorkflowUserTaskView_WorkflowSubProcess": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.WorkflowUserTaskView_WorkflowUserTask": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "association WorkflowCommons.WorkflowUserTaskView_WorkflowUserTaskDefinition": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Alert": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.AlertIcon": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.AlertIcon_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Alert_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Breadcrumb": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Breadcrumb_Underline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_Action": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_ActionWithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Card_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Horizontal": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Horizontal_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Horizontal_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Vertical": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Vertical_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Form_Vertical_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Heroheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Heroheader_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Heroheader_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.ListItem_DoubleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.ListItem_SingleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.ListItem_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.List_Cards": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.List_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Master_Detail": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.PageheaderImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.PageheaderImage_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.PageheaderImage_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Pageheader_WithSearch": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Timeline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Timeline_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Arrow": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Arrow_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Circle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block Atlas_Web_Content.Wizard_Circle_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "building block FeedbackModule.FeedbackWidget": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.ConflictedWorkflowDefinitionView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.ConflictedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.MyInitiatedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.UserTaskView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowAttachment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowComment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowGroupView": {laws: []law{lawParse}, issue: "#721", why: "entity: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowUserTaskView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "entity WorkflowCommons.WorkflowView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, - "enumeration WorkflowCommons.Enum_NotificationArea_RenderAs": {laws: []law{lawGetPut}, issue: "#721", why: "enumeration: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "export mapping Clients.SoapOrderExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "export mapping Services.OrdersExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "import mapping Clients.SoapCustomerOrdersImportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "import mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "import mapping Clients.SoapOrdersImportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "import mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page Administration.Account_Overview": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page FeedbackModule.PopupFailure": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page FeedbackModule.PopupSuccess": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page FeedbackModule.ShareFeedback": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page FeedbackModule.ShareFeedback_Logo": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page MyFirstModule.Home_Web": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.DefaultWorkflowAdmin": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.ManageTaskAssignments": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.MyInitiatedWorkflowView_WithdrawConfirmation": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.MyInitiatedWorkflows": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.TaskAssignmentHelper_UserTask_Reassign_TargetUserOptions": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.TaskAssignmentHelper_UserTask_Retarget_TargetUserOptions": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.TaskAssignmentHelper_UserTask_Unassign_TargetUserOptions": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.TaskDashboard": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp (putget struck by #721 L4), measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.TaskInbox": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp (putget struck by #721 L4), measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.UserTask_Assign": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp (putget since this branch's widget fixes), measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.UserTask_Target": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.UserTask_TargetGroups": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.User_WorkflowGroup_Select": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowAttachment_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowCommentHelper_Edit": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowComment_Edit_Admin": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowDefinition_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowDefinition_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowEndedUserTask": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowGroup_User_Select": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowSubProcess_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "page WorkflowCommons.WorkflowUserTaskView_View": {laws: []law{lawGetPut}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); the page Appearance keeps 1 of 3 DesignProperties (#721 C)"}, - "page WorkflowCommons.Workflow_Dashboard": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "rest client Clients.OrdersRestClient": {laws: []law{lawParse}, issue: "#721", why: "rest client: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "rule Rules.Rule1": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "rule Rules.Rule2": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "scheduled event WorkflowCommons.SE_WorkflowAuditTrailRecord_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "scheduled event: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet Administration.ReadMe": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_AuditTrailViewer_Toolbar": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_MyInitiatedWorkflowView_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_MyInitiatedWorkflowView_StateCircleOnly": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TaskAssignmentHelper_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_TaskAssignmentHelper_TargetUser_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_TaskAssignment_UserTask_Assignees": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TaskAssignment_UserTask_TargetUsers": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TaskCount_RefreshBTN": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Full": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Tasks": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_UserTask_Detail": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: exec passes since mendixlabs/mxcli#1403 (a show page passing the snippet parameter); getput then rewrites conditional-visibility ModuleRoles, a date picker ReadOnlyStyle and a data view source mapping marker, none of them show page mappings — not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_UserTask_DueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, - "snippet WorkflowCommons.Snip_UserTask_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, - "snippet WorkflowCommons.Snip_UserTask_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_UserTask_UnassignedMessage": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_ActivityIcon": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Details": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); association-path template parameters and an expression parameter drift (#721)"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_TaskDueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, - "snippet WorkflowCommons.Snip_WorkflowActivity_Details_ActivityType": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowAuditTrailRecord_Overview": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowDashboard_TaskNumbers": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowDashboard_WorkflowNumbers": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, - "snippet WorkflowCommons.Snip_WorkflowDefinition_AuditTrail": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowSubProcess_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowSubProcess_IncompatibleWarning": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowSubProcess_State": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowUserTaskView_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowUserTaskView_DueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, - "snippet WorkflowCommons.Snip_WorkflowUserTaskView_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, - "snippet WorkflowCommons.Snip_Workflow_ActivityTimelineOnly_Full": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_ActivityTimelineOnly_Tasks": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_AuditTrail": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments_Admin": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_Detail": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_Workflow_DueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, - "snippet WorkflowCommons.Snip_Workflow_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path caption parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, - "snippet WorkflowCommons.Snip_Workflow_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_StateCircleOnly": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_SubProcessDetails": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_TaskDetails": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Alert": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.AlertIcon": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.AlertIcon_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Alert_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Breadcrumb": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Breadcrumb_Underline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_Action": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_ActionWithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Card_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Horizontal": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Horizontal_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Horizontal_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Vertical": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Vertical_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Form_Vertical_WithTitle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Heroheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Heroheader_Background": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Heroheader_WithAction": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.ListItem_DoubleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.ListItem_SingleLine": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.ListItem_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.List_Cards": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.List_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Master_Detail": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.PageheaderImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.PageheaderImage_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.PageheaderImage_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader_WithBack": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader_WithControls": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Pageheader_WithSearch": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Timeline": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Timeline_WithImage": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Arrow": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Arrow_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Circle": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block Atlas_Web_Content.Wizard_Circle_Step": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "building block FeedbackModule.FeedbackWidget": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.ConflictedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.MyInitiatedWorkflowView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.UserTaskView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowAttachment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowComment": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowGroupView": {laws: []law{lawParse}, issue: "#721", why: "entity: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowUserTaskView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "entity WorkflowCommons.WorkflowView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, + "enumeration WorkflowCommons.Enum_NotificationArea_RenderAs": {laws: []law{lawGetPut}, issue: "#721", why: "enumeration: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "export mapping Clients.SoapOrderExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "export mapping Services.OrdersExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "import mapping Clients.SoapCustomerOrdersImportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "import mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "import mapping Clients.SoapOrdersImportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "import mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page Administration.Account_Overview": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page FeedbackModule.PopupFailure": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page FeedbackModule.PopupSuccess": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page FeedbackModule.ShareFeedback": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page FeedbackModule.ShareFeedback_Logo": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page MyFirstModule.Home_Web": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.DefaultWorkflowAdmin": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.ManageTaskAssignments": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.MyInitiatedWorkflowView_WithdrawConfirmation": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.MyInitiatedWorkflows": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.TaskAssignmentHelper_UserTask_Reassign_TargetUserOptions": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.TaskAssignmentHelper_UserTask_Retarget_TargetUserOptions": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.TaskAssignmentHelper_UserTask_Unassign_TargetUserOptions": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.TaskDashboard": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp (putget struck by #721 L4), measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.TaskInbox": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp (putget struck by #721 L4), measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.UserTask_Assign": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp (putget since this branch's widget fixes), measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.UserTask_Target": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.UserTask_TargetGroups": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.User_WorkflowGroup_Select": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowAttachment_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowCommentHelper_Edit": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowComment_Edit_Admin": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowDefinition_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowDefinition_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowEndedUserTask": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowGroup_User_Select": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowSubProcess_View": {laws: []law{lawExec}, issue: "#721", why: "page: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "page WorkflowCommons.WorkflowUserTaskView_View": {laws: []law{lawGetPut}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); the page Appearance keeps 1 of 3 DesignProperties (#721 C)"}, + "page WorkflowCommons.Workflow_Dashboard": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "rest client Clients.OrdersRestClient": {laws: []law{lawParse}, issue: "#721", why: "rest client: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "rule Rules.Rule1": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "rule Rules.Rule2": {laws: []law{lawGetPut}, issue: "#721", why: "rule: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "scheduled event WorkflowCommons.SE_WorkflowAuditTrailRecord_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "scheduled event: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet Administration.ReadMe": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_AuditTrailViewer_Toolbar": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_MyInitiatedWorkflowView_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_MyInitiatedWorkflowView_StateCircleOnly": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_TaskAssignmentHelper_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_TaskAssignmentHelper_TargetUser_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_TaskAssignment_UserTask_Assignees": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_TaskAssignment_UserTask_TargetUsers": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_TaskCount_RefreshBTN": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Full": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Tasks": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_UserTask_Detail": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: exec passes since mendixlabs/mxcli#1403 (a show page passing the snippet parameter); getput then rewrites conditional-visibility ModuleRoles, a date picker ReadOnlyStyle and a data view source mapping marker, none of them show page mappings — not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_UserTask_DueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, + "snippet WorkflowCommons.Snip_UserTask_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, + "snippet WorkflowCommons.Snip_UserTask_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_UserTask_UnassignedMessage": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_ActivityIcon": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Details": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); association-path template parameters and an expression parameter drift (#721)"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_TaskDueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, + "snippet WorkflowCommons.Snip_WorkflowActivity_Details_ActivityType": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowAuditTrailRecord_Overview": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowDashboard_TaskNumbers": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowDashboard_WorkflowNumbers": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, + "snippet WorkflowCommons.Snip_WorkflowDefinition_AuditTrail": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowSubProcess_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowSubProcess_IncompatibleWarning": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowSubProcess_State": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowUserTaskView_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_WorkflowUserTaskView_DueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, + "snippet WorkflowCommons.Snip_WorkflowUserTaskView_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, + "snippet WorkflowCommons.Snip_Workflow_ActivityTimelineOnly_Full": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_ActivityTimelineOnly_Tasks": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_AuditTrail": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments_Admin": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_Detail": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, + "snippet WorkflowCommons.Snip_Workflow_DueDate_Warning": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp (#743); putget passes since a template parameter written as an expression is stored as one (#823 option C)"}, + "snippet WorkflowCommons.Snip_Workflow_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path caption parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, + "snippet WorkflowCommons.Snip_Workflow_NotificationArea": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_StateCircleOnly": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_SubProcessDetails": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, + "snippet WorkflowCommons.Snip_Workflow_TaskDetails": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, } // createOrModifyProbeKnownFailures is the allowlist of From 08bec0d220e3c6c278ade12eedbfaa8933d11679 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:56:22 +0000 Subject: [PATCH 42/50] test: a reworded free note is written, not refused; gofmt splice_handler_test TestSpliceRerun_FreeNoteLineEndings (integration, parity suite) used a reworded free note as its control for 'still refused'. Free notes now change in place (mendixlabs/mxcli#1517), so the control asserts the reword is written while a line-ending-only difference still writes nothing. splice_handler_test.go was formatted with a Go 1.24 gofmt; reformatted with the 1.27.3 toolchain's, as CI's fmt-check runs it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F7x31pk37iZUX9desbsBdf --- mdl/backend/mfmutator/splice_handler_test.go | 24 ++++++++++---------- mdl/roundtrip/flow_modify_notes_test.go | 17 ++++++++++---- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/mdl/backend/mfmutator/splice_handler_test.go b/mdl/backend/mfmutator/splice_handler_test.go index c4dbbee4c7..5f0df41dde 100644 --- a/mdl/backend/mfmutator/splice_handler_test.go +++ b/mdl/backend/mfmutator/splice_handler_test.go @@ -45,18 +45,18 @@ func handlerFlow() ([]bson.D, []bson.D) { a := withEH(obj("a", "Microflows$ActionActivity", 250, 200), microflows.ErrorHandlingTypeCustomWithoutRollback) dSet(a, "Size", "160;60") // resized in Studio Pro: kept return []bson.D{ - obj("start", "Microflows$StartEvent", 100, 200), - a, - obj("h", "Microflows$ActionActivity", 250, 330), - obj("m", "Microflows$ExclusiveMerge", 400, 200), - obj("end", "Microflows$EndEvent", 550, 200), - }, []bson.D{ - flow("f1", "start", "a", 1, 3, false), - flow("f2", "a", "m", 1, 3, false), - flow("err", "a", "h", 2, 0, true), - flow("fh", "h", "m", 1, 2, false), - flow("f3", "m", "end", 1, 3, false), - } + obj("start", "Microflows$StartEvent", 100, 200), + a, + obj("h", "Microflows$ActionActivity", 250, 330), + obj("m", "Microflows$ExclusiveMerge", 400, 200), + obj("end", "Microflows$EndEvent", 550, 200), + }, []bson.D{ + flow("f1", "start", "a", 1, 3, false), + flow("f2", "a", "m", 1, 3, false), + flow("err", "a", "h", 2, 0, true), + flow("fh", "h", "m", 1, 2, false), + flow("f3", "m", "end", 1, 3, false), + } } func TestSplice_ReplaceKeepsTheErrorHandler(t *testing.T) { diff --git a/mdl/roundtrip/flow_modify_notes_test.go b/mdl/roundtrip/flow_modify_notes_test.go index 0c65bfdec0..629499f6fa 100644 --- a/mdl/roundtrip/flow_modify_notes_test.go +++ b/mdl/roundtrip/flow_modify_notes_test.go @@ -288,10 +288,17 @@ func TestSpliceRerun_FreeNoteLineEndings(t *testing.T) { t.Errorf("re-run with %q line breaks wrote %d unit(s)", eol, len(changed)) } } - // Control: a real change to the free note is still refused, and the - // refusal names the note. - err := h.exec(strings.Replace(flow("\n"), "Second line.", "Second line, reworded.", 1)) - if err == nil || !strings.Contains(err.Error(), `note "First line.\r\nSecond line." is now "First line.\nSecond line, reworded."`) { - t.Fatalf("a reworded free note: err = %v\n%s", err, h.out.String()) + // Control: a real change to the free note is a change — written in place + // (mendixlabs/mxcli#1517), not taken for a line-ending difference. + h.out.Reset() + before := h.snapshot() + if err := h.exec(strings.Replace(flow("\n"), "Second line.", "Second line, reworded.", 1)); err != nil { + t.Fatalf("a reworded free note: %v\n%s", err, h.out.String()) + } + if !strings.Contains(h.out.String(), "(set: free notes)") || len(before.diff(h.snapshot())) == 0 { + t.Errorf("a reworded free note was not written:\n%s", h.out.String()) + } + if got := h.describeUnder("mdl 1;", "microflow MyFirstModule.Rerun_FreeNoteEol"); !strings.Contains(got, "Second line, reworded.") { + t.Errorf("describe after the reword:\n%q", got) } } From 33ad8ba2253dc88c8db653e6bb9db45c3dcf4e8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:42:03 +0000 Subject: [PATCH 43/50] fix: find/filter by System.owner builds, and describe keeps System associations qualified `$m = find $l by System.owner = $User;` was refused at exec with advice to create a System association, though mxbuild builds it; describe printed a stored one as `by owner`, and an inherited System association over Administration.Account as `by User_Language`, which re-executed as the attribute Administration.Account.User_Language (CE1613) (ako/mxcli#1629). Measured with mx check on 11.12.5: find/filter by System.owner or System.changedBy is clean when the generalization root stores the member (own entity, FileDocument specialization, specialization of an owning root), CE0091 "No member selected." when it does not, and System.Owner is CE1613. - resolveMemberChange resolves System.owner / System.changedBy to the System association and refuses another case with the CE1613 spelling. Create/change still refuse them first (MDL-CHANGE01, CE1050). - validateListOperationSystemMembers (reference tier: check -p and exec's pre-flight) refuses the CE0091 shape and names the alter. - extractFieldName keeps a System association qualified, and the member speller spells a find's member with the same function, so a declared find and the stored one's description compare alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015XD4JpQem7z5pjk2WuYRqf --- .../2026-10-23-find-by-system-owner.json | 1 + CHANGELOG.md | 1 + docs-site/src/appendixes/error-messages.md | 5 +- .../bug-tests/1629-find-by-system-owner.mdl | 56 +++++ .../cmd_microflows_builder_actions.go | 17 ++ mdl/executor/cmd_microflows_format_action.go | 11 +- mdl/executor/flow_member_spelling.go | 4 +- .../list_operation_system_member_test.go | 219 ++++++++++++++++++ mdl/executor/validate.go | 2 + .../validate_list_operation_system_member.go | 79 +++++++ ...validate_microflow_system_member_change.go | 16 +- 11 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-23-find-by-system-owner.json create mode 100644 mdl-examples/bug-tests/1629-find-by-system-owner.mdl create mode 100644 mdl/executor/list_operation_system_member_test.go create mode 100644 mdl/executor/validate_list_operation_system_member.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-find-by-system-owner.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-find-by-system-owner.json new file mode 100644 index 0000000000..a48bd8ad11 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-23-find-by-system-owner.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-23", "symptom": "`$m = find $l by System.owner = $User;` (filter, System.changedBy alike) was refused at exec with `member \"System.owner\" on entity Shop.Owned is not a known association … create the association first`, though mxbuild builds it; describe printed a stored one as `find $l by owner`, and a System association inherited by Administration.Account as `by User_Language`, which re-executed as the attribute Administration.Account.User_Language — CE1613 (ako/mxcli#1629, 11.12.5)", "cause": "Two halves of one gap. resolveMemberChange looks the qualifier's module up in its domain model, and System's virtual model has no owner/changedBy (they are root-entity flags). extractFieldName cut every member to its last segment, and the builder resolves a bare name in the LIST ENTITY'S module, so any association outside it — System's included — came back as something else", "file": "`mdl/executor/cmd_microflows_builder_actions.go` (resolveMemberChange resolves System.owner/changedBy, refuses another case as CE1613), `mdl/executor/validate_list_operation_system_member.go` (CE0091 when the root stores no such member, reference tier), `mdl/executor/cmd_microflows_format_action.go` (extractFieldName keeps System associations qualified), `mdl/executor/flow_member_spelling.go` (the speller spells a find's member with extractFieldName)", "insight": "Describe and the member speller are two answers to 'how is this member spelled' — change one and the other must take the same function, or diff-then-patch compares unlike spellings. The phantom rewrite that causes is INVISIBLE end to end: write elision reports 'Unchanged' because the rebuilt BSON is identical, so a re-run test passed with the speller reverted; only a direct describedMemberSpellings test caught it (over a System.User list, where the bare name does resolve). Measured (11.12.5): find/filter by System.owner|changedBy clean iff the chain root stores it, else CE0091 'No member selected.' — a third CE code for the same 'entity does not store it' fact (CE0161 in XPath, CE1050 for any create/change). Sibling NOT fixed: a find by another module's association (`Other.Emp_Dept` over an HR list) still describes bare and re-executes as HR's — flow_member_spelling_test already names it as a control", "refs": ["ako/mxcli#1629", "mdl-examples/bug-tests/1629-find-by-system-owner.mdl"], "ce": ["CE0091", "CE1613"], "rules": []} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa0baa56b..16d263af2a 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 +- **`find` / `filter` by `System.owner` or `System.changedBy` builds, and describe prints System associations back so they re-execute** (ako/mxcli#1629) — `$m = find $l by System.owner = $User;` was refused at exec with advice to create a System association, though mxbuild builds it (11.12.5). It now resolves to the System association; on an entity whose generalization root does not store the member, `check -p` and exec refuse it with the CE0091 "No member selected." mxbuild would report, naming the `alter entity` that fixes it. `describe` printed a stored one as `by owner`, and a System association inherited by a specialization of `System.User` as `by User_Language` — which re-executed as an attribute of the specialization, CE1613. A System association in a find or filter is now printed qualified (`by System.User_Language`), and running the description again changes nothing. - **`create` / `change` naming `System.owner` or `System.changedBy` is refused with advice that applies** (mendixlabs/mxcli#1612) — `$S = create M.Entity (Name = 'x', System.owner = $User);` passed `check -p`, and exec then refused it with `member "System.owner" on entity M.Entity is not a known association … create the association first (`create or modify association System.owner from ... to ...`)`, which no project can do. The member cannot be written at all: measured on 11.12.5, `System.owner` / `System.changedBy` as a create or change object member is CE1050 "Unknown association 'System.owner'." for every entity — one storing its owner, one not, a `System.FileDocument` specialization — in a microflow and a nanoflow. `check` (no project needed) and `exec` now refuse it as **MDL-CHANGE01** and name what works: an association to `System.User` the project models. `find $l by System.owner = $u` is not affected; it builds. - **`[id = $obj/System.owner]` on an entity that stores no owner is refused before it reaches the build** (ako/mxcli#1306, follow-up on mendixlabs/mxcli#1358) — the rewrite MDL-RETRIEVE02 suggests passed `check -p` and exec and then failed `mx check` with `[CE0161] "Error(s) in XPath constraint."` when `$obj`'s entity does not store its owner. `check -p` and exec now refuse a constraint reading `System.owner` / `System.changedBy` off a variable whose entity does not store it, judged on the root of its generalization chain (measured on 11.12.5), and name the `alter entity add attribute owner: AutoOwner` that fixes it; MDL-RETRIEVE02's suggestion states the precondition. The existing check for `[System.owner = …]` on the retrieved entity now also judges an entity the same script creates, which it skipped — the ordinary shape. - **Rewriting a page, snippet or layout no longer deletes the widgets MDL cannot author** (mendixlabs/mxcli#1622) — `describe page` prints a classic data grid, a reference selector, a drop-down or a reference set selector as a `-- NOT re-executable` comment, so executing that output rebuilt the page without them and said only `Replaced page M.P`, with `mx check` clean on both sides. `create or modify` / `create or replace` of a stored page, snippet or layout that holds such a widget — one the statement does not declare by name — is now refused, listing each widget, with the `alter` that edits the document while keeping them and the `alter … { drop … }` that removes them on purpose first. `mxcli check -p` reports the same before anything is written, following an earlier `alter` of the document in the same script. diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index a59a68c709..5c060bae91 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -368,7 +368,10 @@ project models. Written as a create or change object member, `System.owner` and `System.changedBy` are CE1050 for every entity — one that stores its owner, one that does not, a `System.FileDocument` specialization — in a microflow and a nanoflow alike (measured on 11.12.5, mendixlabs/mxcli#1612). `check` needs no project to know it; `check` and `exec` both -refuse it. A list operation is different: `find $l by System.owner = $u` builds. +refuse it. A list operation is different: `find $l by System.owner = $u` (and `filter`, and +`System.changedBy`) builds when the list's entity stores the member — on one that does not it +is CE0091 "No member selected.", which `check -p` and `exec` report with the `alter entity` +that stores it (ako/mxcli#1629). **Solution:** To record a user on an object — for example to own seeded data — model the association and set that: diff --git a/mdl-examples/bug-tests/1629-find-by-system-owner.mdl b/mdl-examples/bug-tests/1629-find-by-system-owner.mdl new file mode 100644 index 0000000000..cd4a5235f5 --- /dev/null +++ b/mdl-examples/bug-tests/1629-find-by-system-owner.mdl @@ -0,0 +1,56 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1629 — find / filter by System.owner, and describing it back +-- ============================================================================ +-- +-- `find $l by System.owner = $u` was refused at exec ("… is not a known +-- association … create the association first"), though mxbuild builds it, and +-- describe printed a stored one as `by owner` — which re-executes as an +-- attribute. A System association inherited by a specialization of System.User +-- had the same describe drift: `by User_Language` re-executed as the attribute +-- Administration.Account.User_Language, CE1613. +-- +-- Measured with mx check on 11.12.5: +-- find/filter by System.owner | System.changedBy, the root stores it 0 problems +-- the same on an entity that does not store it CE0091 "No member selected." +-- find by System.Owner CE1613 +-- `check -p` / exec refuse the last two (they need the entity's flags, so plain +-- `check` — what `make check-mdl` runs — cannot see them). This file holds the +-- forms that build; describe prints each back as written. +-- ============================================================================ + +create module B1629; + +create persistent entity B1629.Task ( + "Title": String(200), + "owner": AutoOwner, + "changedBy": AutoChangedBy +); + +-- A System.FileDocument specialization stores both members. +create persistent entity B1629.Attachment extends System.FileDocument ("Caption": String(200)); + +create or modify microflow B1629.Task_FirstOwnedBy ($Tasks: List of B1629.Task, $User: System.User) +returns B1629.Task as $Task +begin + $Task = find $Tasks by System.owner = $User; +end; + +create or modify microflow B1629.Task_EditedBy ($Tasks: List of B1629.Task, $User: System.User) +returns List of B1629.Task as $Edited +begin + $Edited = filter $Tasks by System.changedBy = $User; +end; + +create or modify microflow B1629.Attachment_UploadedBy ($Files: List of B1629.Attachment, $User: System.User) +returns List of B1629.Attachment as $Mine +begin + $Mine = filter $Files by System.owner = $User; +end; + +-- A System association inherited from System.User, by its qualified name. +create or modify microflow B1629.Account_SpeakingLanguage ($Accounts: List of Administration.Account, $Lang: System.Language) +returns Administration.Account as $Account +begin + $Account = find $Accounts by System.User_Language = $Lang; +end; diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 5b4bd83673..aeb9c2c924 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -2410,6 +2410,23 @@ func (fb *flowBuilder) resolveMemberChange(mc *microflows.MemberChange, memberNa qualifiedName = moduleName + "." + memberName } + // System.owner and System.changedBy are associations to System.User that no + // domain model lists — an entity carries them as flags on the root of its + // generalization chain — so the lookup below cannot find them. A list + // operation finds and filters by them (measured clean on 11.12.5, + // ako/mxcli#1629). A create or change object cannot set them (CE1050); + // those statements refuse them before reaching here (refuseSystemMemberChange). + if strings.Count(memberName, ".") == 1 { + if canonical, ok := builtinSystemMemberAssociation(memberName); ok { + if memberName != canonical { + fb.addError("%s", systemMemberSpellingRefusal(memberName, canonical, entityQN)) + return + } + mc.AssociationQualifiedName = canonical + return + } + } + // A one-dot member whose qualifier is NOT a module (`Assoc.Attr`) is a // member reached through an association. Mendix's ChangeActionItem cannot // represent that at all — the slot holds either an attribute of the changed diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index 6894201ebd..b4c35499f4 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -1384,11 +1384,20 @@ func formatListOperation(ctx *ExecContext, op microflows.ListOperation, outputVa // extractFieldName returns the short field name from a qualified attribute // or association reference (e.g. "MyModule.Entity.Status" → "Status", // "MyModule.Order_Customer" → "Order_Customer"). Returns the association -// name if attribute is empty. +// name if attribute is empty — qualified for a System association +// ("System.owner"). func extractFieldName(attribute, association string) string { ref := attribute if ref == "" { ref = association + // A System association stays qualified. The builder resolves a bare + // name in the list entity's own module, so `owner` came back as an + // attribute of the entity and `User_Language` on an Administration.Account + // list as Administration.Account.User_Language — CE1613 on the next + // build (ako/mxcli#1629). The member speller spells it the same way. + if strings.HasPrefix(ref, "System.") { + return ref + } } if ref == "" { return "" diff --git a/mdl/executor/flow_member_spelling.go b/mdl/executor/flow_member_spelling.go index cce9b7d916..b953ba724b 100644 --- a/mdl/executor/flow_member_spelling.go +++ b/mdl/executor/flow_member_spelling.go @@ -135,9 +135,7 @@ func (s *memberSpeller) respell(st ast.MicroflowStatement) { if !ok { return } - short := s.member(entity, name, func(attr, assoc string) string { - return lastSegment(attr + assoc) - }) + short := s.member(entity, name, extractFieldName) if short != name { binary.Left = &ast.IdentifierExpr{Name: short} } diff --git a/mdl/executor/list_operation_system_member_test.go b/mdl/executor/list_operation_system_member_test.go new file mode 100644 index 0000000000..615cdad199 --- /dev/null +++ b/mdl/executor/list_operation_system_member_test.go @@ -0,0 +1,219 @@ +// 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/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// ako/mxcli#1629: `find $l by System.owner = $u` was refused at exec with +// +// member "System.owner" on entity Shop.Owned is not a known association (and a +// one-qualifier name cannot be an attribute) — create the association first +// (`create or modify association System.owner from ... to ...`) or fix the name +// +// although mxbuild builds it, and describe printed a stored one as `by owner`, +// which re-executes as an attribute. Measured with mx check on 11.12.5: +// +// find/filter by System.owner | System.changedBy, the root stores it 0 problems +// (own entity, a System.FileDocument specialization, a specialization of an owning root) +// the same on an entity that does not store it, or its specialization CE0091 "No member selected." +// find by System.Owner CE1613 +// find over Administration.Account by System.User_Language 0 problems, and its +// describe `by User_Language` re-executed as an attribute: CE1613 +const listSystemMemberHead = `mdl 1; +create module B1629; +create persistent entity B1629.Owned (Number: String(50), owner: AutoOwner, changedBy: AutoChangedBy); +create persistent entity B1629.Plain (Number: String(50)); +create persistent entity B1629.Doc extends System.FileDocument (Title: String(50)); +create persistent entity B1629.SubOwned extends B1629.Owned (Extra: String(50)); +create persistent entity B1629.SubPlain extends B1629.Plain (Extra: String(50)); +` + +const listSystemMemberFlow = `create or modify microflow B1629.F ($l: List of %s, $User: System.User) returns Boolean +begin + $m = %s $l by %s = $User; + return true; +end; +` + +// The forms mxbuild builds: check and exec accept them, the member is stored +// as the System association, and describe prints it back so that running the +// description again changes nothing. +func TestListOperationBySystemMember_Builds(t *testing.T) { + cases := []struct{ entity, verb, member string }{ + {"B1629.Owned", "find", "System.owner"}, + {"B1629.Owned", "filter", "System.changedBy"}, + {"B1629.Doc", "find", "System.owner"}, + {"B1629.SubOwned", "find", "System.owner"}, + } + for _, c := range cases { + t.Run(c.entity+" "+c.verb+" "+c.member, func(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + flow := fmt.Sprintf(listSystemMemberFlow, c.entity, c.verb, c.member) + assertAgree(t, exec, dir, listSystemMemberHead+flow, "") + + out.Reset() + if err := agreeExec(t, exec, "describe microflow B1629.F;"); err != nil { + t.Fatalf("describe: %v", err) + } + want := fmt.Sprintf("$m = %s $l by %s = $User;", c.verb, c.member) + if !strings.Contains(out.String(), want) { + t.Fatalf("describe must print %q, got:\n%s", want, out.String()) + } + + // Run the script again: the declared find and the stored one's + // description must agree, or the flow is rewritten every run. + out.Reset() + if err := agreeExec(t, exec, "mdl 1;\n"+flow); err != nil { + t.Fatalf("re-run: %v", err) + } + if !strings.Contains(out.String(), "Unchanged microflow") { + t.Errorf("re-running the same find must change nothing, got:\n%s", out.String()) + } + }) + } +} + +// An entity that does not store the member: mxbuild reports CE0091 "No member +// selected.", so check -p and exec's pre-flight refuse it and name the fix. +func TestListOperationBySystemMember_EntityStoresNone(t *testing.T) { + cases := []struct { + entity, verb, member string + want []string + }{ + {"B1629.Plain", "find", "System.owner", []string{ + "B1629.Plain does not store System.owner", "CE0091", "alter entity B1629.Plain add attribute owner: AutoOwner"}}, + {"B1629.SubPlain", "filter", "System.changedBy", []string{ + "root B1629.Plain does not store System.changedBy", "alter entity B1629.Plain add attribute changedBy: AutoChangedBy"}}, + } + for _, c := range cases { + exec, _, dir := openPedAppCopy(t) + src := listSystemMemberHead + fmt.Sprintf(listSystemMemberFlow, c.entity, c.verb, c.member) + got := strings.Join(agreeCheck(t, exec, dir, src), "\n") + for _, w := range c.want { + if !strings.Contains(got, w) { + t.Errorf("%s %s by %s: check -p must report %q, reported:\n%s", c.entity, c.verb, c.member, w, got) + } + } + } +} + +// `System.Owner` is a different name (CE1613); the refusal says how it is spelled +// instead of advising to create a System association. +func TestListOperationBySystemMember_Spelling(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + src := listSystemMemberHead + fmt.Sprintf(listSystemMemberFlow, "B1629.Owned", "find", "System.Owner") + assertAgree(t, exec, dir, src, "spelled System.owner") + if got := strings.Join(agreeCheck(t, exec, dir, src), "\n"); strings.Contains(got, "create the association first") { + t.Errorf("check still advises creating a System association:\n%s", got) + } +} + +// A System association by its qualified name. Over a specialization of +// System.User, describe printed it bare and the bare name re-executed as an +// attribute of the specialization (CE1613). Over System.User itself the bare +// name does resolve, so the member speller respelled the declared name bare +// while describe prints it qualified — and the flow was rewritten every run. +func TestListOperationBySystemAssociation_RoundTrips(t *testing.T) { + for _, entity := range []string{"Administration.Account", "System.User"} { + t.Run(entity, func(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + flow := fmt.Sprintf(`create or modify microflow B1629.G ($l: List of %s, $Lang: System.Language) returns Boolean +begin + $m = find $l by System.User_Language = $Lang; + return true; +end; +`, entity) + assertAgree(t, exec, dir, "mdl 1;\ncreate module B1629;\n"+flow, "") + out.Reset() + if err := agreeExec(t, exec, "describe microflow B1629.G;"); err != nil { + t.Fatalf("describe: %v", err) + } + if want := "$m = find $l by System.User_Language = $Lang;"; !strings.Contains(out.String(), want) { + t.Fatalf("describe must print %q, got:\n%s", want, out.String()) + } + out.Reset() + if err := agreeExec(t, exec, "mdl 1;\n"+flow); err != nil { + t.Fatalf("re-run: %v", err) + } + if !strings.Contains(out.String(), "Unchanged microflow") { + t.Errorf("re-running the same find must change nothing, got:\n%s", out.String()) + } + }) + } +} + +func TestExtractFieldName_KeepsSystemAssociationsQualified(t *testing.T) { + cases := []struct{ attr, assoc, want string }{ + {"", "System.owner", "System.owner"}, + {"", "System.User_Language", "System.User_Language"}, + {"", "M.Emp_Dept", "Emp_Dept"}, + {"M.Emp.Name", "", "Name"}, + } + for _, c := range cases { + if got := extractFieldName(c.attr, c.assoc); got != c.want { + t.Errorf("extractFieldName(%q, %q) = %q, want %q", c.attr, c.assoc, got, c.want) + } + } +} + +// The member speller puts a declared find in describe's spelling before it is +// compared with the stored one. Over a list of System.User the bare +// `User_Language` resolves to the same association, so the speller shortened +// the declared name while describe prints it qualified: the two never matched. +// The speller now spells a find's member exactly as describe does. +func TestDescribedMemberSpellings_SystemAssociationStaysQualified(t *testing.T) { + sys, hr := model.ID("sys"), model.ID("hr") + backend := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + switch name { + case "System": + return &model.Module{BaseElement: model.BaseElement{ID: sys}, Name: name}, nil + case "HR": + return &model.Module{BaseElement: model.BaseElement{ID: hr}, Name: name}, nil + } + return nil, nil + }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { + switch id { + case sys: + return &domainmodel.DomainModel{ + Entities: []*domainmodel.Entity{{Name: "User"}, {Name: "Language"}}, + Associations: []*domainmodel.Association{{Name: "User_Language"}}, + }, nil + case hr: + return &domainmodel.DomainModel{Entities: []*domainmodel.Entity{{Name: "Emp"}}}, nil + } + return nil, nil + }, + } + ctx, _ := newMockCtx(t, withBackend(backend)) + prog, errs := visitor.Build(`create microflow HR.F ($Users: List of System.User, $Emps: List of HR.Emp, $Lang: System.Language, $U: System.User) +begin + $A = find $Users by System.User_Language = $Lang; + $B = find $Emps by System.owner = $U; +end; +`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + f := prog.Statements[0].(*ast.CreateMicroflowStmt) + got := describedMemberSpellings(ctx, f.Parameters, f.Body, nil) + for i, want := range []string{"System.User_Language", "System.owner"} { + left := got[i].(*ast.ListOperationStmt).Condition.(*ast.BinaryExpr).Left + name, _ := listOperationFieldName(left) + if name != want { + t.Errorf("find %d: spelled %q, want %q — describe prints a System association qualified", i, name, want) + } + } +} diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 689f4eccc8..b3dc7e4d83 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -807,6 +807,7 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext if ctx.Connected() { refErrors = append(refErrors, validateRetrieveOperandTypes(ctx, s.Parameters, s.Body, sc)...) refErrors = append(refErrors, validateRetrieveSystemMemberPaths(ctx, s.Parameters, s.Body, sc)...) + refErrors = append(refErrors, validateListOperationSystemMembers(ctx, s.Parameters, s.Body, sc)...) } if len(refErrors) > 0 && s.Excluded { sc.warnExcluded("microflow", s.Name.String(), refErrors) @@ -874,6 +875,7 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext if ctx.Connected() { refErrors = append(refErrors, validateRetrieveOperandTypes(ctx, s.Parameters, s.Body, sc)...) refErrors = append(refErrors, validateRetrieveSystemMemberPaths(ctx, s.Parameters, s.Body, sc)...) + refErrors = append(refErrors, validateListOperationSystemMembers(ctx, s.Parameters, s.Body, sc)...) } if len(refErrors) > 0 && s.Excluded { sc.warnExcluded("nanoflow", s.Name.String(), refErrors) diff --git a/mdl/executor/validate_list_operation_system_member.go b/mdl/executor/validate_list_operation_system_member.go new file mode 100644 index 0000000000..bbd914c553 --- /dev/null +++ b/mdl/executor/validate_list_operation_system_member.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// `find $l by System.owner = $u` (and filter, and System.changedBy) builds when +// the list's entity stores the member, and fails otherwise (ako/mxcli#1629). +// Measured with mx check on 11.12.5: +// +// the root stores it (own entity, System.FileDocument specialization, +// specialization of an owning root) 0 problems +// the root does not (plain entity, its specialization) CE0091 "No member selected." +// System.Owner CE1613 +// +// So the verdict is the root's flag, judged as validateRetrieveSystemMemberPaths +// judges a constraint's `$var/System.owner`. Silence where the list's entity is +// not visible in the text or the chain cannot be walked. + +// validateListOperationSystemMembers reports each find or filter by +// System.owner / System.changedBy that mxbuild rejects: a misspelled member, or +// a list whose entity does not store it. +func validateListOperationSystemMembers(ctx *ExecContext, params []ast.MicroflowParam, body []ast.MicroflowStatement, sc *scriptContext) []string { + var index map[string]*domainmodel.Entity // loaded on first use: most flows have no such member + loaded := false + var errs []string + types := newFlowObjectTypes(params) + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + defer types.observe(s) + x, ok := s.(*ast.ListOperationStmt) + if !ok || (x.Operation != ast.ListOpFind && x.Operation != ast.ListOpFilter) || + x.ByExpression || !ast.IsMemberEquality(x.Condition) { + return + } + name, ok := listOperationFieldName(x.Condition.(*ast.BinaryExpr).Left) + if !ok { + return + } + canonical, ok := builtinSystemMemberAssociation(name) + if !ok { + return + } + entity := types.lists[x.InputVariable] + if name != canonical { + errs = append(errs, systemMemberSpellingRefusal(name, canonical, entity)) + return + } + if entity == "" { + return + } + if !loaded { + index, loaded = buildEntityIndex(ctx), true + } + member := strings.TrimPrefix(canonical, "System.") + stores, root, known := scriptSystemMemberRoot(index, sc, entity, member) + if stores || !known { + return + } + holder := entity + if root != entity { + holder = entity + "'s root " + root + } + verb := "find" + if x.Operation == ast.ListOpFilter { + verb = "filter" + } + errs = append(errs, fmt.Sprintf( + "%s $%s by %s: %s does not store %s — mxbuild rejects the member with CE0091 \"No member "+ + "selected.\". Store it first: %s", + verb, x.InputVariable, canonical, holder, canonical, systemMemberStoreFix(root, member))) + }) + return errs +} diff --git a/mdl/executor/validate_microflow_system_member_change.go b/mdl/executor/validate_microflow_system_member_change.go index 6500743a22..cc6bd92b07 100644 --- a/mdl/executor/validate_microflow_system_member_change.go +++ b/mdl/executor/validate_microflow_system_member_change.go @@ -35,7 +35,9 @@ const systemMemberChangeRule = "MDL-CHANGE01" // from the same `mx check` run, which first read as "builds clean". Measure // each variant in a project with no other error. // -// A list operation is not covered: `find $l by System.owner = $u` builds clean. +// A list operation is not covered: `find $l by System.owner = $u` builds clean +// when the entity stores the member (ako/mxcli#1629, see +// validateListOperationSystemMembers). // builtinSystemMemberAssociation reports the spelling of the System // association member names, in any case, when it is System.owner or @@ -54,6 +56,18 @@ func builtinSystemMemberAssociation(member string) (string, bool) { return "", false } +// systemMemberSpellingRefusal is the message for System.owner / System.changedBy +// written in another case: a different name to mxbuild, which reports it as +// CE1613 (measured on 11.12.5). entityQN is "" when the entity is not known. +func systemMemberSpellingRefusal(member, canonical, entityQN string) string { + on := "the object" + if entityQN != "" { + on = entityQN + } + return fmt.Sprintf("member %q on %s: the System member is spelled %s — as written, mxbuild rejects it "+ + "with CE1613 \"The selected association '%s' no longer exists.\"", member, on, canonical, member) +} + // systemMemberChangeRefusal is the message and suggestion for a create or // change object member naming System.owner / System.changedBy. The check rule // and the flow builder both word it from here, so check and exec say the same. From 0c812d7e932268ac4acca7d9928e5a147ef3583a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:07:46 +0000 Subject: [PATCH 44/50] fix: create configuration on 11.12+ mints fresh ids for the copied OpenTelemetry part `create configuration 'Staging'` on a Mendix 11.14 project was refused with "3 element id(s) are used more than once". A new configuration copies an existing one as its shape template, and from 11.12 a configuration Studio Pro has saved carries a nested OpenTelemetry part whose elements (the part and its Logs / Traces children) have $IDs of their own. The shallow copy kept them, and the duplicate-id guard refused the write. The template is now deep-copied with a fresh $ID for every nested storage object; the values are still inherited, and the sibling no longer shares sub-documents with the copy. A blank `mxcli new` 11.14 app stores OpenTelemetry = null and does not reproduce it; on testdata/testapp-views the fixed build creates the configuration and `mx check` reports the same errors as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- ...nfiguration-duplicate-nested-ids-1112.json | 1 + .../create-configuration-nested-ids-1112.mdl | 33 ++++++ .../settings_create_configuration_test.go | 61 ++++++++++ .../new_configuration_ids_test.go | 105 ++++++++++++++++++ mdl/settingsoverlay/settingsoverlay.go | 37 +++++- 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-10-23-create-configuration-duplicate-nested-ids-1112.json create mode 100644 mdl-examples/bug-tests/create-configuration-nested-ids-1112.mdl create mode 100644 mdl/backend/modelsdk/settings_create_configuration_test.go create mode 100644 mdl/settingsoverlay/new_configuration_ids_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-23-create-configuration-duplicate-nested-ids-1112.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-23-create-configuration-duplicate-nested-ids-1112.json new file mode 100644 index 0000000000..3545c72d20 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-23-create-configuration-duplicate-nested-ids-1112.json @@ -0,0 +1 @@ +{"area": "mdl/settingsoverlay", "date": "2026-10-23", "symptom": "`create configuration 'Staging'` on testdata/testapp-views (Mendix 11.14.0) → `refusing to write unit …: 3 element id(s) are used more than once` held by Settings$OpenTelemetryConfiguration / …LogsConfiguration / …TracesConfiguration; the same statement succeeded on the 11.13 and 11.6 fixtures", "cause": "newServerConfiguration built a new configuration by shallow-copying the first sibling and deleting only its top-level $ID. On 11.12+ a Studio Pro-saved configuration carries a nested OpenTelemetry part (Model SDK: openTelemetry introduced 11.12.0, tracing deleted 11.12.0) with $IDs on the part and its Logs/Traces children, so the copy put each id in the unit twice and the duplicate-id guard refused the write", "file": "mdl/settingsoverlay/settingsoverlay.go (newServerConfiguration, cloneWithFreshIDs); tests mdl/settingsoverlay/new_configuration_ids_test.go, mdl/backend/modelsdk/settings_create_configuration_test.go", "insight": "Check the subject before trusting a green real run: a blank `mxcli new` 11.14.0 app stores `OpenTelemetry = null`, so the unfixed build creates a configuration there without complaint and `mx check` = 0 errors proves nothing — only a configuration Studio Pro has saved carries the nested part. On testapp-views the fixed build's `mx check` error list is identical to the unmodified baseline. Cloning a template needs a DEEP copy with a fresh $ID per nested storage object, not just the top-level one; the shallow copy also aliased the sibling's sub-documents into the new one. newLanguage uses the same shallow-copy shape but a Texts$Language has no nested elements, so it is unaffected — check the type's children before copying that pattern elsewhere. Found while replaying a describe-emitted `create or modify configuration` for a new name (mendixlabs/mxcli#131 work)", "refs": ["ako/mxcli#1225"]} diff --git a/mdl-examples/bug-tests/create-configuration-nested-ids-1112.mdl b/mdl-examples/bug-tests/create-configuration-nested-ids-1112.mdl new file mode 100644 index 0000000000..d62542c534 --- /dev/null +++ b/mdl-examples/bug-tests/create-configuration-nested-ids-1112.mdl @@ -0,0 +1,33 @@ +mdl 1; +-- Bug: `create configuration` refused on Mendix 11.12+ projects +-- +-- Symptom (testdata/testapp-views, Mendix 11.14.0): +-- $ mxcli -p TestApp.mpr -c "create configuration 'Staging'" +-- Error: failed to update project settings: refusing to write unit …: 3 element id(s) +-- are used more than once, which produces a project that cannot be opened +-- … held by [Settings$OpenTelemetryConfiguration Settings$OpenTelemetryConfiguration] +-- … held by [Settings$OpenTelemetryLogsConfiguration …] +-- … held by [Settings$OpenTelemetryTracesConfiguration …] +-- +-- A new configuration copies an existing one as its shape template. From 11.12 a +-- configuration Studio Pro has saved carries a nested OpenTelemetry part whose +-- elements — the part and its Logs / Traces children — have $IDs of their own, and +-- the copy kept them. The duplicate-id guard refused the write (correctly: Studio +-- Pro refuses a unit holding one id twice). +-- +-- Fix: the template is deep-copied with a fresh $ID for every nested storage +-- object (settingsoverlay.cloneWithFreshIDs); the values are still inherited. +-- +-- Check the subject: a blank `mxcli new` 11.14 app stores OpenTelemetry = null, so +-- it creates a configuration fine even without the fix. Only a configuration Studio +-- Pro has saved reproduces it. Measured on testapp-views: `mx check` reports the +-- same errors after this script as before it. +-- +-- Manual verification: +-- mxcli exec create-configuration-nested-ids-1112.mdl -p app.mpr (an 11.12+ app +-- whose Default configuration was saved by Studio Pro) + +create configuration 'Staging' ( + DatabaseType: 'PostgreSql', + HttpPortNumber: 8081 +); diff --git a/mdl/backend/modelsdk/settings_create_configuration_test.go b/mdl/backend/modelsdk/settings_create_configuration_test.go new file mode 100644 index 0000000000..72ad858088 --- /dev/null +++ b/mdl/backend/modelsdk/settings_create_configuration_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mendixlabs/mxcli/model" +) + +// `create configuration 'Staging'` on a Mendix 11.14 project was refused with +// "3 element id(s) are used more than once": the new configuration copies the +// Default one as its shape template, and on 11.12+ that one carries a nested +// OpenTelemetry part (and its Logs / Traces children) with $IDs of its own. The +// 11.6 and 11.13 fixtures have no such part, which is why it went unnoticed. +func TestUpdateProjectSettings_CreateConfigurationOn1114(t *testing.T) { + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS("../../../testdata/testapp-views")); err != nil { + t.Fatalf("copy fixture: %v", err) + } + proj := filepath.Join(dst, "TestApp.mpr") + + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + ps, err := b.GetProjectSettings() + if err != nil { + t.Fatalf("GetProjectSettings: %v", err) + } + staging := &model.ServerConfiguration{ + Name: "Staging", DatabaseType: "Hsqldb", HttpPortNumber: 8080, ServerPortNumber: 8090, + } + staging.TypeName = "Settings$ServerConfiguration" + ps.Configuration.Configurations = append(ps.Configuration.Configurations, staging) + if err := b.UpdateProjectSettings(ps); err != nil { + t.Fatalf("UpdateProjectSettings refused a new configuration: %v", err) + } + if err := b.Disconnect(); err != nil { + t.Fatalf("disconnect: %v", err) + } + + b2 := New() + if err := b2.Connect(proj); err != nil { + t.Fatalf("reconnect: %v", err) + } + t.Cleanup(func() { _ = b2.Disconnect() }) + ps2, err := b2.GetProjectSettings() + if err != nil { + t.Fatalf("GetProjectSettings(2): %v", err) + } + var names []string + for _, c := range ps2.Configuration.Configurations { + names = append(names, c.Name) + } + if len(names) != 2 || names[1] != "Staging" { + t.Errorf("configurations read back as %v, want [Default Staging]", names) + } +} diff --git a/mdl/settingsoverlay/new_configuration_ids_test.go b/mdl/settingsoverlay/new_configuration_ids_test.go new file mode 100644 index 0000000000..51637a68d0 --- /dev/null +++ b/mdl/settingsoverlay/new_configuration_ids_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package settingsoverlay + +import ( + "strconv" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/model" +) + +// collectIDs returns every $ID in a stored document tree, with the path it sits at. +func collectIDs(v any, path string, into map[string]any) { + if m, ok := AsMap(v); ok { + for k, child := range m { + if k == "$ID" { + into[path] = child + continue + } + collectIDs(child, path+"/"+k, into) + } + return + } + if arr, ok := v.(bson.A); ok { + for i, el := range arr { + collectIDs(el, path+"/"+strconv.Itoa(i), into) + } + } +} + +// A new configuration copies a sibling as its shape template. On Mendix 11.12+ +// that sibling carries a nested OpenTelemetry part whose elements have $IDs of +// their own; copying them made the unit hold the same id twice, and the write +// path refused it ("element id(s) are used more than once") — so `create +// configuration` failed on every 11.12+ project whose configuration had one. +func TestConfigurations_NewConfigurationMintsFreshNestedIDs(t *testing.T) { + otel := func() bson.M { + return bson.M{ + "$ID": "otel", + "$Type": "Settings$OpenTelemetryConfiguration", + "Enabled": false, + "Endpoint": "http://localhost:4318", + "Logs": bson.M{"$ID": "otel-logs", "$Type": "Settings$OpenTelemetryLogsConfiguration", "Enabled": true}, + "Traces": bson.M{"$ID": "otel-traces", "$Type": "Settings$OpenTelemetryTracesConfiguration", "Enabled": true}, + } + } + part := map[string]any{ + "Configurations": bson.A{ + int32(3), + bson.M{"$ID": "id-default", "Name": "Default", "OpenTelemetry": otel()}, + }, + } + cs := &model.ConfigurationSettings{Configurations: []*model.ServerConfiguration{ + {Name: "Default"}, + {Name: "Staging"}, + }} + + cfgs := ArrayElements(Configurations(cs, part)["Configurations"]) + if len(cfgs) != 2 { + t.Fatalf("got %d configurations, want 2", len(cfgs)) + } + seen := map[any]string{} + for _, c := range cfgs { + ids := map[string]any{} + collectIDs(c, c["Name"].(string), ids) + for path, id := range ids { + key := idKey(id) + if other, dup := seen[key]; dup { + t.Errorf("$ID used twice: %s and %s", other, path) + } + seen[key] = path + } + } + + added, stored := cfgs[1], cfgs[0] + if added["Name"] != "Staging" || stored["Name"] != "Default" { + t.Fatalf("unexpected order: %v, %v", added["Name"], stored["Name"]) + } + // The values are still inherited — only the identities are new. + o, ok := AsMap(added["OpenTelemetry"]) + if !ok || o["Endpoint"] != "http://localhost:4318" || o["$Type"] != "Settings$OpenTelemetryConfiguration" { + t.Errorf("OpenTelemetry not inherited from the sibling: %#v", added["OpenTelemetry"]) + } + if logs, ok := AsMap(o["Logs"]); !ok || logs["Enabled"] != true { + t.Errorf("OpenTelemetry.Logs not inherited: %#v", o["Logs"]) + } + // And the sibling used as the template keeps its own. + so, _ := AsMap(stored["OpenTelemetry"]) + if so["$ID"] != "otel" { + t.Errorf("template sibling's OpenTelemetry $ID changed to %#v", so["$ID"]) + } + if sl, _ := AsMap(so["Logs"]); sl["$ID"] != "otel-logs" { + t.Errorf("template sibling's Logs $ID changed to %#v", sl["$ID"]) + } +} + +// idKey makes an $ID comparable whatever its stored form. +func idKey(id any) any { + if b, ok := id.(bson.Binary); ok { + return string(b.Data) + } + return id +} diff --git a/mdl/settingsoverlay/settingsoverlay.go b/mdl/settingsoverlay/settingsoverlay.go index c84437f66a..a11bfa3757 100644 --- a/mdl/settingsoverlay/settingsoverlay.go +++ b/mdl/settingsoverlay/settingsoverlay.go @@ -111,6 +111,12 @@ func ServerConfiguration(cfg *model.ServerConfiguration, raw map[string]any, sib // sibling deliberately: it is version-specific (Mendix 11.6 stores "Tracing", 11.12 // "OpenTelemetry") and a new configuration has no better default to offer. // +// Inherited by value, not by identity: every storage object nested in the copy +// gets a fresh $ID. On 11.12+ the OpenTelemetry part and its Logs / Traces children +// carry $IDs of their own, and sharing them put each id in the unit twice — the +// write path refused it ("element id(s) are used more than once"), so `create +// configuration` failed on every such project. +// // With no sibling to copy there is no way to know which spelling this version // expects, so the fallback writes neither: a property the metamodel does not define // is what Studio Pro chokes on (see JavaVersionKey and mendixlabs/mxcli#759), while @@ -119,9 +125,11 @@ func newServerConfiguration(cfg *model.ServerConfiguration, siblings []map[strin if len(siblings) > 0 { tmpl := make(map[string]any, len(siblings[0])) for k, v := range siblings[0] { - tmpl[k] = v + if k == "$ID" { + continue // minted by ServerConfiguration + } + tmpl[k] = cloneWithFreshIDs(v) } - delete(tmpl, "$ID") tmpl["ConstantValues"] = bson.A{ArrayMarker(siblings[0]["ConstantValues"], DefaultListMarker)} tmpl["CustomSettings"] = bson.A{ArrayMarker(siblings[0]["CustomSettings"], DefaultListMarker)} return tmpl @@ -134,6 +142,31 @@ func newServerConfiguration(cfg *model.ServerConfiguration, siblings []map[strin } } +// cloneWithFreshIDs deep-copies a stored value, minting a new $ID for every +// storage object inside it. The copy shares no document with the original, so a +// later overlay of one cannot change the other. +func cloneWithFreshIDs(v any) any { + if m, ok := AsMap(v); ok { + out := make(map[string]any, len(m)) + for k, child := range m { + if k == "$ID" { + out[k] = bsonutil.NewIDBsonBinary() + continue + } + out[k] = cloneWithFreshIDs(child) + } + return out + } + if arr, ok := v.(bson.A); ok { + out := make(bson.A, len(arr)) + for i, el := range arr { + out[i] = cloneWithFreshIDs(el) + } + return out + } + return v +} + // The two spellings of the runtime Java version property. They differ in value // format as well as in name — see JavaVersionValue. const ( From a4f9ecb61fceba8594feed56c3c47dafdc32abf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:12:13 +0000 Subject: [PATCH 45/50] feat: alter settings configuration sets MaxJavaHeapSize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio Pro's Configuration ▸ Server ▸ Java heap ▸ Maximum size (in MB) sits beside Extra JVM parameters (mendixlabs/mxcli#131). It was read and written back, but `alter settings configuration` and `create configuration` refused the key and DESCRIBE omitted it. Both statements now accept it (Integer, MDL-SET01 at check time), and DESCRIBE emits it when it is not 0 — the stored configuration has no Default/Custom flag, so 0 is Studio Pro's "Default". No version gate: the Model SDK has no versionInfo entry for maxJavaHeapSize. Measured on a blank 11.14.0 app: `mx check` reports 0 errors after setting it to 2048. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- .../skills/mendix/project-settings/SKILL.md | 16 ++-- .../src/reference/settings/alter-settings.md | 2 +- docs-site/src/tools/builtin-rules.md | 2 +- .../131-alter-settings-max-java-heap-size.mdl | 20 +++++ mdl/executor/cmd_settings.go | 23 +++++- mdl/executor/cmd_settings_heap_test.go | 74 +++++++++++++++++++ mdl/executor/validate_settings.go | 1 + 7 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 mdl-examples/bug-tests/131-alter-settings-max-java-heap-size.mdl create mode 100644 mdl/executor/cmd_settings_heap_test.go diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index 6020e8701b..8628079664 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-32 | When to Use This Skill | -| 33-339 | Commands | -| 340-367 | Common Patterns | -| 368-376 | Checklist | -| 377-381 | AfterStartupMicroflow Must Return Boolean | +| 33-341 | Commands | +| 342-369 | Common Patterns | +| 370-378 | Checklist | +| 379-383 | AfterStartupMicroflow Must Return Boolean | # Project Settings @@ -141,12 +141,14 @@ alter settings configuration 'Default' ( The configuration keys are `DatabaseType`, `DatabaseUrl`, `DatabaseName`, `DatabaseUserName`, `DatabasePassword`, `HttpPortNumber`, `ServerPortNumber`, -`ApplicationRootUrl` and `ExtraJvmParameters`; `create configuration` takes the same. +`ApplicationRootUrl`, `ExtraJvmParameters` and `MaxJavaHeapSize` (Server ▸ Java heap +▸ Maximum size, in MB; `0` is Studio Pro's "Default"); `create configuration` takes +the same. `ExtraJvmParameters` is what Studio Pro passes to the JVM when it runs the app — `mxcli run --local` starts the runtime itself and does not apply it. -`HttpPortNumber`, `ServerPortNumber`, `BcryptCost`, `DefaultTaskParallelism` and -`WorkflowEngineParallelism` and `DecimalScale` are Integer-typed; +`HttpPortNumber`, `ServerPortNumber`, `MaxJavaHeapSize`, `BcryptCost`, +`DefaultTaskParallelism`, `WorkflowEngineParallelism` and `DecimalScale` are Integer-typed; `AllowUserMultipleSessions`, `EnableDataStorageOptimisticLocking`, `UseDatabaseForeignKeyConstraints` and `UseOQLVersion2` are Boolean; `FirstDayOfWeek` and `SslCertificateAlgorithm` are enumerations, matched case-insensitively and diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index 6a7af79777..8cbc065fe9 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -32,7 +32,7 @@ Modifies project settings by category. Each category has its own syntax and avai **MODEL** settings control application-level behavior such as the after-startup microflow, hashing algorithm, and Java version. -**CONFIGURATION** settings control named runtime configurations. Each project can have multiple configurations (e.g., `default`, `staging`, `production`). The keys are `DatabaseType`, `DatabaseUrl`, `DatabaseName`, `DatabaseUserName`, `DatabasePassword`, `HttpPortNumber`, `ServerPortNumber`, `ApplicationRootUrl` and `ExtraJvmParameters` (Studio Pro's Server ▸ Extra JVM parameters). The configuration name must be enclosed in single quotes. +**CONFIGURATION** settings control named runtime configurations. Each project can have multiple configurations (e.g., `default`, `staging`, `production`). The keys are `DatabaseType`, `DatabaseUrl`, `DatabaseName`, `DatabaseUserName`, `DatabasePassword`, `HttpPortNumber`, `ServerPortNumber`, `ApplicationRootUrl`, `ExtraJvmParameters` (Studio Pro's Server ▸ Extra JVM parameters) and `MaxJavaHeapSize` (Server ▸ Java heap ▸ Maximum size, in MB; `0` is Studio Pro's "Default"). The configuration name must be enclosed in single quotes. **CONSTANT** settings override the default value of a project constant within a specific runtime configuration. The constant is named as MDL names a constant everywhere, `@Module.Name`; the configuration name is enclosed in single quotes. The older quoted constant name (`CONSTANT 'Module.Name'`) still parses and warns `MDL-DEPR085`; `mxcli fmt --upgrade` rewrites it. diff --git a/docs-site/src/tools/builtin-rules.md b/docs-site/src/tools/builtin-rules.md index 259ded72d6..5cfc144148 100644 --- a/docs-site/src/tools/builtin-rules.md +++ b/docs-site/src/tools/builtin-rules.md @@ -97,7 +97,7 @@ These rules run with `mxcli check` (and the LSP, for real-time diagnostics) rath |------|----------------|-------------| | **MDL-WIDGET01** | `mxcli check` + LSP | Unknown property key on a pluggable widget. The property is not in the widget's `.def.json`. Catches typos like `optionsSourcType` (missing `e`) before MxBuild does. Suggests the nearest known key. | | **MDL-WIDGET02** | `mxcli check --post-migration` | Legacy native widget found on a project that has a pluggable replacement available. Reports each occurrence with the qualified document name, widget instance name, and the recommended pluggable widget. | -| **MDL-SET01** | `mxcli check` + LSP | Non-integer value for an Integer-typed project setting (`HttpPortNumber`, `ServerPortNumber`, `BcryptCost`, `DefaultTaskParallelism`, `WorkflowEngineParallelism`). These used to be skipped silently while the statement still reported success. | +| **MDL-SET01** | `mxcli check` + LSP | Non-integer value for an Integer-typed project setting (`HttpPortNumber`, `ServerPortNumber`, `MaxJavaHeapSize`, `BcryptCost`, `DefaultTaskParallelism`, `WorkflowEngineParallelism`). These used to be skipped silently while the statement still reported success. | | **MDL-I18N01** | `mxcli check -p` | A script that changes `DefaultLanguageCode` leaves a tab page caption — stored in the project, or created by the script before the change — with no text in the new default. MxBuild reports CE4899 "Empty caption". See [Error Messages](../appendixes/error-messages.md#mdl-i18n01-a-tab-page-caption-without-the-default-language-ce4899). | | **MDL-SET02** | `mxcli check` + LSP | Value other than `true` / `false` for a Boolean-typed project setting (`AllowUserMultipleSessions`). Anything else was silently stored as `false`. | | **MDL-SET04** | `mxcli check` + LSP | `ALTER SETTINGS` section or key that does not exist (`alter settings nosuchsection (…)`, a misspelled `ExtraJvmParams`), or a verb the section does not define. Reported with the message `exec` refuses it with, and the valid keys; `check` used to pass these and leave `exec` to stop halfway through the script. | diff --git a/mdl-examples/bug-tests/131-alter-settings-max-java-heap-size.mdl b/mdl-examples/bug-tests/131-alter-settings-max-java-heap-size.mdl new file mode 100644 index 0000000000..6024fbfa3e --- /dev/null +++ b/mdl-examples/bug-tests/131-alter-settings-max-java-heap-size.mdl @@ -0,0 +1,20 @@ +mdl 1; +-- Follow-up to mendixlabs/mxcli#131: MaxJavaHeapSize could not be set +-- +-- MaxJavaHeapSize is Studio Pro's Configuration ▸ Server ▸ Java heap ▸ Maximum +-- size (in MB), on the same tab as Extra JVM parameters. The reader read it and +-- the overlay wrote it back, but `alter settings configuration` and `create +-- configuration` answered "unknown configuration setting: MaxJavaHeapSize", and +-- DESCRIBE left it out. The stored configuration has no Default/Custom flag: 0 is +-- Studio Pro's "Default", and DESCRIBE prints the key only when it is not 0. +-- +-- Measured on a blank 11.14.0 app: `mx check` reports 0 errors after this script. + +alter settings configuration 'Default' ( + MaxJavaHeapSize: 2048 +); + +-- Back to Studio Pro's "Default". +alter settings configuration 'Default' ( + MaxJavaHeapSize: 0 +); diff --git a/mdl/executor/cmd_settings.go b/mdl/executor/cmd_settings.go index b31b855bc8..e11f5f9932 100644 --- a/mdl/executor/cmd_settings.go +++ b/mdl/executor/cmd_settings.go @@ -363,9 +363,8 @@ var useOptimizedClientValues = []string{ } // configurationSettingKeys names every property ALTER SETTINGS CONFIGURATION and -// CREATE CONFIGURATION assign. DatabaseUseIntegratedSecurity, MaxJavaHeapSize and -// the Open*Port flags are read and written back as stored, but no statement sets -// them. +// CREATE CONFIGURATION assign. DatabaseUseIntegratedSecurity and the Open*Port +// flags are read and written back as stored, but no statement sets them. var configurationSettingKeys = []string{ "DatabaseType", "DatabaseUrl", @@ -376,6 +375,7 @@ var configurationSettingKeys = []string{ "ServerPortNumber", "ApplicationRootUrl", "ExtraJvmParameters", + "MaxJavaHeapSize", } // webUISettingKeys names every property ALTER SETTINGS WEBUI assigns. @@ -827,6 +827,14 @@ func alterSettingsConfiguration(ctx *ExecContext, ps *model.ProjectSettings, stm // verbatim; every supported Mendix version carries the property // (mendixlabs/mxcli#131). cfg.ExtraJvmParameters = valStr + case "MaxJavaHeapSize": + // Server ▸ Java heap ▸ Maximum size (in MB). The model has no + // Default/Custom flag: 0 is Studio Pro's "Default". + v, err := settingsInt(key, valStr) + if err != nil { + return err + } + cfg.MaxJavaHeapSize = v default: return unknownSettingsKey("configuration", key) } @@ -1028,6 +1036,12 @@ func createConfiguration(ctx *ExecContext, stmt *ast.CreateConfigurationStmt) er newCfg.ApplicationRootUrl = valStr case "ExtraJvmParameters": newCfg.ExtraJvmParameters = valStr + case "MaxJavaHeapSize": + v, err := settingsInt(key, valStr) + if err != nil { + return err + } + newCfg.MaxJavaHeapSize = v default: return unknownSettingsKey("configuration", key) } @@ -1171,6 +1185,9 @@ func writeSettingsConfiguration(ctx *ExecContext, cfg *model.ServerConfiguration if cfg.ExtraJvmParameters != "" { parts = append(parts, " ExtraJvmParameters: "+mdlQuoted(cfg.ExtraJvmParameters)) } + if cfg.MaxJavaHeapSize != 0 { + parts = append(parts, fmt.Sprintf(" MaxJavaHeapSize: %d", cfg.MaxJavaHeapSize)) + } if cfg.DatabasePassword != "" { fmt.Fprintf(ctx.Output, "-- DatabasePassword is set in configuration %s and is not printed; "+ "add `DatabasePassword: '…'` to set it on a project that lacks it.\n", mdlQuoted(cfg.Name)) diff --git a/mdl/executor/cmd_settings_heap_test.go b/mdl/executor/cmd_settings_heap_test.go new file mode 100644 index 0000000000..ef3c6772f7 --- /dev/null +++ b/mdl/executor/cmd_settings_heap_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" +) + +// MaxJavaHeapSize is Studio Pro's Configuration ▸ Server ▸ Java heap ▸ Maximum +// size (in MB), beside the Extra JVM parameters of mendixlabs/mxcli#131. The +// stored configuration has no separate Default/Custom flag: 0 is Default. It was +// read and written back, but no statement could set it and describe omitted it. + +func TestAlterSettingsConfiguration_MaxJavaHeapSize(t *testing.T) { + ctx, written := jvmParamsCtx(t, &model.ServerConfiguration{Name: "Default", DatabaseType: "Hsqldb"}) + stmt := parseAlterSettings(t, "mdl 1;\nalter settings configuration 'Default' (MaxJavaHeapSize: 2048);") + if err := alterSettings(ctx, stmt); err != nil { + t.Fatalf("alter settings configuration refused MaxJavaHeapSize: %v", err) + } + if got := (*written).Configuration.Configurations[0].MaxJavaHeapSize; got != 2048 { + t.Errorf("MaxJavaHeapSize = %d, want 2048", got) + } +} + +func TestCreateConfiguration_MaxJavaHeapSize(t *testing.T) { + ctx, written := jvmParamsCtx(t, &model.ServerConfiguration{Name: "Default"}) + if err := createConfiguration(ctx, &ast.CreateConfigurationStmt{ + Name: "Big", Properties: map[string]any{"MaxJavaHeapSize": "4096"}, + }); err != nil { + t.Fatalf("create configuration refused MaxJavaHeapSize: %v", err) + } + if cfgs := (*written).Configuration.Configurations; len(cfgs) != 2 || cfgs[1].MaxJavaHeapSize != 4096 { + t.Fatalf("configurations = %+v, want Big with a 4096 MB heap", cfgs) + } +} + +func TestDescribeSettings_MaxJavaHeapSizeRoundTrips(t *testing.T) { + cfg := &model.ServerConfiguration{ + Name: "Default", DatabaseType: "Hsqldb", DatabaseName: "default", + HttpPortNumber: 8080, ServerPortNumber: 8090, MaxJavaHeapSize: 1024, + } + ctx, _ := jvmParamsCtx(t, cfg) + buf := ctx.Output.(*bytes.Buffer) + assertNoError(t, describeSettings(ctx, "Default")) + out := buf.String() + if !strings.Contains(out, "MaxJavaHeapSize: 1024") { + t.Fatalf("describe does not carry MaxJavaHeapSize:\n%s", out) + } + stmt := findStmt[*ast.CreateConfigurationStmt](t, reparse(t, out), out) + target := &model.ServerConfiguration{Name: "Default", DatabaseType: "Hsqldb", DatabaseName: "default", + HttpPortNumber: 8080, ServerPortNumber: 8090} + tctx, _ := jvmParamsCtx(t, target) + assertNoError(t, createConfiguration(tctx, stmt)) + if !reflect.DeepEqual(*target, *cfg) { + t.Errorf("replaying describe output did not reproduce the configuration:\nwant %+v\ngot %+v\n%s", *cfg, *target, out) + } +} + +// 0 is Studio Pro's "Default" heap and is not printed, so a blank project's +// describe output is unchanged. +func TestDescribeSettings_DefaultHeapNotPrinted(t *testing.T) { + ctx, _ := jvmParamsCtx(t, &model.ServerConfiguration{Name: "Default", DatabaseType: "Hsqldb"}) + buf := ctx.Output.(*bytes.Buffer) + assertNoError(t, describeSettings(ctx, "Default")) + if strings.Contains(buf.String(), "MaxJavaHeapSize") { + t.Errorf("default heap printed:\n%s", buf.String()) + } +} diff --git a/mdl/executor/validate_settings.go b/mdl/executor/validate_settings.go index bfbcfdd761..b62fca8c01 100644 --- a/mdl/executor/validate_settings.go +++ b/mdl/executor/validate_settings.go @@ -48,6 +48,7 @@ var typedSettingsKeys = map[string]map[string]settingsValueKind{ "configuration": { "HttpPortNumber": settingsKindInt, "ServerPortNumber": settingsKindInt, + "MaxJavaHeapSize": settingsKindInt, "DatabaseType": settingsKindDatabaseType, }, "webui": { From ca32d59edd8e6585b947ae75d5db9a73d25d2a55 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:21:56 +0000 Subject: [PATCH 46/50] fix: run --local passes the configuration's JVM options to the runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A configuration's Extra JVM parameters — the proxy mendixlabs/mxcli#131 was filed for — and its Java heap maximum are what Studio Pro passes to the runtime JVM. `mxcli run --local` and `test --local` started the JVM with a fixed argument list, so both were silently absent from every local run. The run now takes them from the same configuration it takes the constant values from (chooseConfiguration, extracted from resolveConstantOverrides, and carried on the same constantChain, so the project is read once): `-Xmxm` for a non-zero MaxJavaHeapSize, then the parameters split like a shell would (a quoted run stays one argument), after mxcli's own -D flags and before -jar. It prints what it passes, without -D values, and refuses an unbalanced quote before booting. Verified on the process: booted a blank 11.14.0 app with heap 768 and `-Dmxcli.probe="from the configuration" -Dhttps.proxyPort=29758`; the runtime JVM's /proc//cmdline held -Xmx768m, the probe as one argument and the port, all before -jar. The previous build's JVM held none of them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- ...cal-ignores-configuration-jvm-options.json | 1 + .../skills/mendix/project-settings/SKILL.md | 13 +- .claude/skills/mendix/run-local/SKILL.md | 27 +++- cmd/mxcli/cmd_run.go | 2 + cmd/mxcli/cmd_test_run.go | 2 + cmd/mxcli/constants_resolve.go | 11 ++ cmd/mxcli/docker/localapp.go | 5 + cmd/mxcli/docker/localboot.go | 14 +- cmd/mxcli/docker/localboot_jvmargs_test.go | 49 +++++++ cmd/mxcli/docker/runlocal.go | 4 + cmd/mxcli/runconstants.go | 68 +++++----- cmd/mxcli/runjvm.go | 100 +++++++++++++++ cmd/mxcli/runjvm_test.go | 120 ++++++++++++++++++ cmd/mxcli/testrunner/localapp_options.go | 1 + cmd/mxcli/testrunner/localapp_options_test.go | 11 ++ cmd/mxcli/testrunner/runner.go | 5 + .../src/reference/settings/alter-settings.md | 5 +- ...31-alter-settings-extra-jvm-parameters.mdl | 4 +- 18 files changed, 391 insertions(+), 51 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-run-local-ignores-configuration-jvm-options.json create mode 100644 cmd/mxcli/docker/localboot_jvmargs_test.go create mode 100644 cmd/mxcli/runjvm.go create mode 100644 cmd/mxcli/runjvm_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-run-local-ignores-configuration-jvm-options.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-run-local-ignores-configuration-jvm-options.json new file mode 100644 index 0000000000..dbc212c162 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-23-run-local-ignores-configuration-jvm-options.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-23", "symptom": "A configuration's Extra JVM parameters (e.g. `-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758`, the proxy mendixlabs/mxcli#131 was filed for) and its Java heap maximum were set in the model and shown by `describe settings`, yet absent from every `mxcli run --local` / `test --local` app — silently", "cause": "LocalRuntimeOptions.jvmArgs was a fixed list (mxcli's two -D flags, then -jar). Nothing read ServerConfiguration.ExtraJvmParameters or MaxJavaHeapSize on the run path, although the configuration was already resolved there for its constant values", "file": "cmd/mxcli/runjvm.go (configurationJVMArgs, splitJVMParameters, reportJVMArgs); cmd/mxcli/runconstants.go (chooseConfiguration, extracted); cmd/mxcli/constants_resolve.go (constantChain.JVMArgs); cmd/mxcli/docker/localboot.go (LocalRuntimeOptions.JVMArgs, jvmArgs), localapp.go, runlocal.go; cmd/mxcli/testrunner (RunOptions.JVMArgs); cmd_run.go, cmd_test_run.go", "insight": "Same shape as the constants of FINDINGS §33: a configuration value reaches Studio Pro's run and not mxcli's, and nothing errors. Resolve it from the SAME configuration choice and the same project read as the constants (chooseConfiguration, carried on constantChain) — a second resolver would drift, a second read would open the project twice. Verify on the process, not the options struct: booted the blank 11.14.0 app with MaxJavaHeapSize 768 and `-Dmxcli.probe=\"from the configuration\" -Dhttps.proxyPort=29758`; /proc//cmdline of the runtime JVM held -Xmx768m, `-Dmxcli.probe=from the configuration` (one argument, quotes resolved) and the port, all before -jar; the main build's JVM held none of them. Order is load-bearing: after -jar they are the runtime's arguments, not the JVM's; after mxcli's own -D flags so a deliberate property wins; heap before parameters so an explicit -Xmx wins. The report omits -D values (a trust-store password goes there too; run output reaches CI logs). An unbalanced quote is refused before boot rather than passed on split wrongly. `mxcli docker run` is a separate path and not covered", "refs": ["mendixlabs/mxcli#131", "ako/mxcli#1225"]} diff --git a/.claude/skills/mendix/project-settings/SKILL.md b/.claude/skills/mendix/project-settings/SKILL.md index 8628079664..9f467cdca3 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-32 | When to Use This Skill | -| 33-341 | Commands | -| 342-369 | Common Patterns | -| 370-378 | Checklist | -| 379-383 | AfterStartupMicroflow Must Return Boolean | +| 33-342 | Commands | +| 343-370 | Common Patterns | +| 371-379 | Checklist | +| 380-384 | AfterStartupMicroflow Must Return Boolean | # Project Settings @@ -144,8 +144,9 @@ The configuration keys are `DatabaseType`, `DatabaseUrl`, `DatabaseName`, `ApplicationRootUrl`, `ExtraJvmParameters` and `MaxJavaHeapSize` (Server ▸ Java heap ▸ Maximum size, in MB; `0` is Studio Pro's "Default"); `create configuration` takes the same. -`ExtraJvmParameters` is what Studio Pro passes to the JVM when it runs the app — -`mxcli run --local` starts the runtime itself and does not apply it. +`ExtraJvmParameters` and `MaxJavaHeapSize` are what Studio Pro passes to the runtime +JVM when it runs the app; `mxcli run --local` and `mxcli test --local` pass them too +(see the run-local skill). `HttpPortNumber`, `ServerPortNumber`, `MaxJavaHeapSize`, `BcryptCost`, `DefaultTaskParallelism`, `WorkflowEngineParallelism` and `DecimalScale` are Integer-typed; diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index 9c1d086e81..d7ee14d1fd 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -28,11 +28,11 @@ description: "The warm Docker-free dev loop, `mxcli run --local` — model chang | 507-539 | The same licence also stops the app after a few hours | | 540-619 | External browser preview (`--hub`) | | 620-628 | Validation checklist | -| 629-668 | Constant values come from a configuration | -| 669-672 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | -| 673-676 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | -| 677-680 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | -| 681-683 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | +| 629-685 | Constant values come from a configuration | +| 686-689 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | +| 690-693 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | +| 694-697 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | +| 698-700 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | # Warm Local Dev Loop — `mxcli run --local` @@ -656,6 +656,23 @@ encryption key while the model said otherwise. runtime ignores a value for a constant that does not exist, so a typo would otherwise be reported as applied and do nothing. +The same configuration's **JVM options** reach the runtime JVM too, as they do when +Studio Pro runs the app: `MaxJavaHeapSize` (Java heap ▸ Maximum size, in MB) as +`-Xmxm`, then the **Extra JVM parameters**, split like a shell would split them +(a quoted run stays one argument). Set them with +`alter settings configuration 'Default' (ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888')`. + +```text +Passing 3 JVM option(s) from configuration "Default": -Xmx768m -Dhttps.proxyHost=… -Dhttps.proxyPort=… +``` + +- A `-D` value is not printed (the parameters are also where a trust-store password + goes); `describe settings` shows them. Nothing set, nothing printed. +- They go after mxcli's own `-D` flags, so a property the configuration sets wins. +- An unbalanced quote is refused before the boot, naming the configuration — + passed on, it would cut a property in half or stop the JVM starting. +- `mxcli test --local` boots with the same options. + Setting a constant on an app that is *already running* is a different mechanism again: `MicroflowConstants` over the M2EE admin port, which is how Mendix Cloud injects per-environment values. Measured on 11.12.1, `update_configuration` is diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index bc31a39b98..32018d5b74 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -240,10 +240,12 @@ Examples: os.Exit(1) } reportConstantChain(os.Stdout, overrides) + reportJVMArgs(os.Stdout, overrides) opts := docker.LocalRunOptions{ ProjectPath: projectPath, ConstantOverrides: overrides.Values, + JVMArgs: overrides.JVMArgs, Hub: hub, HubSecret: hubSecret, HubKey: hubKey, diff --git a/cmd/mxcli/cmd_test_run.go b/cmd/mxcli/cmd_test_run.go index 9a2e883a9c..6fde431228 100644 --- a/cmd/mxcli/cmd_test_run.go +++ b/cmd/mxcli/cmd_test_run.go @@ -236,7 +236,9 @@ Examples: os.Exit(1) } reportConstantChain(os.Stdout, overrides) + reportJVMArgs(os.Stdout, overrides) opts.ConstantOverrides = overrides.Values + opts.JVMArgs = overrides.JVMArgs } else if len(constantArgs) > 0 { // --attach runs against an app someone else booted, and the Docker path // configures the container. Accepting --constant there and doing nothing diff --git a/cmd/mxcli/constants_resolve.go b/cmd/mxcli/constants_resolve.go index cb3633a182..98557805ad 100644 --- a/cmd/mxcli/constants_resolve.go +++ b/cmd/mxcli/constants_resolve.go @@ -41,6 +41,10 @@ type constantChain struct { Private []string // overrides whose value is not in the model Stale []string // machine-store entries naming no constant of this project Note string // why no configuration contributed + // JVMArgs are the same configuration's JVM options (runjvm.go). They ride on + // the chain because they come from the one configuration choice and the one + // project read; a second read would cost a second open of the project. + JVMArgs []string } // parseConstantFlags turns repeated `--constant Module.Name=value` into a map. @@ -89,6 +93,13 @@ func resolveConstantChain(ps *model.ProjectSettings, want string, flags, machine chain.Values[name] = value chain.From[name] = layerConfiguration } + if chosen, _ := chooseConfiguration(ps, want); chosen != nil { + args, err := configurationJVMArgs(chosen) + if err != nil { + return constantChain{}, err + } + chain.JVMArgs = args + } var unknown []string for name := range flags { diff --git a/cmd/mxcli/docker/localapp.go b/cmd/mxcli/docker/localapp.go index 4e2f323787..c224e28e29 100644 --- a/cmd/mxcli/docker/localapp.go +++ b/cmd/mxcli/docker/localapp.go @@ -56,6 +56,10 @@ type LocalAppOptions struct { // resolving to the wrong value is not an error. See // docs/11-proposals/PROPOSAL_constant_values.md and mxcli-chat FINDINGS §33. ConstantOverrides map[string]string + // JVMArgs are the configuration's JVM options, as LocalRuntimeOptions.JVMArgs + // — forwarded for the same reason as ConstantOverrides: a headless boot must + // run the configuration `run --local` runs. + JVMArgs []string // Stdout/Stderr receive progress messages. Stdout io.Writer Stderr io.Writer @@ -79,6 +83,7 @@ func (o LocalAppOptions) runtimeOptions(installPath string) LocalRuntimeOptions RuntimeLogPath: o.RuntimeLogPath, Env: o.Env, ConstantOverrides: o.ConstantOverrides, + JVMArgs: o.JVMArgs, Stdout: o.Stdout, Stderr: o.Stderr, } diff --git a/cmd/mxcli/docker/localboot.go b/cmd/mxcli/docker/localboot.go index e7980e5a7c..0a2fa3cf30 100644 --- a/cmd/mxcli/docker/localboot.go +++ b/cmd/mxcli/docker/localboot.go @@ -97,6 +97,11 @@ type LocalRuntimeOptions struct { // merged over the defaults mxbuild wrote into the deployment. See // mergeConstantOverrides. ConstantOverrides map[string]string + // JVMArgs are the running configuration's JVM options — its Java heap + // maximum and Extra JVM parameters, already split into arguments — which + // Studio Pro passes to the runtime JVM. They go after mxcli's own -D flags and + // before -jar. + JVMArgs []string // Trace attaches the bundled OpenTelemetry Java agent to the runtime JVM // (traces via the console exporter → the tee'd runtime log). The caller should // also set OpenTelemetry._RuntimeSpanFilters via RuntimeSettings — unfiltered @@ -259,12 +264,17 @@ func (o *LocalRuntimeOptions) launcherJar() string { // silently returns 0 rows (findings #36). `run --local` is always a development // loop (it forces DTAPMode=D), so enabling live preview unconditionally matches // what docker mode already does. +// +// The configuration's JVMArgs come after those flags, so a property the +// configuration sets deliberately wins (the JVM keeps the last -D for a name), +// and before -jar, after which they would be the runtime's arguments instead. func (o *LocalRuntimeOptions) jvmArgs() []string { - return []string{ + args := []string{ "-Dmendix.live-preview=enabled", "-Dmendix.running.locally.by.studiopro=true", - "-jar", o.launcherJar(), o.DeployDir, } + args = append(args, o.JVMArgs...) + return append(args, "-jar", o.launcherJar(), o.DeployDir) } // m2eeAdminListenAddr is the address the M2EE admin API binds to, and the diff --git a/cmd/mxcli/docker/localboot_jvmargs_test.go b/cmd/mxcli/docker/localboot_jvmargs_test.go new file mode 100644 index 0000000000..84f3fcadfa --- /dev/null +++ b/cmd/mxcli/docker/localboot_jvmargs_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "slices" + "testing" +) + +// mendixlabs/mxcli#131 follow-up: a configuration's Extra JVM parameters (and its +// Java heap maximum) are what Studio Pro passes to the runtime JVM. `run --local` +// started the JVM with a fixed argument list, so a proxy set in the model with +// `alter settings configuration 'Default' (ExtraJvmParameters: …)` was silently +// absent from a local run. +func TestJVMArgs_CarriesConfigurationParameters(t *testing.T) { + o := testLocalOpts() + o.JVMArgs = []string{"-Xmx2048m", "-Dhttps.proxyHost=127.0.0.1", "-Dhttps.proxyPort=29758"} + args := o.jvmArgs() + + jar := slices.Index(args, "-jar") + if jar < 0 { + t.Fatalf("no -jar in %v", args) + } + for _, want := range o.JVMArgs { + i := slices.Index(args, want) + if i < 0 { + t.Errorf("jvmArgs missing %q; got %v", want, args) + continue + } + // After -jar they would be the runtime's arguments, not the JVM's. + if i > jar { + t.Errorf("%q comes after -jar, so the JVM never sees it: %v", want, args) + } + } + // After mxcli's own -D flags, so a parameter the configuration sets + // deliberately wins (the JVM takes the last -D for a property). + if slices.Index(args, "-Dhttps.proxyHost=127.0.0.1") < slices.Index(args, "-Dmendix.live-preview=enabled") { + t.Errorf("configuration parameters precede mxcli's defaults: %v", args) + } +} + +// `mxcli test --local` boots through LocalAppOptions; it has to run with the same +// JVM parameters as `run --local`, as it already does with the same constants. +func TestLocalAppOptions_ForwardsJVMArgs(t *testing.T) { + opts := LocalAppOptions{DeployDir: "/tmp/app/deployment", JVMArgs: []string{"-Xmx1024m"}} + if rt := opts.runtimeOptions("/install/path"); !slices.Equal(rt.JVMArgs, opts.JVMArgs) { + t.Fatalf("JVMArgs = %v, want %v to reach the runtime", rt.JVMArgs, opts.JVMArgs) + } +} diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 15ca890ff1..51de11feb2 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -54,6 +54,9 @@ type LocalRunOptions struct { // wrote into the deployment. Empty means "every constant keeps its default", // which is what a local run always did before — see runconstants.go. ConstantOverrides map[string]string + // JVMArgs are that configuration's JVM options (Java heap maximum, Extra JVM + // parameters) for the runtime JVM — see LocalRuntimeOptions.JVMArgs. + JVMArgs []string // DeployDir is where the serve Deploy target writes (default /deployment). DeployDir string // MxBuildPath overrides mxbuild resolution (optional). @@ -896,6 +899,7 @@ func RunLocal(opts LocalRunOptions) error { TraceServiceName: traceService, TraceOTLPEndpoint: opts.TraceOTLP, ConstantOverrides: opts.ConstantOverrides, + JVMArgs: opts.JVMArgs, Env: opts.Env, Stdout: w, Stderr: stderr, diff --git a/cmd/mxcli/runconstants.go b/cmd/mxcli/runconstants.go index abb7e8f8b9..58b78ff6bb 100644 --- a/cmd/mxcli/runconstants.go +++ b/cmd/mxcli/runconstants.go @@ -49,42 +49,11 @@ type constantOverrides struct { // values. want names one explicitly; empty means "pick the obvious one". func resolveConstantOverrides(ps *model.ProjectSettings, want string) constantOverrides { out := constantOverrides{Values: map[string]string{}} - if ps == nil || ps.Configuration == nil || len(ps.Configuration.Configurations) == 0 { - out.Note = "the project has no configurations" + chosen, note := chooseConfiguration(ps, want) + if chosen == nil { + out.Note = note return out } - cfgs := ps.Configuration.Configurations - - var chosen *model.ServerConfiguration - switch { - case want != "": - for _, c := range cfgs { - if strings.EqualFold(c.Name, want) { - chosen = c - break - } - } - if chosen == nil { - out.Note = fmt.Sprintf("no configuration named %q (have: %s)", want, configurationNames(cfgs)) - return out - } - case len(cfgs) == 1: - chosen = cfgs[0] - default: - for _, c := range cfgs { - if strings.EqualFold(c.Name, "Default") { - chosen = c - break - } - } - if chosen == nil { - // Several configurations and no "Default": which one this run means is - // the user's call, not a guess worth making silently. - out.Note = fmt.Sprintf("the project has %d configurations and none is named \"Default\" (%s); "+ - "pass --configuration to choose one", len(cfgs), configurationNames(cfgs)) - return out - } - } out.Configuration = chosen.Name for _, cv := range chosen.ConstantValues { @@ -101,6 +70,37 @@ func resolveConstantOverrides(ps *model.ProjectSettings, want string) constantOv return out } +// chooseConfiguration picks the configuration a run represents: the one want +// names, else the only one, else "Default". With none to pick it returns nil and +// says why. Everything a run takes from a configuration — its constant values, its +// JVM options — comes from this one choice. +func chooseConfiguration(ps *model.ProjectSettings, want string) (*model.ServerConfiguration, string) { + if ps == nil || ps.Configuration == nil || len(ps.Configuration.Configurations) == 0 { + return nil, "the project has no configurations" + } + cfgs := ps.Configuration.Configurations + switch { + case want != "": + for _, c := range cfgs { + if strings.EqualFold(c.Name, want) { + return c, "" + } + } + return nil, fmt.Sprintf("no configuration named %q (have: %s)", want, configurationNames(cfgs)) + case len(cfgs) == 1: + return cfgs[0], "" + } + for _, c := range cfgs { + if strings.EqualFold(c.Name, "Default") { + return c, "" + } + } + // Several configurations and no "Default": which one this run means is the + // user's call, not a guess worth making silently. + return nil, fmt.Sprintf("the project has %d configurations and none is named \"Default\" (%s); "+ + "pass --configuration to choose one", len(cfgs), configurationNames(cfgs)) +} + func configurationNames(cfgs []*model.ServerConfiguration) string { names := make([]string, 0, len(cfgs)) for _, c := range cfgs { diff --git a/cmd/mxcli/runjvm.go b/cmd/mxcli/runjvm.go new file mode 100644 index 0000000000..d4c114646b --- /dev/null +++ b/cmd/mxcli/runjvm.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/mendixlabs/mxcli/model" +) + +// A configuration's Server tab carries JVM options — Java heap ▸ Maximum size +// (MaxJavaHeapSize, in MB; 0 is "Default") and Extra JVM parameters — which Studio +// Pro passes to the runtime JVM when it runs the app. `run --local` started the +// JVM with a fixed argument list, so a proxy set with +// +// alter settings configuration 'Default' (ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888'); +// +// was in the model, round-tripped through `describe settings`, and absent from +// every local run — the case mendixlabs/mxcli#131 was filed for. Same shape as the +// constants in runconstants.go, and resolved from the same configuration. + +// configurationJVMArgs returns a configuration's JVM options as arguments: the +// heap maximum first, so an -Xmx among the parameters is the one that wins, then +// the parameters split the way a shell would split them. +func configurationJVMArgs(cfg *model.ServerConfiguration) ([]string, error) { + var args []string + if cfg.MaxJavaHeapSize > 0 { + args = append(args, fmt.Sprintf("-Xmx%dm", cfg.MaxJavaHeapSize)) + } + params, err := splitJVMParameters(cfg.ExtraJvmParameters) + if err != nil { + return nil, fmt.Errorf("configuration %q: ExtraJvmParameters: %w\n"+ + " hint: fix it with `alter settings configuration '%s' (ExtraJvmParameters: '…')`", + cfg.Name, err, cfg.Name) + } + return append(args, params...), nil +} + +// splitJVMParameters splits a parameter string on whitespace, keeping a single- or +// double-quoted run together and dropping the quotes, so +// `-Dapp.greeting="hello world"` is one argument. An unbalanced quote is an error: +// passed on, it would cut a property in half or stop the JVM from starting. +func splitJVMParameters(s string) ([]string, error) { + var ( + args []string + cur strings.Builder + inArg bool + quote rune + quoteAt int + ) + for i, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + case r == '"' || r == '\'': + quote, quoteAt, inArg = r, i, true + case r == ' ' || r == '\t' || r == '\n' || r == '\r': + if inArg { + args = append(args, cur.String()) + cur.Reset() + inArg = false + } + default: + cur.WriteRune(r) + inArg = true + } + } + if quote != 0 { + return nil, fmt.Errorf("unbalanced quote at position %d in %q", quoteAt, s) + } + if inArg { + args = append(args, cur.String()) + } + return args, nil +} + +// reportJVMArgs says which JVM options the run passes, and from which +// configuration, so silence means "none are set" rather than "yours are in +// effect". A -D value is not printed: the parameters are also where a trust-store +// password goes, and run output lands in CI logs; `describe settings` shows them. +func reportJVMArgs(w io.Writer, c constantChain) { + if len(c.JVMArgs) == 0 { + return + } + shown := make([]string, len(c.JVMArgs)) + for i, a := range c.JVMArgs { + if name, _, isProp := strings.Cut(a, "="); isProp && strings.HasPrefix(a, "-D") { + a = name + "=…" + } + shown[i] = a + } + fmt.Fprintf(w, "Passing %d JVM option(s) from configuration %q: %s\n", + len(c.JVMArgs), c.Configuration, strings.Join(shown, " ")) +} diff --git a/cmd/mxcli/runjvm_test.go b/cmd/mxcli/runjvm_test.go new file mode 100644 index 0000000000..d904fddbaa --- /dev/null +++ b/cmd/mxcli/runjvm_test.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "slices" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#131 follow-up: `alter settings configuration 'Default' +// (ExtraJvmParameters: '-Dhttps.proxyHost=…')` is what Studio Pro passes to the +// runtime JVM, and `run --local` ignored it — the proxy the issue was filed for +// was set in the model and absent from every local run, silently. + +func jvmCfg(name, extra string, heap int) *model.ServerConfiguration { + return &model.ServerConfiguration{Name: name, ExtraJvmParameters: extra, MaxJavaHeapSize: heap} +} + +func TestConfigurationJVMArgs(t *testing.T) { + for _, tc := range []struct { + name string + cfg *model.ServerConfiguration + want []string + error string + }{ + {name: "nothing set", cfg: jvmCfg("Default", "", 0), want: nil}, + {name: "as reported", cfg: jvmCfg("Default", "-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758", 0), + want: []string{"-Dhttps.proxyHost=127.0.0.1", "-Dhttps.proxyPort=29758"}}, + // Java heap ▸ Maximum size is in MB; 0 is Studio Pro's "Default". + {name: "heap", cfg: jvmCfg("Default", "", 2048), want: []string{"-Xmx2048m"}}, + // The heap goes first, so an -Xmx among the parameters is the one that wins. + {name: "heap and parameters", cfg: jvmCfg("Default", "-Xss2m", 512), want: []string{"-Xmx512m", "-Xss2m"}}, + {name: "quoted value with a space", cfg: jvmCfg("Default", `-Dapp.greeting="hello world" -Dx='a b'`, 0), + want: []string{"-Dapp.greeting=hello world", "-Dx=a b"}}, + {name: "unbalanced quote", cfg: jvmCfg("Default", `-Dapp.greeting="hello`, 0), error: "unbalanced quote"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := configurationJVMArgs(tc.cfg) + if tc.error != "" { + if err == nil || !strings.Contains(err.Error(), tc.error) { + t.Fatalf("want an error containing %q, got %v (args %v)", tc.error, err, got) + } + // Name the configuration and the property, so the fix is obvious. + if !strings.Contains(err.Error(), "Default") || !strings.Contains(err.Error(), "ExtraJvmParameters") { + t.Errorf("error does not say where the value lives: %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, tc.want) { + t.Errorf("args = %q, want %q", got, tc.want) + } + }) + } +} + +// The JVM parameters come from the same configuration as the constants — the +// one the run represents — not from whichever is first. +func TestResolveConstantChain_CarriesTheConfigurationsJVMArgs(t *testing.T) { + ps := settingsWith( + jvmCfg("Production", "-Dhttps.proxyHost=prod.example.invalid", 0), + jvmCfg("Default", "-Dhttps.proxyHost=127.0.0.1", 1024), + ) + chain, err := resolveConstantChain(ps, "", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if want := []string{"-Xmx1024m", "-Dhttps.proxyHost=127.0.0.1"}; !slices.Equal(chain.JVMArgs, want) { + t.Errorf("JVMArgs = %q, want Default's %q", chain.JVMArgs, want) + } + + chain, err = resolveConstantChain(ps, "Production", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if want := []string{"-Dhttps.proxyHost=prod.example.invalid"}; !slices.Equal(chain.JVMArgs, want) { + t.Errorf("JVMArgs = %q, want Production's %q", chain.JVMArgs, want) + } +} + +// A value the run cannot split is refused rather than passed on mangled: the JVM +// would then fail to start, or start with a property cut in half. +func TestResolveConstantChain_RefusesUnsplittableJVMParameters(t *testing.T) { + ps := settingsWith(jvmCfg("Default", `-Dx="open`, 0)) + if _, err := resolveConstantChain(ps, "", nil, nil, nil); err == nil { + t.Fatal("an unbalanced quote in ExtraJvmParameters was accepted") + } +} + +// The run says what it passes, so silence stops meaning "your proxy is in +// effect". A -D value is not printed: the parameters are also where a trust-store +// password goes, and run output lands in CI logs. `describe settings` shows them. +func TestReportJVMArgs(t *testing.T) { + var buf bytes.Buffer + reportJVMArgs(&buf, constantChain{ + Configuration: "Default", + JVMArgs: []string{"-Xmx2048m", "-Djavax.net.ssl.trustStorePassword=s3cr3t", "-Dhttps.proxyPort=29758"}, + }) + out := buf.String() + for _, want := range []string{`configuration "Default"`, "-Xmx2048m", "-Djavax.net.ssl.trustStorePassword=…", "-Dhttps.proxyPort=…"} { + if !strings.Contains(out, want) { + t.Errorf("report missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "s3cr3t") || strings.Contains(out, "29758") { + t.Errorf("report printed a -D value:\n%s", out) + } + + buf.Reset() + reportJVMArgs(&buf, constantChain{Configuration: "Default"}) + if buf.Len() != 0 { + t.Errorf("nothing to pass, but the report said %q", buf.String()) + } +} diff --git a/cmd/mxcli/testrunner/localapp_options.go b/cmd/mxcli/testrunner/localapp_options.go index 2a2a6b0f26..314b7a8cf2 100644 --- a/cmd/mxcli/testrunner/localapp_options.go +++ b/cmd/mxcli/testrunner/localapp_options.go @@ -40,6 +40,7 @@ func localAppOptions(opts RunOptions, logPath string, env []string, w io.Writer) SkipBuild: opts.SkipBuild, Env: env, ConstantOverrides: opts.ConstantOverrides, + JVMArgs: opts.JVMArgs, MxBuildPath: opts.MxBuildPath, RuntimeLogPath: logPath, Stdout: w, diff --git a/cmd/mxcli/testrunner/localapp_options_test.go b/cmd/mxcli/testrunner/localapp_options_test.go index 612e720356..47c2d372b3 100644 --- a/cmd/mxcli/testrunner/localapp_options_test.go +++ b/cmd/mxcli/testrunner/localapp_options_test.go @@ -80,3 +80,14 @@ func TestLocalAppOptions_CarriesTheMxBuildPath(t *testing.T) { } } } + +// The configuration's JVM options reach a --local test app as they reach a +// `run --local` app, for the reason the constants do: --attach runs against an +// app `run --local` booted, so a suite must not see a different JVM under --local. +func TestLocalAppOptions_CarriesTheJVMArgs(t *testing.T) { + opts := RunOptions{ProjectPath: "/tmp/app/App.mpr", JVMArgs: []string{"-Xmx1024m", "-Dhttps.proxyHost=127.0.0.1"}} + got := localAppOptions(opts, "log", nil, io.Discard) + if len(got.JVMArgs) != 2 || got.JVMArgs[1] != "-Dhttps.proxyHost=127.0.0.1" { + t.Fatalf("JVMArgs = %v, want the configuration's options", got.JVMArgs) + } +} diff --git a/cmd/mxcli/testrunner/runner.go b/cmd/mxcli/testrunner/runner.go index 0582d2863c..bd30506970 100644 --- a/cmd/mxcli/testrunner/runner.go +++ b/cmd/mxcli/testrunner/runner.go @@ -80,6 +80,11 @@ type RunOptions struct { // and inherits ITS constants, and the Docker path configures the container. ConstantOverrides map[string]string + // JVMArgs are the same configuration's JVM options (Java heap maximum, Extra + // JVM parameters), resolved by the caller and carried like ConstantOverrides. + // Only --local uses them. + JVMArgs []string + // DBType is the database a --local run boots its scratch app against, in the // canonical spelling docker.NormalizeDBType returns ("postgresql" or // "hsqldb"; empty means PostgreSQL). Set it through ResolveTestDBType, which diff --git a/docs-site/src/reference/settings/alter-settings.md b/docs-site/src/reference/settings/alter-settings.md index 8cbc065fe9..1b3b26fa7d 100644 --- a/docs-site/src/reference/settings/alter-settings.md +++ b/docs-site/src/reference/settings/alter-settings.md @@ -101,8 +101,9 @@ ALTER SETTINGS CONFIGURATION 'default' ( ExtraJvmParameters: '-Dhttps.proxyHost= ``` The value is stored as written and is what Studio Pro passes to the JVM when it -runs the app. `mxcli run --local` starts the runtime JVM itself and does not -apply it. +runs the app. `mxcli run --local` and `mxcli test --local` pass it too, after +`-Xmxm` for a non-zero `MaxJavaHeapSize`, and print which options they pass +(without `-D` values). ### Override a constant in a configuration diff --git a/mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl b/mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl index 036987860a..657ef553be 100644 --- a/mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl +++ b/mdl-examples/bug-tests/131-alter-settings-extra-jvm-parameters.mdl @@ -19,8 +19,8 @@ mdl 1; -- mxcli -p app.mpr -c "describe settings configuration 'Default'" -- → ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758' -- --- Note: this sets what Studio Pro passes when it runs the app. `mxcli run --local` --- starts the runtime JVM itself and does not apply the configuration's parameters. +-- This sets what Studio Pro passes to the runtime JVM when it runs the app; +-- `mxcli run --local` and `mxcli test --local` pass it too. alter settings configuration 'Default' ( ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=29758' From 3cef0f9d9eab1afde17e422bc287ba91dcacc9ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:22:31 +0000 Subject: [PATCH 47/50] docs: describe settings takes no category, only CONFIGURATION 'name' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LIST / DESCRIBE SETTINGS page documented `DESCRIBE SETTINGS [category]` with examples `DESCRIBE SETTINGS MODEL`, `… CONFIGURATION` and `… WORKFLOWS`; none of them parse. The forms that do are `DESCRIBE SETTINGS` and `DESCRIBE SETTINGS CONFIGURATION 'name'`. The section table now says what DESCRIBE emits for each section, in the order it emits it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014hU79xp8jYcDYXASt1GHHY --- .../settings/list-describe-settings.md | 46 ++++++------------- mdl/conformance/allowlist.txt | 1 - 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/docs-site/src/reference/settings/list-describe-settings.md b/docs-site/src/reference/settings/list-describe-settings.md index 097abb606f..96b65d4805 100644 --- a/docs-site/src/reference/settings/list-describe-settings.md +++ b/docs-site/src/reference/settings/list-describe-settings.md @@ -4,31 +4,25 @@ LIST SETTINGS - DESCRIBE SETTINGS [ category ] + DESCRIBE SETTINGS -## Description + DESCRIBE SETTINGS CONFIGURATION 'name' -Displays project settings. `LIST SETTINGS` provides a compact overview of all settings across all categories. `DESCRIBE SETTINGS` outputs the full settings in round-trippable MDL syntax that can be used to recreate the same configuration. +## Description -When `DESCRIBE SETTINGS` is called without a category, it outputs all settings. When a category is specified, only that category is shown. +Displays project settings. `LIST SETTINGS` provides a compact overview, one row per section. `DESCRIBE SETTINGS` outputs every section as re-executable MDL — the `ALTER SETTINGS` and `CREATE OR MODIFY CONFIGURATION` statements that recreate it. `DESCRIBE SETTINGS CONFIGURATION 'name'` outputs one configuration only, and names the ones that exist when there is none by that name. There is no form that describes another single section. 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 | -|----------|----------| -| `MODEL` | Application-level settings: AfterStartupMicroflow, BeforeShutdownMicroflow, HashAlgorithm, JavaVersion, etc. | -| `CONFIGURATION` | Runtime configurations: DatabaseType, DatabaseUrl, HttpPortNumber, etc. Each named configuration is listed separately. | -| `CONSTANT` | Constant value overrides per configuration. Shows which constants have non-default values in each configuration. An override whose value is private — stored on the developer's workstation rather than in the shared model — is reported as `(private)`; its value is not in the project and mxcli cannot show it. | -| `LANGUAGE` | Localization settings: DefaultLanguageCode and available languages. | -| `WORKFLOWS` | Workflow engine settings: UserEntity, DefaultTaskParallelism, etc. | -| `WEBUI` | Web UI settings: `OptimizedClient` — `Yes` (React client), `No` (classic client) or `MigrationMode`. | +`DESCRIBE SETTINGS` emits, in order: -## Parameters - -**category** (DESCRIBE only) -: One of `MODEL`, `CONFIGURATION`, `CONSTANT`, `LANGUAGE`, or `WORKFLOWS`. If omitted, all categories are shown. +| Section | Emitted as | +|---------|------------| +| Runtime | `ALTER SETTINGS RUNTIME (…)` — AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc.; only the properties this project's Mendix version stores. | +| Configurations | `CREATE OR MODIFY CONFIGURATION 'name' (…)` per configuration — DatabaseType, DatabaseUrl, HttpPortNumber, etc. (never `DatabasePassword`), then `ALTER SETTINGS CONSTANT … IN CONFIGURATION 'name'` per constant override. A private override — its value kept on the developer's workstation, not in the shared model — is a comment, since its value is not in the project. | +| Languages | `ALTER SETTINGS LANGUAGE ADD OR MODIFY 'code' (…)` per enabled language, then the default language. | +| Workflows | `ALTER SETTINGS WORKFLOWS (…)` and one `ADD OR MODIFY GROUP` per workflow group. | +| Web UI | `ALTER SETTINGS WEBUI (OptimizedClient: …)` — `Yes` (React client), `No` (classic client) or `MigrationMode`. | ## Examples @@ -44,16 +38,10 @@ LIST SETTINGS; DESCRIBE SETTINGS; ``` -### Describe model settings only - -```sql -DESCRIBE SETTINGS MODEL; -``` - -### Describe runtime configurations +### Describe one configuration ```sql -DESCRIBE SETTINGS CONFIGURATION; +DESCRIBE SETTINGS CONFIGURATION 'Default'; ``` ### List workflow groups @@ -67,12 +55,6 @@ descriptions. It reads the project settings directly, so no catalog refresh is needed. Groups are created with [`ALTER SETTINGS WORKFLOWS ... GROUP`](alter-settings.md). -### Describe workflow settings - -```sql -DESCRIBE SETTINGS WORKFLOWS; -``` - ## See Also [ALTER SETTINGS](alter-settings.md), [LIST CONSTANT VALUES](../query/list-constants.md) diff --git a/mdl/conformance/allowlist.txt b/mdl/conformance/allowlist.txt index 584f0a3c59..bb32c026c0 100644 --- a/mdl/conformance/allowlist.txt +++ b/mdl/conformance/allowlist.txt @@ -155,7 +155,6 @@ 4 syntax docs-site/src/reference/security/grant.md 5 syntax docs-site/src/reference/security/revoke.md 1 syntax docs-site/src/reference/security/update-security.md -3 syntax docs-site/src/reference/settings/list-describe-settings.md 11 syntax docs-site/src/reference/workflow/create-workflow.md 2 syntax docs-site/src/tools/callers-callees.md 1 syntax docs-site/src/tools/catalog-sql.md From ff69c3a31904d31f7281437e81fafc43635f8eec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:19:59 +0000 Subject: [PATCH 48/50] fix: read a SOAP mapping's imported web service under its storage key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard that refuses rewriting a mapping sourced from an imported web service (#365) never fired on a Studio Pro mapping: gen read the source under its SDK name `ImportedWebService`, but Mendix stores it as `WsdlFile` (and an export mapping's header flag as `IsHeaderParameter`, not `IsHeader`). mxbuild's own metadata pairs each storage name with those SDK names; both pairs sat in keyaudit's known-wrong list. So describe → exec of a SOAP mapping rewrote it and dropped the binding (CE6896), and describe printed no SOURCE NOT REPRESENTABLE marker (ako/mxcli#1631). - gen: storage-name overrides for both keys, in init and in the by-name lookup of InitFromRaw; struck from keyaudit. - roundtrip: the #365 refusal joins the harness's deliberate refusals (ADR-0012), and the three TestApp Clients.Soap* mappings, allowlisted as breaking GetPut since #743, are struck — they are now refused with nothing written. Control on those Studio Pro-authored mappings: with the gen fix reverted the harness reports "executing the describe output wrote 1 unit(s)"; with it, "refused, nothing written: … service OrdersWS, operation GetOrder". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017jEcJZDio1jrTrWtVzagjY --- ...ap-mapping-source-read-under-sdk-name.json | 1 + .../mapping_webservice_source_test.go | 121 ++++++++++++++++++ mdl/roundtrip/pedapp_roundtrip_test.go | 5 + mdl/roundtrip/testapp_allowlist_test.go | 7 +- modelsdk/gen/exportmappings/types.go | 14 +- modelsdk/gen/importmappings/types.go | 10 +- modelsdk/gen/keyaudit_test.go | 3 - 7 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/modelsdk/2026-10-23-soap-mapping-source-read-under-sdk-name.json create mode 100644 mdl/backend/modelsdk/mapping_webservice_source_test.go diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-23-soap-mapping-source-read-under-sdk-name.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-23-soap-mapping-source-read-under-sdk-name.json new file mode 100644 index 0000000000..868d2b069d --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-23-soap-mapping-source-read-under-sdk-name.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "modelsdk/gen", "symptom": "ako/mxcli#1631: the refusal that keeps a rewrite from dropping a SOAP-sourced mapping's binding (checkNoWebServiceSource, #365) never fired on a Studio Pro mapping. `describe` → `exec` of TestApp's Clients.SoapOrdersImportMapping, SoapCustomerOrdersImportMapping and SoapOrderExportMapping wrote the unit and dropped the imported web service — the CE6896 #365 was filed for — and describe printed no SOURCE NOT REPRESENTABLE marker", "cause": "gen bound ImportMappings/ExportMappings `ImportedWebService` under its SDK name; Mendix stores it as `WsdlFile` (and an export's `IsHeader` as `IsHeaderParameter`). Both sat in keyaudit's knownWrongKeys. mxcli's own writer already wrote `WsdlFile`, so the write side knew the key and the read side did not. The #365 tests built model.WebServiceMappingSource by hand, never from a stored document, so they were green against a reader that always returned an empty source", "file": "modelsdk/gen/{import,export}mappings/types.go (STORAGE-NAME OVERRIDE in init AND the by-name LookupErr in InitFromRaw), modelsdk/gen/keyaudit_test.go, mdl/roundtrip/pedapp_roundtrip_test.go (refusals), mdl/roundtrip/testapp_allowlist_test.go", "fix": "Read WsdlFile / IsHeaderParameter; the #365 refusal is added to the round-trip harness's deliberate `refusals` (ADR-0012), and the three TestApp SOAP mappings struck from the allowlist (they broke GetPut by dropping the binding)", "test": "TestMappingWebServiceSource_ReadsTheStoredKeys (plants a stored SOAP mapping in PedApp and reads it through GetImport/ExportMappingByQualifiedName); TestTestAppRoundTrip on the three Clients.Soap* mappings", "insight": "**A by-name reference has TWO key sites in gen**: the property constructed in init AND an explicit `raw.LookupErr(\"\")` in InitFromRaw. Overriding the property alone left the test failing unchanged — grep the old key across the whole types.go, not just the init line. (refs.go carries the SDK name too, but DefaultRefRegistry has no non-test consumer, so it is inert, as for earlier overrides.) **A guard that reads a field is only as good as the field's reader**: write its test from a STORED document — plant the unit with the storage keys and read it through the backend — or a blind reader passes it. **The fixture already had the evidence**: TestApp's allowlist recorded the three SOAP mappings as `breaks getput, not yet triaged` since #743; grepping a fixture's mprcontents for the storage key (`WsdlFile` non-empty) found them in seconds and gave a Studio Pro-authored control: pre-fix 'wrote 1 unit(s)', fixed 'refused, nothing written: … service OrdersWS, operation GetOrder'. mxbuild's Mendix.Modeler.Integration.dll metadata pairs WsdlFile↔importedWebService and IsHeaderParameter↔isHeader (python byte search; ServiceName/OperationName are already right). When a harness counts a deliberate refusal as keeping both laws, the refusal's message must be on its list, or fixing the guard reads as a new exec failure", "ce": ["CE6896"], "refs": ["ako/mxcli#1631", "ako/mxcli#365", "ako/mxcli#1626"]} diff --git a/mdl/backend/modelsdk/mapping_webservice_source_test.go b/mdl/backend/modelsdk/mapping_webservice_source_test.go new file mode 100644 index 0000000000..a6cf19454e --- /dev/null +++ b/mdl/backend/modelsdk/mapping_webservice_source_test.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/codec" + "github.com/mendixlabs/mxcli/modelsdk/element" + mmpr "github.com/mendixlabs/mxcli/modelsdk/mpr" +) + +// withStoredKeys re-encodes doc with the given top-level keys set to the given +// values, keeping key order — the shape a Studio Pro document has and mxcli's +// own writer never produces for these keys. +func withStoredKeys(t *testing.T, doc []byte, set map[string]any) []byte { + t.Helper() + var d bson.D + if err := bson.Unmarshal(doc, &d); err != nil { + t.Fatal(err) + } + for i := range d { + if v, ok := set[d[i].Key]; ok { + d[i].Value = v + delete(set, d[i].Key) + } + } + for k := range set { + t.Fatalf("key %q is not in the encoded document", k) + } + out, err := bson.Marshal(d) + if err != nil { + t.Fatal(err) + } + return out +} + +// ako/mxcli#1631: a mapping sourced from an imported web service stores the +// service as WsdlFile, and an export mapping's header flag as +// IsHeaderParameter — mxbuild's own metadata pairs each storage name with the +// SDK names importedWebService and isHeader. gen bound the SDK names, so a +// stored SOAP mapping read back with no source at all, and the refusal that +// keeps a rewrite from deleting the binding (checkNoWebServiceSource, #365) +// could not fire on one. Its tests built the model by hand, never from a +// stored document. +func TestMappingWebServiceSource_ReadsTheStoredKeys(t *testing.T) { + b := connectedBackend(t) + mods, err := b.ListModules() + if err != nil || len(mods) == 0 { + t.Fatalf("list modules: %v", err) + } + var mod *model.Module + for _, m := range mods { + if m.Name == "MyFirstModule" { + mod = m + } + } + if mod == nil { + t.Fatal("fixture has no MyFirstModule") + } + + insert := func(g element.Element, unitType string, set map[string]any) string { + raw, err := (&codec.Encoder{}).Encode(g) + if err != nil { + t.Fatal(err) + } + raw = withStoredKeys(t, raw, set) + if err := b.writer.InsertUnit(string(g.ID()), string(mod.ID), "Documents", unitType, raw); err != nil { + t.Fatal(err) + } + return string(g.ID()) + } + + soap := map[string]any{ + "WsdlFile": "MyFirstModule.WS_Orders", + "ServiceName": "OrderService", + "OperationName": "GetOrder", + "XsdRootElementName": "GetOrderResponse", + } + im := &model.ImportMapping{BaseElement: model.BaseElement{ID: model.ID(mmpr.GenerateID())}, Name: "IMM_Soap"} + insert(importMappingToGen(im), "ImportMappings$ImportMapping", copyMap(soap)) + em := &model.ExportMapping{BaseElement: model.BaseElement{ID: model.ID(mmpr.GenerateID())}, Name: "EXM_Soap"} + exportSet := copyMap(soap) + exportSet["IsHeaderParameter"] = true + insert(exportMappingToGen(em), "ExportMappings$ExportMapping", exportSet) + + gotIm, err := b.GetImportMappingByQualifiedName("MyFirstModule", "IMM_Soap") + if err != nil { + t.Fatal(err) + } + gotEm, err := b.GetExportMappingByQualifiedName("MyFirstModule", "EXM_Soap") + if err != nil { + t.Fatal(err) + } + for kind, src := range map[string]model.WebServiceMappingSource{ + "import": gotIm.WebServiceSource, "export": gotEm.WebServiceSource, + } { + if src.ImportedWebService != "MyFirstModule.WS_Orders" || !src.IsSet() { + t.Errorf("%s mapping: stored WsdlFile reads back as ImportedWebService %q (IsSet %v), want %q", + kind, src.ImportedWebService, src.IsSet(), "MyFirstModule.WS_Orders") + } + // The control: these keys were always read correctly. + if src.ServiceName != "OrderService" || src.OperationName != "GetOrder" { + t.Errorf("%s mapping: service/operation read back as %q/%q", kind, src.ServiceName, src.OperationName) + } + } + if !gotEm.WebServiceSource.IsHeader { + t.Error("export mapping: stored IsHeaderParameter true reads back as IsHeader false") + } +} + +func copyMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} diff --git a/mdl/roundtrip/pedapp_roundtrip_test.go b/mdl/roundtrip/pedapp_roundtrip_test.go index 585a84f3b7..52d4f295aa 100644 --- a/mdl/roundtrip/pedapp_roundtrip_test.go +++ b/mdl/roundtrip/pedapp_roundtrip_test.go @@ -288,6 +288,11 @@ var refusals = []string{ // Writing into a marketplace module is overwritten by the next module // update; the executor refuses rather than write it. "is a marketplace module", + // A mapping sourced from an imported web service: MDL cannot state the + // binding, so a rewrite is refused rather than written without it (#365). + // It could only fire once the source was read under its storage key, + // WsdlFile (ako/mxcli#1631) — before that, these rewrites dropped it. + "is sourced from the imported web service", } func isRefusal(err error) bool { diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 42949c9da6..272271f120 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -55,6 +55,10 @@ package roundtrip // the view's OQL column declares them, so describe now gives them no statement — // and WorkflowCommons.ConflictedWorkflowDefinitionView, whose only loss was the // stored OQL text describe → exec re-indented. +// ako/mxcli#1631 struck the three Clients.Soap* mappings: gen read their +// imported web service under the SDK name instead of WsdlFile, so the #365 +// refusal could not see it and the rewrite dropped the binding (GetPut). Read +// under the storage key, the rewrite is refused and nothing is written. var testAppKnownFailures = map[string]knownFailure{ "building block Atlas_Web_Content.Alert": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "building block Atlas_Web_Content.AlertIcon": {laws: []law{lawParse}, issue: "#721", why: "building block: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, @@ -105,10 +109,7 @@ var testAppKnownFailures = map[string]knownFailure{ "entity WorkflowCommons.WorkflowUserTaskView": {laws: []law{lawExec}, issue: "#721", why: "entity: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "entity WorkflowCommons.WorkflowView": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "entity: executes since a Studio Pro index keeps its GUID on CREATE OR MODIFY (mendixlabs/mxcli#1470); re-executing adds a fourth access rule (the XPath-scoped grant is not matched to its stored rule) and the documentation's trailing whitespace drifts — not yet triaged into #721's classes"}, "enumeration WorkflowCommons.Enum_NotificationArea_RenderAs": {laws: []law{lawGetPut}, issue: "#721", why: "enumeration: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "export mapping Clients.SoapOrderExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "export mapping Services.OrdersExportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "export mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "import mapping Clients.SoapCustomerOrdersImportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "import mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "import mapping Clients.SoapOrdersImportMapping": {laws: []law{lawGetPut}, issue: "#721", why: "import mapping: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page Administration.Account_Overview": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page FeedbackModule.PopupFailure": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page FeedbackModule.PopupSuccess": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, diff --git a/modelsdk/gen/exportmappings/types.go b/modelsdk/gen/exportmappings/types.go index 910a7189da..07448fbab5 100644 --- a/modelsdk/gen/exportmappings/types.go +++ b/modelsdk/gen/exportmappings/types.go @@ -267,7 +267,8 @@ func (o *ExportMapping) InitFromRaw(raw bson.Raw) { } } o.rootElementName.Init(raw) - if val, err := raw.LookupErr("ImportedWebService"); err == nil { + // Stored as WsdlFile — see the STORAGE-NAME OVERRIDE in init (ako/mxcli#1631). + if val, err := raw.LookupErr("WsdlFile"); err == nil { if s, ok := val.StringValueOK(); ok { o.importedWebService.SetFromDecode(s) } @@ -870,7 +871,12 @@ func initExportMapping() *ExportMapping { // deleting it (CE0270, mendixlabs/mxcli#1623). o.rootElementName = property.NewPrimitive[string]("XsdRootElementName", property.DecodeString) o.rootElementName.Bind(&o.Base, 7) - o.importedWebService = property.NewByNameRef[element.Element]("ImportedWebService", "WebServices$ImportedWebService") + // STORAGE-NAME OVERRIDE: BSON key is "WsdlFile", not the SDK name + // "ImportedWebService" — mxbuild's own metadata pairs StorageName WsdlFile + // with SdkName importedWebService. Read under the SDK name, a SOAP-sourced + // mapping had no source, and the guard that refuses rewriting one (#365) + // never fired on a stored document (ako/mxcli#1631). + o.importedWebService = property.NewByNameRef[element.Element]("WsdlFile", "WebServices$ImportedWebService") o.importedWebService.Bind(&o.Base, 8) o.serviceName = property.NewPrimitive[string]("ServiceName", property.DecodeString) o.serviceName.Bind(&o.Base, 9) @@ -888,7 +894,9 @@ func initExportMapping() *ExportMapping { o.parameterName.Bind(&o.Base, 15) o.parameterTypeName = property.NewPrimitive[string]("ParameterTypeName", property.DecodeString) o.parameterTypeName.Bind(&o.Base, 16) - o.isHeader = property.NewPrimitive[bool]("IsHeader", property.DecodeBool) + // STORAGE-NAME OVERRIDE: BSON key is "IsHeaderParameter", not the SDK name + // "IsHeader" — the pair mxbuild's metadata records (ako/mxcli#1631). + o.isHeader = property.NewPrimitive[bool]("IsHeaderParameter", property.DecodeBool) o.isHeader.Bind(&o.Base, 17) o.nullValueOption = property.NewEnum[string]("NullValueOption") o.nullValueOption.Bind(&o.Base, 18) diff --git a/modelsdk/gen/importmappings/types.go b/modelsdk/gen/importmappings/types.go index 520ea0e124..3a4ea01239 100644 --- a/modelsdk/gen/importmappings/types.go +++ b/modelsdk/gen/importmappings/types.go @@ -256,7 +256,8 @@ func (o *ImportMapping) InitFromRaw(raw bson.Raw) { } } o.rootElementName.Init(raw) - if val, err := raw.LookupErr("ImportedWebService"); err == nil { + // Stored as WsdlFile — see the STORAGE-NAME OVERRIDE in init (ako/mxcli#1631). + if val, err := raw.LookupErr("WsdlFile"); err == nil { if s, ok := val.StringValueOK(); ok { o.importedWebService.SetFromDecode(s) } @@ -860,7 +861,12 @@ func initImportMapping() *ImportMapping { // deleting it (CE0270, mendixlabs/mxcli#1623). o.rootElementName = property.NewPrimitive[string]("XsdRootElementName", property.DecodeString) o.rootElementName.Bind(&o.Base, 7) - o.importedWebService = property.NewByNameRef[element.Element]("ImportedWebService", "WebServices$ImportedWebService") + // STORAGE-NAME OVERRIDE: BSON key is "WsdlFile", not the SDK name + // "ImportedWebService" — mxbuild's own metadata pairs StorageName WsdlFile + // with SdkName importedWebService. Read under the SDK name, a SOAP-sourced + // mapping had no source, and the guard that refuses rewriting one (#365) + // never fired on a stored document (ako/mxcli#1631). + o.importedWebService = property.NewByNameRef[element.Element]("WsdlFile", "WebServices$ImportedWebService") o.importedWebService.Bind(&o.Base, 8) o.serviceName = property.NewPrimitive[string]("ServiceName", property.DecodeString) o.serviceName.Bind(&o.Base, 9) diff --git a/modelsdk/gen/keyaudit_test.go b/modelsdk/gen/keyaudit_test.go index 0d1a6e42e0..c1fa20ff71 100644 --- a/modelsdk/gen/keyaudit_test.go +++ b/modelsdk/gen/keyaudit_test.go @@ -129,10 +129,7 @@ var knownWrongKeys = []keyMismatch{ {"DomainModels$Attribute", "DataStorageGuid", "GUID"}, {"DomainModels$CrossAssociation", "DataStorageGuid", "GUID"}, {"DomainModels$EqualsToRuleInfo", "EqualsToValue", "Value"}, - {"ExportMappings$ExportMapping", "ImportedWebService", "WsdlFile"}, - {"ExportMappings$ExportMapping", "IsHeader", "IsHeaderParameter"}, {"Images$Image", "ImageData", "Image"}, - {"ImportMappings$ImportMapping", "ImportedWebService", "WsdlFile"}, {"JavaActions$JavaAction", "ActionTypeParameters", "TypeParameters"}, {"JavaActions$JavaAction", "ActionReturnType", "JavaReturnType"}, {"JavaActions$JavaAction", "ModelerActionInfo", "MicroflowActionInfo"}, From 3074c5fb2c2958f98e21d0f6f089838b3f4d32a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:41:38 +0000 Subject: [PATCH 49/50] fix: keep a view query's outer comments, and gate OQL comments on 11.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment before a view entity query's first token or after its last was dropped: the stored query was the token span, and the lexer skips comments. Every describe → exec into another project lost it (ako/mxcli#1632). The query now widens over comments between `as (` and the first token and between the last token and `)`; whitespace handling is unchanged. Measuring that on a real build showed the comment is only legal from Mendix 11.7.0. 10.24.25 and 11.6.6 report CE0174 for a comment anywhere in the query (UseOQLVersion2 on), while 11.7.0 and 11.13.0 report 0 errors. So a query that contains a comment is now refused by `check -p` and `exec` below 11.7 (MDL-VERSION01, oql_functions.comments). That also catches the comment between clauses mxcli always stored, which check passed and mxbuild then rejected. `--` inside a string is not a comment. Measured: on 10.24.25 the script is refused and nothing is written. On 11.7.0 all four comments are stored, describe → exec into a fresh project keeps 4 of 4 (pre-fix binary 0), mx check 0 errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017jEcJZDio1jrTrWtVzagjY --- ...iew-query-comments-outside-token-span.json | 1 + .../skills/mendix/write-oql-queries/SKILL.md | 23 +++- .../bug-tests/1632-view-query-comments.mdl | 49 +++++++ mdl/executor/version_gates.go | 14 +- mdl/executor/version_gates_test.go | 19 +++ .../view_entity_oql_roundtrip_test.go | 6 + mdl/visitor/visitor_entity.go | 125 +++++++++++++++++- .../visitor_view_entity_oql_comments_test.go | 76 +++++++++++ sdk/versions/mendix-11.yaml | 4 + 9 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-visitor/2026-10-23-view-query-comments-outside-token-span.json create mode 100644 mdl-examples/bug-tests/1632-view-query-comments.mdl create mode 100644 mdl/visitor/visitor_view_entity_oql_comments_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-23-view-query-comments-outside-token-span.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-23-view-query-comments-outside-token-span.json new file mode 100644 index 0000000000..d61bb3b406 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-23-view-query-comments-outside-token-span.json @@ -0,0 +1 @@ +{"date": "2026-10-23", "area": "mdl/visitor", "symptom": "ako/mxcli#1632: a view entity query's comment before the first token or after the last was dropped — from a script, and from every describe → exec into another project (pre-fix binary: 0 of 4 comments kept on 11.7.0). Separately, a comment BETWEEN clauses was stored, passed check and exec, and then failed mx check below 11.7 with CE0174 \"Error(s) in OQL query: The '-' part is incomplete or incorrect\"", "cause": "ExitCreateViewEntityStatement stored extractOriginalText(oqlCtx) — the first-to-last token span — and the lexer `-> skip`s LINE_COMMENT and BLOCK_COMMENT, so outer comments were outside every span. Nothing knew that OQL comments are version-dependent: Mendix accepts them from 11.7.0 only, in any position, whatever UseOQLVersion2 says", "file": "mdl/visitor/visitor_entity.go (viewQueryText, firstOuterComment, lastOuterCommentEnd, lineIndentBefore), mdl/executor/version_gates.go (oql_functions.comments gate on CreateViewEntityStmt), sdk/versions/mendix-11.yaml", "fix": "The stored query is widened over comments between `as (`/`as` and the first token and between the last token and `)` (whitespace semantics unchanged, so #653's dedent holds); a query containing a comment (stripOQLComments(q) != q, so `'--'` in a string is not one) is refused by check -p and exec below 11.7.0 (MDL-VERSION01)", "test": "TestViewEntityOQL_KeepsCommentsOutsideTheTokens (5 shapes + 3 controls), TestViewEntityOQL_DescribeExecRoundTripIsStable (3 new comment shapes), TestCheckVersionGates_ReportsEveryHandlerGate/view_query_comment, TestCheckVersionGates_ViewQueryWithoutCommentPasses; mdl-examples/bug-tests/1632-view-query-comments.mdl", "insight": "**The real run reversed the issue's premise.** The obvious fix — keep the comment — turned working 10.x scripts into CE0174, because Mendix below 11.7 rejects OQL comments ANYWHERE; the silent drop of an OUTER comment had been accidentally protective, and the defect that actually broke builds was the INTERIOR comment mxcli always kept (the #653 fixtures even store `-- every sale`). Unit tests passed for all of it; only `mxcli docker check` could say. **Measure the floor, then read the notes**: 10.24.25 and 11.6.6 reject (comment-free control 0 errors), 11.7.0 and 11.13.0 accept; the docs say 11.7.0 too, but the bracket is what makes the gate load-bearing. UseOQLVersion2 was true on all four, so the obvious setting is not the discriminator. **Two traps on 11.x controls**: `Target` is reserved in 11.x OQL (\"The 'Target' part is incomplete\" on the comment-free control too) — rename test entities before believing a negative; and a planted comment-free control is what separates 'comments rejected' from 'my query is wrong'. Positions are RUNE indices in ANTLR's InputStream, so scan []rune, not the UTF-8 string. Gate on a comment, not on the characters: reuse stripOQLComments, which skips string literals", "ce": ["CE0174"], "rules": ["MDL-VERSION01"], "refs": ["ako/mxcli#1632", "ako/mxcli#653", "ako/mxcli#1626"]} diff --git a/.claude/skills/mendix/write-oql-queries/SKILL.md b/.claude/skills/mendix/write-oql-queries/SKILL.md index 9c3cb377f8..2dc15ea21a 100644 --- a/.claude/skills/mendix/write-oql-queries/SKILL.md +++ b/.claude/skills/mendix/write-oql-queries/SKILL.md @@ -13,13 +13,13 @@ description: "Write OQL for Mendix VIEW entities — joins, aggregates, calculat | 27-32 | Reference files | | 33-35 | Purpose | | 36-42 | When to Use This Skill | -| 43-378 | Critical OQL Syntax Rules | -| 379-515 | Step-by-Step Process | -| 516-611 | Common Mistakes to Avoid | -| 612-643 | Testing OQL Queries | -| 644-662 | Integration with MDL Linter | -| 663-670 | References | -| 671-695 | Summary Checklist | +| 43-387 | Critical OQL Syntax Rules | +| 388-524 | Step-by-Step Process | +| 525-620 | Common Mistakes to Avoid | +| 621-652 | Testing OQL Queries | +| 653-671 | Integration with MDL Linter | +| 672-679 | References | +| 680-704 | Summary Checklist | # Skill: Write OQL Queries for Mendix VIEW Entities @@ -376,6 +376,15 @@ left outer join Shop.CompetitorProduct as cp on p.ProductCode = cp.ProductCode - **Association traversal** (`alias/Module.Association/entity`): When joining on a Mendix-defined association - **JOIN ON** (`join entity on condition`): When joining on arbitrary conditions or non-association fields +### 12. Comments in a View Query Need Mendix 11.7+ + +`--` and `/* */` comments in a view entity's query are stored as written, before, +between or after its clauses. Mendix accepts them from **11.7.0** only. Below +that, every comment is CE0174 ("The '-' part is incomplete or incorrect"), even +with OQL v2 on, so `check -p` and `exec` refuse the statement (MDL-VERSION01). +The comments in this skill's examples are annotation: leave them out of a query +for an older project. A `--` inside a string literal is not a comment. + ## Step-by-Step Process ### Step 1: Define VIEW Entity Schema diff --git a/mdl-examples/bug-tests/1632-view-query-comments.mdl b/mdl-examples/bug-tests/1632-view-query-comments.mdl new file mode 100644 index 0000000000..9510c0862d --- /dev/null +++ b/mdl-examples/bug-tests/1632-view-query-comments.mdl @@ -0,0 +1,49 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1632 — a comment before a view query's first token or after its +-- last was dropped, because the stored query was the token span and the lexer +-- skips comments. A describe → exec into another project lost it every time. +-- +-- The query now keeps those comments. They are only legal from Mendix 11.7.0: +-- measured, 10.24.25 and 11.6.6 report CE0174 "Error(s) in OQL query: The '-' +-- part is incomplete or incorrect" for a comment ANYWHERE in the query (with +-- UseOQLVersion2 on), while 11.7.0 and 11.13.0 build at 0 errors. So on an +-- older project `check -p` and `exec` refuse a commented query (MDL-VERSION01) +-- — which also catches the comment between clauses mxcli always stored and +-- mxbuild then rejected. +-- +-- Run against an 11.7+ project. Measured on 11.7.0: all four comments are +-- stored, `describe module VqC with all` → exec into a fresh project keeps 4 of +-- 4 (the pre-fix binary kept 0), mx check 0 errors. +-- ============================================================================ + +create module VqC; + +create persistent entity VqC.Item ( + Code: String(20), + Amount: Integer +); + +create view entity VqC.VLead ( + Code: String(20) +) as ( + -- view over items + select i.Code as Code + from VqC.Item as i +); + +create view entity VqC.VTrail ( + Code: String(20) +) as ( + select i.Code as Code + from VqC.Item as i -- all of them +); + +create view entity VqC.VBlock ( + Total: Integer +) as ( + /* header */ + select sum(i.Amount) as Total + from VqC.Item as i + /* footer */ +); diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go index 3e8cc9fd80..ae2224d89a 100644 --- a/mdl/executor/version_gates.go +++ b/mdl/executor/version_gates.go @@ -66,8 +66,20 @@ func statementVersionGates(stmt ast.Statement) []versionGate { "On an earlier version, model the buckets as your own entity and target user tasks with a microflow."}} } case *ast.CreateViewEntityStmt: - return []versionGate{{"domain_model", "view_entities", "create view entity", + gates := []versionGate{{"domain_model", "view_entities", "create view entity", "upgrade your project to 10.18+ or use a regular entity with a microflow data source"}} + // An OQL comment — `--` or `/* */`, anywhere in the query — is CE0174 + // "Error(s) in OQL query: The '-' part is incomplete or incorrect" below + // the floor. The query now keeps a comment before its first token or + // after its last (ako/mxcli#1632); one between clauses it always kept, and + // check passed what mxbuild then rejected. Gated on a COMMENT, not on the + // characters, so `'--'` in a string literal is not one. + if q := s.Query.RawQuery; stripOQLComments(q) != q { + gates = append(gates, versionGate{"oql_functions", "comments", + "a comment in a view entity's OQL query", + "remove the comments from the query — Mendix rejects them as CE0174 before 11.7 — or upgrade the project"}) + } + return gates // A domain-model EXPORT LEVEL is 9.3+ (mendixlabs/mxcli#1498). Gated on the // clause being STATED, as the deep-link URL is: a statement without one // carries the stored level and writes nothing new. diff --git a/mdl/executor/version_gates_test.go b/mdl/executor/version_gates_test.go index 61d55776ce..59bc779167 100644 --- a/mdl/executor/version_gates_test.go +++ b/mdl/executor/version_gates_test.go @@ -35,6 +35,11 @@ func TestCheckVersionGates_ReportsEveryHandlerGate(t *testing.T) { {"message definition document", &ast.CreateMessageDefinitionStmt{}, [3]int{11, 14, 0}, "create message definition"}, {"workflow group", &ast.AlterSettingsStmt{Section: "WORKFLOWS", AddGroup: true, GroupName: "G"}, [3]int{11, 1, 0}, "add group"}, {"view entity", &ast.CreateViewEntityStmt{}, [3]int{10, 17, 0}, "create view entity"}, + // ako/mxcli#1632: an OQL comment is CE0174 below 11.7 (measured 10.24.25, + // 11.6.6), so now that the query keeps one, a version that rejects it + // refuses the statement instead of writing an unbuildable model. + {"view query comment", &ast.CreateViewEntityStmt{Query: ast.OQLQuery{ + RawQuery: "-- totals\nselect sum(s.Amount) as Total\nfrom M.Sale as s"}}, [3]int{11, 6, 6}, "a comment in a view entity's OQL query"}, {"agent model", &ast.CreateModelStmt{}, [3]int{11, 8, 0}, "create model"}, {"consumed mcp", &ast.CreateConsumedMCPServiceStmt{}, [3]int{11, 8, 0}, "create consumed mcp service"}, {"knowledge base", &ast.CreateKnowledgeBaseStmt{}, [3]int{11, 8, 0}, "create knowledge base"}, @@ -137,3 +142,17 @@ func TestCheckFeatureIsOnlyCalledFromTheGateTable(t *testing.T) { } } } + +// The comment gate fires on a COMMENT, not on the characters: a query with +// none, or with `--` and `/*` inside a string literal, passes on 10.24.25 — the +// view-entity floor is 10.18, so nothing else gates these. +func TestCheckVersionGates_ViewQueryWithoutCommentPasses(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{ + &ast.CreateModuleStmt{Name: "M"}, + &ast.CreateViewEntityStmt{Query: ast.OQLQuery{RawQuery: "select s.Code as Code\nfrom M.Sale as s"}}, + &ast.CreateViewEntityStmt{Query: ast.OQLQuery{RawQuery: "select s.Code as Code\nfrom M.Sale as s\nwhere s.Code != '--' and s.Code != '/* x */'"}}, + }} + if vs := versionGateViolations(versionCtx(t, 10, 24, 25), prog); len(vs) != 0 { + t.Errorf("a view query without a comment was gated on 10.24.25: %+v", vs) + } +} diff --git a/mdl/executor/view_entity_oql_roundtrip_test.go b/mdl/executor/view_entity_oql_roundtrip_test.go index 00e887bfab..3c5e327b1c 100644 --- a/mdl/executor/view_entity_oql_roundtrip_test.go +++ b/mdl/executor/view_entity_oql_roundtrip_test.go @@ -61,6 +61,12 @@ func TestViewEntityOQL_DescribeExecRoundTripIsStable(t *testing.T) { "tabs and blank line": "select\n\tsum(s.Amount) as Total\n\n\t-- every sale\nfrom Shop.Sale as s", "relative indent kept": "select\n sum(s.Amount) as Total\n from Shop.Sale as s\n where s.Amount > 0", "comment between clauses": "select sum(s.Amount) as Total\n/* all sales */\nfrom Shop.Sale as s", + // A comment outside the query's tokens was dropped here — this test + // parses the description, so there is no stored text to carry + // (ako/mxcli#1632). + "leading comment": "-- totals per sale\nselect sum(s.Amount) as Total\nfrom Shop.Sale as s", + "trailing comment": "select sum(s.Amount) as Total\nfrom Shop.Sale as s -- all of them", + "block comments": "/* header */\nselect sum(s.Amount) as Total\nfrom Shop.Sale as s\n/* footer */", } for name, src := range cases { storedCases["script: "+name] = src diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 2f79ac2a4c..83a0980e21 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -200,8 +200,7 @@ func (b *Builder) buildViewEntity(ctx *parser.CreateEntityStatementContext) { // OQL Query - use token stream to preserve whitespace, and walk parse tree for structured data if oqlCtx := ctx.OqlQuery(); oqlCtx != nil { - firstIndent, firstIsIndent := leadingLineWhitespace(oqlCtx) - raw := dedentOQL(extractOriginalText(oqlCtx), firstIndent, firstIsIndent) + raw := viewQueryText(ctx, oqlCtx) stmt.Query = ast.OQLQuery{ RawQuery: raw, Parsed: buildOQLParsed(oqlCtx.(*parser.OqlQueryContext), raw), @@ -211,6 +210,128 @@ func (b *Builder) buildViewEntity(ctx *parser.CreateEntityStatementContext) { b.statements = append(b.statements, stmt) } +// viewQueryText is the text a view entity stores for its query: from the query's +// first token to its last, widened over any comment between `as (` and the +// first token and between the last token and `)`, then dedented (#653). +// +// The lexer skips comments, so the rule's own token span cannot see them: a +// comment heading or closing the query was dropped from every script, and from +// every describe → exec into another project, where there is no stored text to +// keep (ako/mxcli#1632). Only comments widen the span — the whitespace outside +// it is the script's layout, which is what the dedent already accounts for. +func viewQueryText(ctx *parser.CreateEntityStatementContext, oqlCtx parser.IOqlQueryContext) string { + q := oqlCtx.(antlr.ParserRuleContext) + start, stop := q.GetStart(), q.GetStop() + if start == nil || stop == nil || start.GetInputStream() == nil || + start.GetStart() < 0 || stop.GetStop() < start.GetStart() { + firstIndent, firstIsIndent := leadingLineWhitespace(q) + return dedentOQL(extractOriginalText(q), firstIndent, firstIsIndent) + } + is := start.GetInputStream() + from, to := start.GetStart(), stop.GetStop() + + // Before the query: back to the `(`, or to `as` when there is none. + open := ctx.LPAREN() + if open == nil { + open = ctx.AS() + } + if open != nil { + if p := open.GetSymbol().GetStop() + 1; p < from { + if c := firstOuterComment([]rune(is.GetText(p, from-1))); c >= 0 { + from = p + c + } + } + } + // After it: up to the `)`. Without one the statement's terminator follows + // directly, so there is no region a comment could sit in. + if closing := ctx.RPAREN(); closing != nil { + if p := closing.GetSymbol().GetStart() - 1; p > to { + if e := lastOuterCommentEnd([]rune(is.GetText(to+1, p))); e >= 0 { + to = to + 1 + e + } + } + } + + firstIndent, firstIsIndent := lineIndentBefore(is, from) + return dedentOQL(is.GetText(from, to), firstIndent, firstIsIndent) +} + +// firstOuterComment returns the index of the first comment in a region that +// holds only whitespace and comments, or -1 when it holds no comment or holds +// anything else (then the span is left as it was). +func firstOuterComment(region []rune) int { + for i := 0; i < len(region); i++ { + switch { + case isOQLSpace(region[i]): + continue + case startsComment(region, i): + return i + default: + return -1 + } + } + return -1 +} + +// lastOuterCommentEnd returns the index of the last character of the last +// comment in a region that holds only whitespace and comments, or -1. +func lastOuterCommentEnd(region []rune) int { + last := -1 + for i := 0; i < len(region); { + switch { + case isOQLSpace(region[i]): + i++ + case i+1 < len(region) && region[i] == '-' && region[i+1] == '-': + j := i + for j < len(region) && region[j] != '\n' && region[j] != '\r' { + if !isOQLSpace(region[j]) { + last = j + } + j++ + } + i = j + case i+1 < len(region) && region[i] == '/' && region[i+1] == '*': + end := -1 + for j := i + 2; j+1 < len(region); j++ { + if region[j] == '*' && region[j+1] == '/' { + end = j + 1 + break + } + } + if end < 0 { + return -1 + } + last, i = end, end+1 + default: + return -1 + } + } + return last +} + +func startsComment(r []rune, i int) bool { + return i+1 < len(r) && ((r[i] == '-' && r[i+1] == '-') || (r[i] == '/' && r[i+1] == '*')) +} + +func isOQLSpace(r rune) bool { + return r == ' ' || r == '\t' || r == '\r' || r == '\n' || r == '\v' || r == '\f' +} + +// lineIndentBefore returns the text between the start of pos's line and pos, +// and whether it is all whitespace — leadingLineWhitespace for a position that +// is not a token's. +func lineIndentBefore(is antlr.CharStream, pos int) (string, bool) { + lineStart := pos + for lineStart > 0 && is.GetText(lineStart-1, lineStart-1) != "\n" { + lineStart-- + } + if lineStart == pos { + return "", true + } + prefix := is.GetText(lineStart, pos-1) + return prefix, strings.TrimLeft(prefix, " \t") == "" +} + // leadingLineWhitespace returns the text between the start of the line holding // ctx's first token and that token, and whether it is all whitespace. It is the // indentation of the query's first line, which extractOriginalText drops. diff --git a/mdl/visitor/visitor_view_entity_oql_comments_test.go b/mdl/visitor/visitor_view_entity_oql_comments_test.go new file mode 100644 index 0000000000..188017cd45 --- /dev/null +++ b/mdl/visitor/visitor_view_entity_oql_comments_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#1632: the stored query was the text from its first token to its +// last, and the lexer skips comments, so a comment before the first token or +// after the last was dropped — from a script, and from every describe → exec +// into another project. A comment between clauses always survived; the cases +// without an outer comment are the control and must store exactly what they did. +func TestViewEntityOQL_KeepsCommentsOutsideTheTokens(t *testing.T) { + cases := []struct { + name, src, want string + }{ + { + name: "line comment before the first token", + src: "create view entity M.V (Code: String(20)) as (\n -- view over targets\n select t.Code as Code\n from M.T as t\n);", + want: "-- view over targets\nselect t.Code as Code\nfrom M.T as t", + }, + { + name: "line comment after the last token", + src: "create view entity M.V (Code: String(20)) as (\n select t.Code as Code\n from M.T as t -- end\n);", + want: "select t.Code as Code\nfrom M.T as t -- end", + }, + { + name: "comment on its own line after the query", + src: "create view entity M.V (Code: String(20)) as (\n select t.Code as Code\n from M.T as t\n -- end of view\n);", + want: "select t.Code as Code\nfrom M.T as t\n-- end of view", + }, + { + name: "block comments at both ends", + src: "create view entity M.V (Code: String(20)) as (\n /* header */\n select t.Code as Code\n from M.T as t\n /* footer */\n);", + want: "/* header */\nselect t.Code as Code\nfrom M.T as t\n/* footer */", + }, + { + name: "two leading comments", + src: "create view entity M.V (Code: String(20)) as (\n -- first\n -- second\n select t.Code as Code\n from M.T as t\n);", + want: "-- first\n-- second\nselect t.Code as Code\nfrom M.T as t", + }, + { + name: "control: no outer comment", + src: "create view entity M.V (Code: String(20)) as (\n select t.Code as Code\n from M.T as t\n);", + want: "select t.Code as Code\nfrom M.T as t", + }, + { + name: "control: query on the as ( line", + src: "create view entity M.V (Code: String(20)) as (select t.Code as Code\n from M.T as t);", + want: "select t.Code as Code\nfrom M.T as t", + }, + { + name: "control: no parentheses", + src: "create view entity M.V (Code: String(20)) as\n select t.Code as Code\n from M.T as t;", + want: "select t.Code as Code\nfrom M.T as t", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("parse failed: %v", errs) + } + stmt, ok := prog.Statements[0].(*ast.CreateViewEntityStmt) + if !ok { + t.Fatalf("got %T, want *ast.CreateViewEntityStmt", prog.Statements[0]) + } + if stmt.Query.RawQuery != tc.want { + t.Errorf("stored OQL\nwant %q\n got %q", tc.want, stmt.Query.RawQuery) + } + }) + } +} diff --git a/sdk/versions/mendix-11.yaml b/sdk/versions/mendix-11.yaml index ed4f76e2f5..d5346903bc 100644 --- a/sdk/versions/mendix-11.yaml +++ b/sdk/versions/mendix-11.yaml @@ -39,6 +39,10 @@ features: join_types: min_version: "10.18.0" mdl: "INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN" + comments: + min_version: "11.7.0" + mdl: "-- line comment / /* block comment */ in a view entity's OQL query" + notes: "Measured, not taken from release notes (they agree: OQL comments are listed as introduced in 11.7.0). The same view entities, each with one comment between clauses, at the end of a line or as a block: mx check 10.24.25 and 11.6.6 report CE0174 \"Error(s) in OQL query: The '-' part is incomplete or incorrect\" on every one, with the comment-free control at 0 errors; 11.7.0 and 11.13.0 report 0 errors. UseOQLVersion2 was true on all four, so the setting is not the discriminator. ako/mxcli#1632" microflows: basic: From 2254ae52ffc8f264232ec7d505952a9a41d379a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:49:24 +0000 Subject: [PATCH 50/50] Stop mirror.py from duplicating mirrors on a short listing On 2026-10-22 a transient empty page cut the listing of existing mirrors short, and mirror.py re-created the 40 oldest upstream issues as new mirrors. When duplicates exist, it also tracked the newest mirror (the listing is newest first), so a closed duplicate shadowed the open original and upstream closures were applied to the wrong issue. - paged() asks again before treating an empty page after a full one as the end of the listing - with duplicates, track the open mirror, and the oldest of those - refuse to create mirrors (exit 1) when the listing is empty but the fork has mirrors, or more than 5 upstream issues older than the newest mirrored one are unmirrored; --allow-backfill overrides - test_mirror.py runs the scenarios against a fake API; the previous script fails the four that cover the incident Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDRrgJhKKmSUnBFaDUw62 --- ...10-23-mirror-short-listing-duplicates.json | 1 + .claude/skills/issue-triage/mirror.py | 44 ++++++++- .claude/skills/issue-triage/test_mirror.py | 92 +++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/other/2026-10-23-mirror-short-listing-duplicates.json create mode 100644 .claude/skills/issue-triage/test_mirror.py diff --git a/.claude/skills/fix-issue/findings/other/2026-10-23-mirror-short-listing-duplicates.json b/.claude/skills/fix-issue/findings/other/2026-10-23-mirror-short-listing-duplicates.json new file mode 100644 index 0000000000..2f16ab386f --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-23-mirror-short-listing-duplicates.json @@ -0,0 +1 @@ +{"area": "other/issue-triage", "date": "2026-10-23", "symptom": "The mirror workflow's 2026-10-22 15:03 UTC run re-created 40 mirrors of upstream issues that already had one (#1571-#1610; 31 duplicated open mirrors, 8 revived issues the maintainer had closed as completed), and the triage routine then classified the duplicates as new issues. Later, when upstream closed #280, the workflow closed the duplicate #1584 and left the original #1234 open.", "cause": "paged() treats an empty page as the end of the listing, so a transient empty third page of the fork's ~280 mirrors dropped the oldest 41; mirror.py saw them as unmirrored and created the oldest 40 (its cap). Separately, by_up kept the first mirror seen per upstream issue, and the listing is newest first, so a duplicate shadowed the original.", "file": ".claude/skills/issue-triage/mirror.py (paged, main: by_up, backfill guard), test_mirror.py", "insight": "The run summary settled it: upstream_open 192 with exactly the 41 lowest numbers missing is a truncated tail, not an empty listing. Mirrors are created oldest first, so a missing upstream issue older than the newest mirrored one is itself the signal that the listing was short; the guard refuses (exit 1, a red run) instead of guessing. The same day the triage board script hit an empty listing too and created a second board, so treat any empty or short GitHub listing as suspect. Control: the original script fails the transient-page, short-listing, empty-listing and duplicate-tracking scenarios in test_mirror.py; the fixed one passes all seven.", "refs": ["ako/mxcli#1571", "ako/mxcli#1234"]} diff --git a/.claude/skills/issue-triage/mirror.py b/.claude/skills/issue-triage/mirror.py index 6287de11f9..82a11015f6 100644 --- a/.claude/skills/issue-triage/mirror.py +++ b/.claude/skills/issue-triage/mirror.py @@ -13,6 +13,13 @@ original, unless the mirror has `agent:working` or an open fork PR mentions it; then it only labels and comments +It refuses to create mirrors (exit 1) when the listing of existing mirrors looks +short: empty while the fork has mirrors, or more than BACKFILL_LIMIT unmirrored +upstream issues older than the newest mirrored one. Mirrors are created oldest +first, so such gaps mean the listing was cut off, and creating them would +duplicate existing mirrors. Pass --allow-backfill when they really are missing. +Scenario tests: test_mirror.py. + Mirrors never ping or link back to upstream: upstream text sits in a fenced block and the upstream reference in a code span, so no @mentions reach upstream users and no "mentioned this issue" events appear upstream. @@ -29,6 +36,7 @@ MAX_BODY = 60000 COMMENT_CHARS = 1500 LAST_COMMENTS = 5 +BACKFILL_LIMIT = 5 # older-than-newest gaps tolerated (e.g. a mirror deleted by hand) DRY = False UPSTREAM = None @@ -64,6 +72,14 @@ def paged(path, limit=5000): out, page = [], 1 while len(out) < limit: batch = api(f"{path}{sep}per_page=100&page={page}") + # An empty page after a full one is either the end of an exact multiple + # of 100 or a transient API glitch. Treating a glitch as the end once cut + # the mirror listing short and re-created 40 mirrors; ask again first. + for attempt in range(2 if out else 0): + if batch: + break + time.sleep(5 * (attempt + 1)) + batch = api(f"{path}{sep}per_page=100&page={page}") if not batch: break out += batch @@ -131,6 +147,9 @@ def main(): ap.add_argument("--fork", required=True) ap.add_argument("--max-create", type=int, default=40, help="cap new mirrors per run (GitHub limits issue creation)") ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--allow-backfill", action="store_true", + help="create mirrors for upstream issues older than the newest one already mirrored " + "(normally a sign that the fork listing came back short)") a = ap.parse_args() up, fork, DRY = a.upstream, a.fork, a.dry_run UPSTREAM = up @@ -152,9 +171,32 @@ def main(): continue hit = MARKER.search(m.get("body") or "") if hit and hit.group(1).lower() == up.lower(): - by_up.setdefault(int(hit.group(2)), m) + n = int(hit.group(2)) + # With duplicates, track the open mirror, and the oldest of those. + # The listing is newest first, so taking the first one seen tracked + # a closed duplicate and left the open original stale. + seen = by_up.get(n) + if seen is None or (m["state"] == "open", -m["number"]) > (seen["state"] == "open", -seen["number"]): + by_up[n] = m + + def mirrors_of_up(issues): + return [m for m in issues or [] if (h := MARKER.search(m.get("body") or "")) and h.group(1).lower() == up.lower()] + + # An empty listing looks like a fresh fork; ask once more before seeding it. + if not by_up and mirrors_of_up(api(f"/repos/{fork}/issues?state=all&labels=mirror&per_page=100")): + sys.exit("refusing to create mirrors: the fork listing found no mirrors of " + f"{up}, but the fork has issues labelled `mirror`; rerun later") missing = sorted(n for n in open_up if n not in by_up) + # Mirrors are created oldest first, so a missing upstream issue older than + # the newest one already mirrored means the fork listing came back short. + # Creating them would duplicate existing mirrors (2026-10-22: 40 at once). + newest = max(by_up, default=0) + backfill = [n for n in missing if n < newest] + if len(backfill) > BACKFILL_LIMIT and not a.allow_backfill: + sys.exit(f"refusing to create mirrors: {len(backfill)} upstream issues older than #{newest} have no mirror " + f"(first: {backfill[:10]}), so the fork listing ({len(by_up)} mirrors) was probably incomplete. " + "Rerun later, or pass --allow-backfill if they are really unmirrored.") for n in missing[:a.max_create]: i = open_up[n] api(f"/repos/{fork}/issues", "POST", diff --git a/.claude/skills/issue-triage/test_mirror.py b/.claude/skills/issue-triage/test_mirror.py new file mode 100644 index 0000000000..f88421dba8 --- /dev/null +++ b/.claude/skills/issue-triage/test_mirror.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Scenario tests for mirror.py against a fake GitHub API (no network). + + python3 -I .claude/skills/issue-triage/test_mirror.py + +Covers the 2026-10-22 incident, where a short listing of existing mirrors made +mirror.py re-create 40 of them, and the duplicate-tracking bug it exposed. +""" +import contextlib, importlib.util, io, json, os, re, sys +sys.dont_write_bytecode = True # loading mirror.py must not leave __pycache__ in the skill dir +UP, FORK = "up/repo", "me/repo" + +def load(path): + spec = importlib.util.spec_from_file_location("mirror_" + str(id(path)), path) + mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) + mod.time.sleep = lambda s: None + return mod + +def mirror(fork_no, up_no, state="open", updated="T"): + return {"number": fork_no, "state": state, "labels": [{"name": "mirror"}], + "body": f"\nbody"} + +def run(path, up_open, fork_mirrors, glitch=lambda page, calls: False, args=()): + """glitch(page, call_index) -> True makes that fork-listing page come back empty.""" + mod = load(path) + calls = {"fork_pages": 0} + up_issues = [{"number": n, "title": f"t{n}", "body": "b", "updated_at": "T", "created_at": "2026-01-01", + "comments": 0, "labels": [], "user": {"login": "u"}} for n in up_open] + # the fork lists newest first, like GitHub + fork_sorted = sorted(fork_mirrors, key=lambda m: -m["number"]) + def api(path, method="GET", data=None, ok_codes=()): + if method != "GET": + return {} + m = re.search(r"[?&]page=(\d+)", path); page = int(m.group(1)) if m else 1 + if path.startswith(f"/repos/{UP}/issues?"): + return up_issues[(page-1)*100: page*100] + if path.startswith(f"/repos/{FORK}/issues?") and "labels=mirror" in path: + calls["fork_pages"] += 1 + if glitch(page, calls["fork_pages"]): + return [] + return fork_sorted[(page-1)*100: page*100] + if path.startswith(f"/repos/{FORK}/pulls"): + return [] + if path.startswith(f"/repos/{UP}/issues/"): + return {"state": "open"} + raise AssertionError(path) + mod.api = api + out = io.StringIO(); code = 0 + sys.argv = ["mirror.py", "--upstream", UP, "--fork", FORK, "--dry-run", *args] + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()) as err: + try: + mod.main() + except SystemExit as e: + code = e.code if isinstance(e.code, int) else 1 + err.write(str(e.code)) + lines = out.getvalue().strip().splitlines() + summary = json.loads(lines[-1]) if lines and code == 0 else None + return code, summary, err.getvalue() + +def scenarios(path): + res = {} + ups = list(range(1, 251)) + fork = [mirror(1000 + n, n) for n in ups] # 250 mirrors = 3 pages + # A: page 3 empty once (transient) -> recovered, nothing created + code, s, _ = run(path, ups, fork, glitch=lambda p, c: p == 3 and c == 3) + res["A transient empty page recovers"] = code == 0 and s["created"] == [] + # B: page 3 empty persistently -> must refuse, never create duplicates + code, s, _ = run(path, ups, fork, glitch=lambda p, c: p == 3) + res["B short listing refused"] = code != 0 + # C: whole listing empty -> must refuse + code, s, _ = run(path, ups, fork, glitch=lambda p, c: c == 1) + res["C empty listing refused"] = code != 0 + # D: duplicate pair, newer copy closed -> refresh the open original + code, s, _ = run(path, [7], [mirror(10, 7, "open", updated="OLD"), mirror(50, 7, "closed", updated="OLD")]) + res["D open original tracked"] = code == 0 and s["refreshed"] == [7] + # E: fresh fork seeds normally, oldest first, capped + code, s, _ = run(path, ups, []) + res["E fresh seed works"] = code == 0 and s["created"] == list(range(1, 41)) + # F: new upstream issues beyond the newest mirrored are created normally + code, s, _ = run(path, ups + [251, 252], fork) + res["F new issues still mirrored"] = code == 0 and s["created"] == [251, 252] + # G: backfill allowed explicitly + code, s, _ = run(path, ups, fork, glitch=lambda p, c: p == 3, args=["--allow-backfill"]) + res["G --allow-backfill overrides"] = code == 0 and len(s["created"]) == 40 + return res + +if __name__ == "__main__": + here = os.path.dirname(os.path.abspath(__file__)) + results = scenarios(os.path.join(here, "mirror.py")) + for k, v in results.items(): + print(("PASS " if v else "FAIL ") + k) + sys.exit(0 if all(results.values()) else 1)