From 83a94aaca6ed22f6a04a7c6256af9dc534785518 Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 19:24:12 +0000 Subject: [PATCH 01/23] fix(visitor): show features for version x.y reaches the feature table (#910) The bare VERSION branch of ExitShowStatement ran before the FEATURES branch and claimed 'show features for version 10.24', printing the session's show version output. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-visitor.jsonl | 1 + mdl/visitor/show_features_for_version_test.go | 39 +++++++++++++++++++ mdl/visitor/visitor_query.go | 4 +- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 mdl/visitor/show_features_for_version_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl index 859d97609..8a0a4a570 100644 --- a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl @@ -51,3 +51,4 @@ {"date": "2026-09-30", "area": "mdl/visitor", "symptom": "`fmt --upgrade --header` refused the header over `$Hit = find($Regions, $currentObject = $X)` when `$Regions` was assigned by `call microflow` (MDL-V1-LIST \"depends on a type the script does not state\"), although the called flow's return type settles it — in the same script (CapTrackV6 11-viewstate) or in the project (26-admin-goals)", "cause": "operandKind counted every `$x = call …` as an unknown definition: the visitor has no project, and never looked at the flows the script itself creates. The mdl 0 flow builder decides at exec time from `declaredVars[$x] == \"String\"`, which registerResultVariableType sets from the called flow's return type, looked up in the model as the earlier statements left it", "file": "mdl/visitor/visitor_upgrade_fixes.go (collectOperandDefs, operandReading, scriptFlowReturn), mdl/visitor/visitor_list_activities.go (both call sites: ExitListOperationStatement, setCallFix), mdl/ast/ast.go (LanguageNote.Operand / OperandChoice), mdl/upgrade/upgrade.go (Options.Flows, resolveOperand), mdl/executor/flow_return_types.go (FlowReturnTypes), cmd/mxcli/cmd_fmt.go (-p)", "fix": "Resolve in exec's order: a flow an earlier statement of the script creates (b.statements, the statements built so far) answers statically; otherwise the note carries both fixes as an OperandChoice and `fmt --upgrade -p` resolves it through executor.FlowReturnTypes, which calls the builder's own lookupMicroflowReturnType + registerResultVariableType. A drop/rename/move/`if not exists` of the callee earlier in the script, a callee the project lacks, and definitions that disagree stay refused", "insight": "Answer the upgrade's type question with the builder's resolver, not a copy (duplicate-resolver-drift). The execute-both control only discriminates in one direction: the builder turns a List operation over a declared String into the string function under EVERY version (addListOperationAction), so misreading a String as a list writes the same model; the dangerous misreading is a list read as a String (`set $x = find(…)` is always the string function under mdl 1). Choose the control from the direction that can hurt, or a green execute-both proves nothing. Verified on the CapTrackV6 rehearsal: 11-viewstate upgrades with no project, 26-admin-goals refuses without -p and upgrades with it, and its upgraded SUB_Admin_GoalScope execs as Unchanged on the project, same as the original", "issue": "ako/mxcli#860"} {"area": "mdl/visitor", "date": "2026-09-30", "symptom": "`LastImport: date,` in a headerless script (mxcli-ledger 01-domain-model.mdl) -> `type `date` is not supported \u2014 Mendix has no date-only type; mxcli silently stored it as DateTime` from fmt, check and exec alike; `fmt --upgrade` could not parse the file, so the migration needed a hand edit although `date` -> `DateTime` is exactly what every earlier build stored.", "cause": "#706 refused `date` together with `float`/`currency` in every version. The two cases differ: float/currency stored a String (a wrong model, refusal right), `date` stored the DateTime the author could only have meant \u2014 a respelling, which ADR-0011 makes a deprecated alias, not an error.", "file": "`mdl/visitor/visitor_silent_drops.go` (recordDateType), `mdl/visitor/visitor_helpers.go` + `visitor_microflow.go` (DATE_TYPE builds TypeDateTime), `mdl/grammar/domains/MDLDomainModel.g4` (`@alias MDL-DEPR160` on dataType and nonListDataType), `mdl/deprecation/deprecation.go` (DateType, RemovedIn 1), tests `mdl/visitor/visitor_date_alias_test.go`, `mdl/upgrade/date_alias_test.go`", "insight": "Sort a #706-style 'silently stored as X' refusal by what X was: if X is what the author meant (date -> DateTime), the form is an alias and belongs in the registry (warn, fmt rewrite, refuse under mdl 1); only when X is wrong (float -> String, dropped association options) is a refusal in every version right. For the alias to be provable, the builder must produce the canonical AST (TypeDateTime, not TypeDate), or the registry's Example/CanonicalExample same-AST test fails. Upgrading the ledger's pre-migration 01 now reproduces its hand-finished committed file byte for byte.", "refs": ["ako/mxcli#714", "ako/mxcli#706"]} {"date": "2026-10-01", "area": "mdl/visitor", "symptom": "ako/mxcli#877: mxcli-rest wrote `drop microflow if exists X;` between each flow's `/** … */` doc comment and its `create`; six flows lost their documentation. `mx check` passed, `exec` was clean, describe round-tripped, `mxcli check` said nothing; only lint QUAL002 (19 -> 22) noticed.", "cause": "The grammar's `statement: docComment? (…)` lets every statement start with a doc comment, but only the create exits read one (findDocComment); every other statement drops it silently. Nothing recorded that a comment was parsed and not stored.", "file": "mdl/visitor/visitor_doc_comment_placement.go, mdl/visitor/visitor_r2_children.go, mdl/visitor/visitor_strict_terminators.go, mdl/ast/ast.go, mdl/executor/validate_detached_doc_comments.go, mdl/executor/validate_program.go", "fix": "Enter/ExitStatement record, per top-level statement, a doc comment on a statement that does not store one (not a createStatement, or a create whose AST has no Documentation/OuterDocumentation/DocumentationSet field: create module, module/user role, demo user, …) as ast.Program.DetachedDocComments, with the next storing statement's head and line filled in when it is reached. ValidateProgram warns MDL089 naming that statement (check and exec, every version). The write-microflows pitfalls reference and check-syntax skill show drop-then-create with the comment between the drop and the create.", "test": "mdl/visitor TestDetachedDocComments (drop between comment and create, grant/revoke, create module; controls: comment above its create, entity and constant); mdl/executor TestValidateProgram_DetachedDocComment (message names the create and its line; control: kept comment not reported). Revert check: without the ExitStatement hook, 3 visitor subtests fail and the executor test reports 0 MDL089 warnings.", "insight": "A grammar that accepts a construct everywhere but a handful of exits read it is a silent-drop generator; record what was parsed and not consumed at the one rule that admits it, rather than auditing every reader. Surveying mdl-examples found the same loss in the repo's own scripts (section-header doc comments above grant/revoke/create module role)."} +{"date": "2026-10-01", "area": "mdl/visitor", "symptom": "`show features for version 10.24` (and `list features for version 10.24`) prints the session `show version` output (Mendix Version: 11.x ...) instead of the feature table; `show features` and `show features added since 10.24` work", "cause": "ExitShowStatement is one long else-if chain over token presence; the bare `ctx.VERSION() != nil` branch for `show version` sat ~380 lines above the FEATURES branch, and `for version` also carries the VERSION token, so the earlier, broader test won", "fix": "Guard the show-version branch with `ctx.FEATURES() == nil`", "insight": "In a token-presence else-if chain, a branch keyed on ONE token claims every alternative containing it; order the chain most-specific first or make each branch exclude the head keyword of the others. TestEveryShowAlternativeProducesAStatement cannot see this: it only checks that SOME statement is produced, not the right one", "issue": "ako/mxcli#910", "file": "mdl/visitor/visitor_query.go (ExitShowStatement)", "test": "mdl/visitor/show_features_for_version_test.go"} diff --git a/mdl/visitor/show_features_for_version_test.go b/mdl/visitor/show_features_for_version_test.go new file mode 100644 index 000000000..3cfe7fcf4 --- /dev/null +++ b/mdl/visitor/show_features_for_version_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestShowFeaturesForVersionRoutesToFeatures is ako/mxcli#910: `show features +// for version 10.24` printed the session's `show version` output, because the +// bare `ctx.VERSION() != nil` branch of ExitShowStatement ran before the +// FEATURES branch and swallowed the `for version` alternative. +func TestShowFeaturesForVersionRoutesToFeatures(t *testing.T) { + for _, src := range []string{ + "show features for version 10.24;", + "list features for version 10.24;", + "mdl 1;\nlist features for version 10.24;", + } { + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("%q: parse errors: %v", src, errs) + } + got := prog.Statements[len(prog.Statements)-1] + fs, ok := got.(*ast.ShowFeaturesStmt) + if !ok { + t.Fatalf("%q: got %T, want *ast.ShowFeaturesStmt", src, got) + } + if fs.ForVersion != "10.24" { + t.Errorf("%q: ForVersion = %q, want 10.24", src, fs.ForVersion) + } + } + // Control: the bare `show version` still routes to the session version. + prog, _ := Build("show version;") + if s, ok := prog.Statements[0].(*ast.ShowStmt); !ok || s.ObjectType != ast.ShowVersion { + t.Errorf("show version: got %#v, want ShowStmt{ShowVersion}", prog.Statements[0]) + } +} diff --git a/mdl/visitor/visitor_query.go b/mdl/visitor/visitor_query.go index 9a129685e..512b47f56 100644 --- a/mdl/visitor/visitor_query.go +++ b/mdl/visitor/visitor_query.go @@ -317,7 +317,9 @@ func (b *Builder) ExitShowStatement(ctx *parser.ShowStatementContext) { } } b.statements = append(b.statements, stmt) - } else if ctx.VERSION() != nil { + } else if ctx.VERSION() != nil && ctx.FEATURES() == nil { + // `show features for version x.y` also carries VERSION; it belongs to + // the FEATURES branch below (ako/mxcli#910). b.statements = append(b.statements, &ast.ShowStmt{ObjectType: ast.ShowVersion}) } else if ctx.CATALOG() != nil { // SHOW CATALOG STATUS or SHOW CATALOG TABLES From f4e5a995982b851edb0785fd34b9801a7428367e Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 19:24:43 +0000 Subject: [PATCH 02/23] fix(alter-page): set Editable writes a list view's boolean instead of reporting success with nothing stored (mendixlabs/mxcli#1214) A list view / data view stores Editable as a boolean; the setter only wrote strings, so `set Editable = true on lvRows` returned nil. The stored value's type now decides the vocabulary (true/false vs Always/Never), the input enum is canonicalised, anything else is refused, and EditableIf writes the Conditional enum beside its settings element as CREATE does. Co-Authored-By: Claude Opus 5.5 --- mdl/backend/pagemutator/editable_test.go | 107 +++++++++++++++++++++++ mdl/backend/pagemutator/mutator.go | 81 ++++++++++++++++- 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 mdl/backend/pagemutator/editable_test.go diff --git a/mdl/backend/pagemutator/editable_test.go b/mdl/backend/pagemutator/editable_test.go new file mode 100644 index 000000000..b09e9d297 --- /dev/null +++ b/mdl/backend/pagemutator/editable_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// mendixlabs/mxcli#1214: `alter page … set Editable = true on ` +// reported "Altered page" and stored nothing. A list view's (and data view's) +// Editable is a BOOLEAN; the setter only wrote string values (the input-widget +// enum), so a boolean fell through and returned nil. +func TestSetWidgetProperty_Editable_ListViewBoolean(t *testing.T) { + for _, typ := range []string{"Forms$ListView", "Forms$DataView"} { + t.Run(typ, func(t *testing.T) { + w := bson.D{ + {Key: "$Type", Value: typ}, + {Key: "Name", Value: "lv"}, + {Key: "Editable", Value: false}, + } + rawData := makeRawPage(w) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("lv", "Editable", true); err != nil { + t.Fatalf("set Editable = true: %v", err) + } + if got := bsonnav.DGet(findBsonWidget(rawData, "lv").widget, "Editable"); got != true { + t.Fatalf("Editable = %#v after set Editable = true, want true", got) + } + // Back to false, so the test is not satisfied by a setter that only ever writes true. + if err := m.SetWidgetProperty("lv", "editable", false); err != nil { + t.Fatalf("set Editable = false: %v", err) + } + if got := bsonnav.DGet(findBsonWidget(rawData, "lv").widget, "Editable"); got != false { + t.Fatalf("Editable = %#v after set Editable = false, want false", got) + } + // The input-widget enum is not a list view's vocabulary: refuse, never no-op. + if err := m.SetWidgetProperty("lv", "Editable", "Never"); err == nil { + t.Fatal("set Editable = 'Never' on a list view succeeded; want an error naming true/false") + } + }) + } +} + +// An input widget's Editable is the Always/Never/Conditional enum. A boolean +// used to be dropped silently; a lower-case enum was stored verbatim, which is +// not a value Studio Pro reads. +func TestSetWidgetProperty_Editable_InputEnum(t *testing.T) { + w := bson.D{ + {Key: "$Type", Value: "Forms$TextBox"}, + {Key: "Name", Value: "tb"}, + {Key: "Editable", Value: "Always"}, + {Key: "ConditionalEditabilitySettings", Value: nil}, + } + rawData := makeRawPage(w) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + + if err := m.SetWidgetProperty("tb", "Editable", "never"); err != nil { + t.Fatalf("set Editable = 'never': %v", err) + } + if got := bsonnav.DGet(findBsonWidget(rawData, "tb").widget, "Editable"); got != "Never" { + t.Fatalf("Editable = %#v, want the canonical \"Never\"", got) + } + if err := m.SetWidgetProperty("tb", "Editable", true); err == nil { + t.Fatal("set Editable = true on a text box succeeded; want an error naming Always/Never") + } + if err := m.SetWidgetProperty("tb", "Editable", "Conditional"); err == nil { + t.Fatal("set Editable = 'Conditional' succeeded with no expression; want an error pointing at [expr]") + } + + // EDITABLE IF writes the settings element AND the enum that says it applies, + // as CREATE does (pages.WidgetEditability). Setting a plain value afterwards + // clears the element again, so the two never contradict each other. + if err := m.SetWidgetProperty("tb", "EditableIf", "$currentObject/Active"); err != nil { + t.Fatalf("set EditableIf: %v", err) + } + got := findBsonWidget(rawData, "tb").widget + if e := bsonnav.DGet(got, "Editable"); e != "Conditional" { + t.Fatalf("Editable = %#v after set Editable = [expr], want \"Conditional\"", e) + } + if err := m.SetWidgetProperty("tb", "Editable", "Always"); err != nil { + t.Fatalf("set Editable = 'Always': %v", err) + } + got = findBsonWidget(rawData, "tb").widget + if e := bsonnav.DGet(got, "Editable"); e != "Always" { + t.Fatalf("Editable = %#v, want \"Always\"", e) + } + if s := bsonnav.DGet(got, "ConditionalEditabilitySettings"); s != nil { + t.Fatalf("ConditionalEditabilitySettings = %#v after set Editable = 'Always', want null", s) + } +} + +// A widget with no Editable property at all (a container) refuses the set. +func TestSetWidgetProperty_Editable_NoSuchProperty(t *testing.T) { + w := bson.D{ + {Key: "$Type", Value: "Forms$DivContainer"}, + {Key: "Name", Value: "ctn"}, + } + rawData := makeRawPage(w) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("ctn", "Editable", true); err == nil { + t.Fatal("set Editable on a container succeeded; it has no Editable property") + } +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 499883a9e..a92749a29 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -2818,6 +2818,76 @@ func setWidgetConditionalSettingMut(widget bson.D, field, typeName, expression s return bsonnav.DSet(widget, field, doc) } +// setWidgetEditableMut writes `set Editable = …`. Two widget families store two +// different things under the same key, so the stored value's type decides what +// the statement may say: +// +// - a list view or data view stores a BOOLEAN — `true` / `false`; +// - an input widget stores the Always / Never / Conditional enum. +// +// Anything else is refused. Before mendixlabs/mxcli#1214 only a string was +// written, so `set Editable = true on lvRows` — the boolean every list view +// takes — returned nil and the command reported "Altered page" with nothing +// stored, leaving the list's inputs read-only at runtime. +func setWidgetEditableMut(widget bson.D, value any) error { + typ := bsonnav.DGetString(widget, "$Type") + switch bsonnav.DGet(widget, "Editable").(type) { + case bool: + b, ok := editableBool(value) + if !ok { + return fmt.Errorf("Editable on a %s is true or false, not %v", widgetTypeLabel(typ), value) + } + bsonnav.DSet(widget, "Editable", b) + return nil + case string: + s, isString := value.(string) + canon, ok := pages.CanonicalEditability(s) + if !isString || !ok { + return fmt.Errorf("Editable on a %s is Always or Never (or `Editable = [expression]` for a condition), not %v", + widgetTypeLabel(typ), value) + } + if canon == "Conditional" { + // The enum without its settings element is a condition with no + // expression; the bracketed form writes both. + return fmt.Errorf("`set Editable = Conditional` needs the condition itself: write `set Editable = [expression]`") + } + bsonnav.DSet(widget, "Editable", canon) + // A plain value replaces a stored condition rather than contradicting it. + bsonnav.DSet(widget, "ConditionalEditabilitySettings", nil) + return nil + default: + return fmt.Errorf("a %s has no Editable property", widgetTypeLabel(typ)) + } +} + +// editableBool reads a list/data view's boolean Editable from MDL, which hands +// it over as a bool (or, quoted, as the string "true"/"false"). +func editableBool(value any) (bool, bool) { + switch v := value.(type) { + case bool: + return v, true + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true": + return true, true + case "false": + return false, true + } + } + return false, false +} + +// widgetTypeLabel is a stored $Type as an author reads it: "Forms$ListView" → "ListView". +func widgetTypeLabel(typ string) string { + if i := strings.LastIndex(typ, "$"); i >= 0 { + typ = typ[i+1:] + } + if typ == "" { + return "widget" + } + return typ +} + func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { // Property names arrive verbatim from MDL (any case) — `set class on …` is as // valid as `set Class on …`, and `create page` reads them case-insensitively @@ -2864,10 +2934,7 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { } return nil case "editable": - if s, ok := value.(string); ok { - bsonnav.DSet(widget, "Editable", s) - } - return nil + return setWidgetEditableMut(widget, value) case "visible": // A page widget has no plain boolean "Visible" field — visibility is modeled // via ConditionalVisibilitySettings. Route static booleans and expression @@ -2900,6 +2967,12 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { "Forms$ConditionalEditabilitySettings", expr, false) { return fmt.Errorf("widget does not support conditional editability (only input widgets are editable)") } + // The element only applies under the enum that says so — CREATE writes + // both (pages.WidgetEditability); writing the element alone left the + // stored enum saying Always or Never beside it. + if _, isEnum := bsonnav.DGet(widget, "Editable").(string); isEnum { + bsonnav.DSet(widget, "Editable", "Conditional") + } return nil case "name": if s, ok := value.(string); ok { From 93a2bb0eeebc1a5fa4518441ccca9e99435cbc9b Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 19:25:53 +0000 Subject: [PATCH 03/23] feat(cli): mxcli version subcommand (#534) Prints exactly what --version prints (version and build time), the bare-subcommand spelling go, docker, gh and mx all accept. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + cmd/mxcli/cmd_version.go | 29 ++++++++++++++ cmd/mxcli/cmd_version_test.go | 40 +++++++++++++++++++ docs-site/src/appendixes/quick-reference.md | 1 + 4 files changed, 71 insertions(+) create mode 100644 cmd/mxcli/cmd_version.go create mode 100644 cmd/mxcli/cmd_version_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index 17a066416..c97f7a0b5 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -135,3 +135,4 @@ {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "The LSP's CREATE MICROFLOW / CREATE NANOFLOW / CREATE ENUMERATION snippet completions inserted MDL that does not parse under any language version (`missing '(' at 'BEGIN'`, a quoted enumeration value name), the CONSTANT snippet the deprecated clause form (MDL-DEPR136), and PAGE/SNIPPET a statement mdl 1 refuses for its missing `;`; completion also offered alias-only keywords (SHOW_PAGE, DELETE_BEHAVIOR, DEFINE) and the `show entities` listings after SHOW.", "cause": "The snippets and keyword lists were hand-written once and never parsed; the grammar moved on (R2 property lists, R8 words, `list` for `show`) and nothing tied the completion text to it. The keyword list is generated from the lexer alone, which cannot tell an alias token from a canonical one: that is said by the `/* @alias MDL-DEPRnnn */` markers in the parser grammar.", "fix": "Snippets rewritten to canonical mdl 1. cmd/gen-completions reads the parser grammar too and drops a token every parser-rule use of which carries an @alias marker (the `keyword` rule, which lists tokens usable as names, does not count). `list` gets the listings; `show` offers page/message/home page.", "insight": "A completion text is MDL that ships in the binary, so it is held to what docs are held to: TestCompletionSnippetsAreMdl1 parses every snippet, expanded with its defaults, under `mdl 1;` and requires no deprecation. Deciding alias-only from the markers is the registry's own data; a token-swap rewrite word (`snippet`, `column`, `comment`) is NOT alias-only \u2014 those words stay canonical elsewhere \u2014 and neither is SHOW (show page, show message).", "issue": "ako/mxcli#714 (decision 5)", "file": "cmd/mxcli/lsp_completion.go, cmd/gen-completions/aliases.go", "test": "cmd/mxcli/lsp_mdl1_test.go TestCompletionSnippetsAreMdl1, TestCompletionOffersNoDeprecatedSpelling, TestCompletionListAndShowContinueIntoMdl1; cmd/gen-completions/aliases_test.go"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "A stub-then-real script set (two files each with `create or modify microflow X`) upgraded with `fmt --upgrade -p` one file at a time ended up split: the stub's file declined the header (MDL-V1-REBUILD) and the real flow's file took it. Run in order, the mdl 0 stub rebuilt the stored real flow and the mdl 1 real statement was refused, every run; mx check 0 errors, the app running the placeholder.", "cause": "Each command judged one file against the model as stored; check/fmt took exactly one file, so nothing could see a flow declared twice across the set, and a per-file header decision is wrong for a pair whose files must agree.", "fix": "check and fmt accept several files as one script set (cmd/mxcli/script_set.go): findFlowRedeclarations finds a flow declared by two create-or-modify statements; check warns MDL-STUB01 naming both; fmt --upgrade groups files sharing such a flow and declines the header for all of them when any one cannot take it (canTakeHeader = the per-file verdict). One file alone is unchanged.", "insight": "A per-file verdict is only right when the files are independent; the run is the unit when two files write the same document. The symptom hides because each half is individually correct (decline is right for the stub, header is right for the real file) - only the combination is wrong, so test the set, with the single-file decision as the control.", "issue": "ako/mxcli#905", "file": "cmd/mxcli/script_set.go", "test": "cmd/mxcli/script_set_test.go TestFmtUpgrade_ScriptSetDecidesStubThenRealHeaderTogether, TestCheck_ScriptSetWarnsOnStubThenReal"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "fmt --upgrade over a stub-then-real set still split the pair when the stub's file carried a written `mdl 0;`: the real file took `mdl 1;`. With the real file already `mdl 1;`, fmt printed 'no language header added' about it while the pair stayed split.", "cause": "canTakeHeader returned true for ANY written header (langver.ScanWrittenHeader's bool), reading a pinned `mdl 0;` as 'already has the header'; and the group decline was applied to a file that already carries the header, which fmt never removes.", "fix": "canTakeHeader: a written header can take it only when it is langver.Latest. decideSetHeaders: a file already under the header is not declined; fmt says the pair stays under different headers and to drop the stub or take the header off.", "insight": "ScanWrittenHeader's bool means 'a header is written', not 'the header is mdl 1'; a written mdl 0 pin is the strongest 'cannot take it' there is. Test the group decision with every header state of each file, not only headerless ones.", "issue": "ako/mxcli#905", "file": "cmd/mxcli/cmd_fmt.go", "test": "cmd/mxcli/script_set_test.go TestFmtUpgrade_ScriptSetPinnedMdl0StubHoldsTheRealFileBack, TestFmtUpgrade_ScriptSetAlreadySplitPairIsReportedAsSplit"} +{"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli version` fails with `unknown command \"version\" for \"mxcli\"` while `mxcli --version` works", "cause": "cobra's `Version:` field registers only the --version flag; no subcommand existed, though shouldSuppressWarning already listed a `version` argument", "fix": "Add a `version` subcommand (cmd/mxcli/cmd_version.go) printing exactly cobra's --version text (name, version, build time)", "insight": "Code that special-cases an argument (warning suppression) is evidence of an intended command; a test pinning the subcommand output to the flag output keeps the two from drifting", "issue": "ako/mxcli#534", "file": "cmd/mxcli/cmd_version.go", "test": "cmd/mxcli/cmd_version_test.go"} diff --git a/cmd/mxcli/cmd_version.go b/cmd/mxcli/cmd_version.go new file mode 100644 index 000000000..01530845e --- /dev/null +++ b/cmd/mxcli/cmd_version.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// versionCmd is `mxcli version`, the bare-subcommand spelling every +// neighbouring tool (go, docker, gh, mx) accepts. It prints exactly what +// `--version` prints — the build version and build time compiled in through +// -X main.Version / -X main.BuildTime — so a bug report quoting either one +// identifies the same build (ako/mxcli#534). +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the mxcli version and build time", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + root := cmd.Root() + // Same text as cobra's default --version template. + fmt.Fprintf(cmd.OutOrStdout(), "%s version %s\n", root.Name(), root.Version) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/cmd/mxcli/cmd_version_test.go b/cmd/mxcli/cmd_version_test.go new file mode 100644 index 000000000..411973aaf --- /dev/null +++ b/cmd/mxcli/cmd_version_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +// TestVersionSubcommand is ako/mxcli#534: `mxcli version` was an unknown +// command although --version worked and shouldSuppressWarning already +// expected a `version` argument. The subcommand must print exactly what +// --version prints, so a bug report quoting either one carries the same +// build identification. +func TestVersionSubcommand(t *testing.T) { + sub := runRootForTest(t, []string{"version"}) + flag := runRootForTest(t, []string{"--version"}) + if strings.TrimSpace(sub) == "" { + t.Fatal("`mxcli version` printed nothing") + } + if sub != flag { + t.Errorf("`mxcli version` = %q, `mxcli --version` = %q; want identical", sub, flag) + } + if !strings.Contains(sub, rootCmd.Version) { + t.Errorf("`mxcli version` = %q, does not carry the build version %q", sub, rootCmd.Version) + } +} + +func runRootForTest(t *testing.T, args []string) string { + t.Helper() + rootCmd.SetOut(nil) + out, err := captureStdout(t, func() error { + rootCmd.SetArgs(args) + return rootCmd.Execute() + }) + if err != nil { + t.Fatalf("`mxcli %v` failed: %v\n%s", args, err, out) + } + return out +} diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 7c4ccd93c..85f10b8ee 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -555,6 +555,7 @@ Cross-reference commands require `REFRESH CATALOG FULL` to populate reference da | External SQL | `mxcli sql --driver postgres --dsn '...' "SELECT 1"` | Direct database query | | Docker build | `mxcli docker build -p app.mpr` | Build with PAD patching | | Docker check | `mxcli docker check -p app.mpr` | Validate with `mx check` | +| Version | `mxcli version` (or `--version`) | Build version and build time | | Diagnostics | `mxcli diag [--bundle]` | Session logs, version info | | Loop report | `mxcli diag loop-report [--json]` | Which mxcli commands a session actually ran, how often, how long | | New project | `mxcli new --version X.Y.Z` | Create project from scratch with all tooling | From c7f89f5e0608e7e54c5b4b867faf205e64a607f7 Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 19:27:24 +0000 Subject: [PATCH 04/23] fix(domainmodel): UpdateAttribute carries the stored attribute GUID (#627) The fluent API's AttributeModifier.Apply() rebuilt the attribute with raw == nil and kept only its $ID, so the codec minted GUID = $ID - the #1119 data-loss class. The write guard refused it, leaving the API unusable on any Studio Pro-authored attribute. Carry the stored raw bytes and export level via carryStoredAttribute, now shared with the entity rewrite's carryAttributeIdentity. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-backend.jsonl | 1 + mdl/backend/modelsdk/domainmodel_alter.go | 8 + .../modelsdk/domainmodel_child_identity.go | 29 ++-- .../issue627_update_attribute_guid_test.go | 138 ++++++++++++++++++ 4 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 mdl/backend/modelsdk/issue627_update_attribute_guid_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index b42032884..bb7f53251 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -149,3 +149,4 @@ {"area": "mdl/backend", "date": "2026-09-29", "symptom": "describe -> exec, CREATE OR MODIFY or an ALTER that rebuilds the document turns an API-exported document Hidden: enumerations, pages, layouts, rules, view-entity OQL source documents, import/export mappings, JSON structures, published and consumed REST services, scheduled events, workflows, database connections, business event services, data transformers, queues, regular expressions and agent-editor documents. The run reports success, mx check is clean; the module's public surface silently shrinks. A workflow's own `export level API` clause was a no-op on create and on rewrite.", "cause": "Each rewrite converter builds a fresh document and writes ExportLevel as a constant (\"Hidden\"), or passes the semantic model's value where the executor itself filled in \"Hidden\" (mappings, database connection, business events), and the unit is replaced wholesale. MDL has no export-level spelling for most of these kinds, so describe cannot print it and the executed script cannot restore it. workflowToGen ignored wf.ExportLevel entirely. The round-trip harness could not see it: every document in TestApp and PedApp is Hidden, the constant itself.", "file": "mdl/backend/modelsdk/export_level_carry.go", "fix": "One byte-level carry, keepStoredExportLevel(unitID, contents): replaces only the top-level ExportLevel element of the freshly encoded rewrite with the stored value, copying every other element verbatim, and never adds the key. Wired into every Update path that writes ExportLevel (UpdateEnumeration/Rule/Layout/ImportMapping/ExportMapping/JsonStructure/PublishedRestService/ConsumedRestService/DataTransformer/DatabaseConnection/BusinessEventService, writeCustomBlob update, WriteViewEntitySourceDocument update; page via carryStoredPageHeader). Kinds with an MDL spelling (workflow, scheduled event, queue, regular expression) use keepStoredExportLevelUnlessSet: an authored level wins. workflowToGen now writes orDefault(wf.ExportLevel, \"Hidden\").", "insight": "A fixture-driven round trip is blind to any constant that happens to equal every fixture value: 775 TestApp documents round-tripped while 10 kinds hid API documents. Set the subject to the non-default value first (here: patch ExportLevel to API on the working copy) and run both the plain describe output and an edited one, because an elided unchanged write passes a converter that still writes the constant. Carrying at the encoded-bytes level covers gen-typed, newElem-built and hand-serialized writers with one helper, where a gen setter per converter would have needed three mechanisms.", "refs": ["ako/mxcli#816", "ako/mxcli#801", "ako/mxcli#812"], "test": "mdl/backend/modelsdk/issue816_export_level_test.go (TestUpdatePaths_KeepStoredExportLevel, 18 kinds); mdl/roundtrip/export_level_test.go (TestTestAppExportLevelSurvivesRoundTrip, -tags integration)"} {"date": "2026-09-30", "area": "mdl/backend", "symptom": "ako/mxcli#859 review of PR #864: after the built comparison landed, changing or adding `show page M.P with title = 'X'` in a `create or modify microflow` reported \"Unchanged microflow\" and wrote nothing, under mdl 0 and mdl 1 (main spliced it). Nothing warned.", "cause": "builtAsStored compares the declared flow and the stored flow both READ BACK through the codec, so any property the reader drops compares equal whatever either side holds. The ShowFormAction reader never read FormSettings.TitleOverride. Probing encode(built) against encode(readback(built)) over mdl-examples found the reader also dropped ExclusiveSplit/LoopedActivity ErrorHandlingType and a REST call's bound output variable (ResultHandling.ResultVariableName -> RestCallAction.OutputVariable), plus CallWebServiceAction (#861). Before the built comparison such a loss was a visible phantom re-splice; after it, a silently dropped edit.", "fix": "ReadBackMicroflow/ReadBackNanoflow re-encode what they read back and refuse (error -> statement diff, the pre-#859 path) when it is not the document first written, $IDs aside (sameWritten). The reader now reads TitleOverride, the split's and loop's ErrorHandlingType, and a bound REST call's OutputVariable, so those flows keep matching.", "insight": "A comparison made on both sides through the same lossy reader cannot see what the reader loses; the lost property becomes a change that is never written. When equality is decided after a decode, prove the decode lossless for the value at hand (write it again and compare bytes) and fall back when it is not. The probe that found the fields: diff encode(x) with encode(decode(encode(x))) over every mdl-examples flow.", "issue": "ako/mxcli#859", "file": "mdl/backend/modelsdk/microflow_readback.go, mdl/backend/modelsdk/microflow_read_actions.go, mdl/backend/modelsdk/microflow.go, mdl/roundtrip/flow_idempotent_shapes_test.go"} {"date": "2026-09-30", "area": "mdl/backend", "symptom": "ako/mxcli#843 (rehearsal M2): under mdl 1, `create or modify nanoflow … returns Boolean as $Done` over a nanoflow stored without a return variable refuses \"the stored document has no ReturnVariableName property … set it in Studio Pro\"; the same statement on a microflow reports \"set: ReturnVariableName\".", "cause": "mfmutator.SetHeader refuses any stated header key the stored document lacks (a key the project version does not declare makes the document unopenable). mxcli's nanoflow writer omits ReturnVariableName when the statement has no `as $Var`, while the microflow writer always writes it on 10+, so only nanoflows hit the refusal.", "fix": "Optional mfmutator.PropertyDeclarer on Deps; the codec deps answer from the metamodel version data (type, then Microflows$MicroflowBase; ReturnVariableName is 10.12+) against the project version, and SetHeader inserts the key after its predecessor in the encoder's order. No answer (MCP, unknown version) keeps the refusal.", "insight": "A refusal keyed on 'the stored document lacks the key' conflates 'this version has no such property' with 'the writer left it out'; the metamodel version data separates the two. Studio Pro 11 stores ReturnVariableName on every nanoflow (PedApp: 13 of 13), so adding it matches what Studio Pro writes.", "issue": "ako/mxcli#843", "file": "mdl/backend/mfmutator/header.go"} +{"date": "2026-10-01", "area": "mdl/backend", "symptom": "ako/mxcli#627: the fluent API's AttributeModifier.Apply() (Backend.UpdateAttribute) on a Studio Pro-authored attribute is refused with \"refusing to write unit …: 1 element(s) kept their $ID but would be written with a different GUID (DomainModels$Attribute)\"; without the #1119 guard it would re-mint the GUID and drop the column on the next deploy.", "cause": "UpdateAttribute rebuilds the attribute with attributeToGen (raw == nil) and carried only the $ID, so the codec's EmitGUID default wrote GUID = $ID. No MDL statement reaches it — every ALTER ENTITY form goes through UpdateEntity, which has the carry — so it was found by enumerating the converter's call sites, not by a repro.", "fix": "UpdateAttribute carries the stored attribute's raw bytes and export level onto the rebuild via carryStoredAttribute, the helper now shared with carryAttributeIdentity. Attribute -> Attribute keeps $Type, so SetRaw suffices.", "insight": "Test on PedApp (GUID != $ID); an mxcli-created attribute re-mints the same value and cannot fail. The unfixed code fails the test via the write guard's refusal, which is the observable symptom on main.", "issue": "ako/mxcli#627", "file": "mdl/backend/modelsdk/domainmodel_alter.go"} diff --git a/mdl/backend/modelsdk/domainmodel_alter.go b/mdl/backend/modelsdk/domainmodel_alter.go index ccd3abf86..bcc65de6e 100644 --- a/mdl/backend/modelsdk/domainmodel_alter.go +++ b/mdl/backend/modelsdk/domainmodel_alter.go @@ -774,6 +774,14 @@ func (b *Backend) UpdateAttribute(domainModelID, entityID model.ID, attr *domain next := attributeToGen(attr, entityIsExternal(ent)) next.SetID(items[idx].ID()) + // attributeToGen returns raw == nil, which the codec reads as a NEW element + // and mints GUID = $ID for — the runtime keys mendixsystem$attribute.id on + // that GUID and would drop the column (ako/mxcli#627, the #1119 class). + // Attribute -> Attribute keeps the $Type, so carrying the stored raw bytes + // is enough; no raw transform is needed. + if stored, ok := items[idx].(*genDm.Attribute); ok { + carryStoredAttribute(next, stored) + } // The generated list offers only Append and Remove, so an in-place replace // means rebuilding it. Order is worth the rebuild: it is the order Studio diff --git a/mdl/backend/modelsdk/domainmodel_child_identity.go b/mdl/backend/modelsdk/domainmodel_child_identity.go index b07c04bb9..ca0d91a38 100644 --- a/mdl/backend/modelsdk/domainmodel_child_identity.go +++ b/mdl/backend/modelsdk/domainmodel_child_identity.go @@ -82,15 +82,7 @@ func carryAttributeIdentity(ge, orig *genDm.Entity, entity *domainmodel.Entity) claimed := make(map[string]bool, len(storedByID)) carry := func(ga, sa *genDm.Attribute) { - ga.SetID(sa.ID()) - ga.SetRaw(sa.Raw()) - // attributeToGen sets ExportLevel "Hidden", and a property the rebuild - // sets wins over the carried raw bytes — so without this an API attribute - // became Hidden on every rewrite of its entity (ako/mxcli#801). The - // semantic attribute has no export level to take it from. - if lvl := sa.ExportLevel(); lvl != "" { - ga.SetExportLevel(lvl) - } + carryStoredAttribute(ga, sa) claimed[string(sa.ID())] = true } @@ -170,3 +162,22 @@ func carryIndexIdentity(ge, orig *genDm.Entity, entity *domainmodel.Entity) { claimed[string(si.ID())] = true } } + +// carryStoredAttribute makes the rebuilt attribute ga the stored attribute sa as +// far as identity goes: the stored $ID and raw bytes (so the GUID passes through +// instead of being re-minted as $ID — #1119, ako/mxcli#627) and the stored export +// level. Both rebuild sites use it: the entity rewrite (carryAttributeIdentity) +// and the single-attribute rewrite (Backend.UpdateAttribute). +func carryStoredAttribute(ga, sa *genDm.Attribute) { + ga.SetID(sa.ID()) + if raw := sa.Raw(); raw != nil { + ga.SetRaw(raw) + } + // attributeToGen sets ExportLevel "Hidden", and a property the rebuild + // sets wins over the carried raw bytes — so without this an API attribute + // became Hidden on every rewrite of its entity (ako/mxcli#801). The + // semantic attribute has no export level to take it from. + if lvl := sa.ExportLevel(); lvl != "" { + ga.SetExportLevel(lvl) + } +} diff --git a/mdl/backend/modelsdk/issue627_update_attribute_guid_test.go b/mdl/backend/modelsdk/issue627_update_attribute_guid_test.go new file mode 100644 index 000000000..f73ef0c0b --- /dev/null +++ b/mdl/backend/modelsdk/issue627_update_attribute_guid_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// copyPedApp copies the Studio Pro-authored PedApp fixture into a temp dir. Its +// elements have GUID != $ID, which is the only subject on which a re-minted +// storage GUID is observable (CLAUDE.md, "A GUID Is the Database's Identity"). +func copyPedApp(t *testing.T) string { + t.Helper() + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS("../../../testdata/pedapp")); err != nil { + t.Fatalf("copy PedApp fixture: %v", err) + } + return filepath.Join(dst, "PedApp.mpr") +} + +// TestIssue627_UpdateAttributePreservesGUID guards ako/mxcli#627: the +// AttributeModifier path of the fluent API (Backend.UpdateAttribute) rebuilt the +// attribute from the semantic model with raw == nil, so the codec minted +// GUID = $ID — the #1119 data-loss class, the column dropped on the next deploy. +// The #1119 write guard refused it, which left the API unusable on any Studio +// Pro-authored attribute. +// +// The subject is a PedApp string attribute whose stored GUID differs from its +// $ID; the comparison is against the GUID read before the write, never against a +// previous run (a re-mint is stable, so a second write is elided). +func TestIssue627_UpdateAttributePreservesGUID(t *testing.T) { + proj := copyPedApp(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + + dmID, ent, attr := studioProStringAttribute(t, b) + before := attributeGUIDs(t, b, dmID, ent.ID) + ids := attributeIDs(t, b, dmID, ent.ID) + want := before[attr.Name] + if want == "" || want == ids[attr.Name] { + t.Fatalf("subject %s.%s: GUID %q, $ID %q — need a stored GUID that differs from $ID", + ent.Name, attr.Name, want, ids[attr.Name]) + } + + st := attr.Type.(*domainmodel.StringAttributeType) + st.Length += 7 + if err := b.UpdateAttribute(dmID, ent.ID, attr); err != nil { + t.Fatalf("UpdateAttribute: %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() }) + + after := attributeGUIDs(t, b2, dmID, ent.ID) + if got := after[attr.Name]; got != want { + t.Errorf("attribute %s.%s: storage GUID changed %s -> %s", ent.Name, attr.Name, want, got) + } + // The siblings pass through as stored bytes; assert it so a future rebuild + // of the whole list is caught here too. + for name, g := range before { + if after[name] != g { + t.Errorf("sibling attribute %s: storage GUID changed %s -> %s", name, g, after[name]) + } + } + // And the edit itself landed — otherwise "GUID unchanged" proves nothing. + dm, err := b2.GetDomainModel(moduleOfDM(t, b2, dmID)) + if err != nil { + t.Fatalf("GetDomainModel: %v", err) + } + var gotLen int + for _, e := range dm.Entities { + if e.ID != ent.ID { + continue + } + for _, a := range e.Attributes { + if a.Name == attr.Name { + if s, ok := a.Type.(*domainmodel.StringAttributeType); ok { + gotLen = s.Length + } + } + } + } + if gotLen != st.Length { + t.Errorf("attribute %s.%s: length = %d after UpdateAttribute, want %d", ent.Name, attr.Name, gotLen, st.Length) + } +} + +// studioProStringAttribute picks the first string attribute with a non-zero +// length in a loadable, non-System domain model. +func studioProStringAttribute(t *testing.T, b *Backend) (model.ID, *domainmodel.Entity, *domainmodel.Attribute) { + t.Helper() + dms, err := b.ListDomainModels() + if err != nil { + t.Fatalf("ListDomainModels: %v", err) + } + for _, d := range dms { + if _, err := b.loadDomainModelGen(d.ID); err != nil { + continue + } + for _, e := range d.Entities { + for _, a := range e.Attributes { + if s, ok := a.Type.(*domainmodel.StringAttributeType); ok && s.Length > 0 { + return d.ID, e, a + } + } + } + } + t.Fatal("no string attribute in a loadable PedApp domain model") + return "", nil, nil +} + +func moduleOfDM(t *testing.T, b *Backend, dmID model.ID) model.ID { + t.Helper() + dms, err := b.ListDomainModels() + if err != nil { + t.Fatalf("ListDomainModels: %v", err) + } + for _, d := range dms { + if d.ID == dmID { + return d.ContainerID + } + } + t.Fatalf("domain model %s not found", dmID) + return "" +} From d25d79a6492bbfbbb4076ff800972246068f5a8a Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 19:57:08 +0000 Subject: [PATCH 05/23] fix(cli): read MDL scripts with a UTF-8 BOM or in UTF-16 (mendixlabs/mxcli#1253) Windows PowerShell 5.1 writes a UTF-8 BOM (Set-Content -Encoding UTF8) or UTF-16LE ('>'), and every script reader passed the raw bytes to the lexer: an invisible token-recognition error at line 1:0. One decoder, mdl/srctext, now strips the BOM and decodes UTF-16 for check, exec, fmt, diff, the multi-file check pass, the test runner and EXECUTE SCRIPT. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + cmd/mxcli/cmd_check.go | 2 +- cmd/mxcli/cmd_diff.go | 2 +- cmd/mxcli/cmd_fmt.go | 4 +- cmd/mxcli/mdlsource.go | 20 ++++- cmd/mxcli/mdlsource_encoding_test.go | 85 +++++++++++++++++++ cmd/mxcli/testrunner/parser.go | 9 +- mdl/executor/cmd_misc.go | 9 +- mdl/srctext/srctext.go | 68 +++++++++++++++ mdl/srctext/srctext_test.go | 68 +++++++++++++++ 10 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 cmd/mxcli/mdlsource_encoding_test.go create mode 100644 mdl/srctext/srctext.go create mode 100644 mdl/srctext/srctext_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index c97f7a0b5..ce5852126 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -136,3 +136,4 @@ {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "A stub-then-real script set (two files each with `create or modify microflow X`) upgraded with `fmt --upgrade -p` one file at a time ended up split: the stub's file declined the header (MDL-V1-REBUILD) and the real flow's file took it. Run in order, the mdl 0 stub rebuilt the stored real flow and the mdl 1 real statement was refused, every run; mx check 0 errors, the app running the placeholder.", "cause": "Each command judged one file against the model as stored; check/fmt took exactly one file, so nothing could see a flow declared twice across the set, and a per-file header decision is wrong for a pair whose files must agree.", "fix": "check and fmt accept several files as one script set (cmd/mxcli/script_set.go): findFlowRedeclarations finds a flow declared by two create-or-modify statements; check warns MDL-STUB01 naming both; fmt --upgrade groups files sharing such a flow and declines the header for all of them when any one cannot take it (canTakeHeader = the per-file verdict). One file alone is unchanged.", "insight": "A per-file verdict is only right when the files are independent; the run is the unit when two files write the same document. The symptom hides because each half is individually correct (decline is right for the stub, header is right for the real file) - only the combination is wrong, so test the set, with the single-file decision as the control.", "issue": "ako/mxcli#905", "file": "cmd/mxcli/script_set.go", "test": "cmd/mxcli/script_set_test.go TestFmtUpgrade_ScriptSetDecidesStubThenRealHeaderTogether, TestCheck_ScriptSetWarnsOnStubThenReal"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "fmt --upgrade over a stub-then-real set still split the pair when the stub's file carried a written `mdl 0;`: the real file took `mdl 1;`. With the real file already `mdl 1;`, fmt printed 'no language header added' about it while the pair stayed split.", "cause": "canTakeHeader returned true for ANY written header (langver.ScanWrittenHeader's bool), reading a pinned `mdl 0;` as 'already has the header'; and the group decline was applied to a file that already carries the header, which fmt never removes.", "fix": "canTakeHeader: a written header can take it only when it is langver.Latest. decideSetHeaders: a file already under the header is not declined; fmt says the pair stays under different headers and to drop the stub or take the header off.", "insight": "ScanWrittenHeader's bool means 'a header is written', not 'the header is mdl 1'; a written mdl 0 pin is the strongest 'cannot take it' there is. Test the group decision with every header state of each file, not only headerless ones.", "issue": "ako/mxcli#905", "file": "cmd/mxcli/cmd_fmt.go", "test": "cmd/mxcli/script_set_test.go TestFmtUpgrade_ScriptSetPinnedMdl0StubHoldsTheRealFileBack, TestFmtUpgrade_ScriptSetAlreadySplitPairIsReportedAsSplit"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli version` fails with `unknown command \"version\" for \"mxcli\"` while `mxcli --version` works", "cause": "cobra's `Version:` field registers only the --version flag; no subcommand existed, though shouldSuppressWarning already listed a `version` argument", "fix": "Add a `version` subcommand (cmd/mxcli/cmd_version.go) printing exactly cobra's --version text (name, version, build time)", "insight": "Code that special-cases an argument (warning suppression) is evidence of an intended command; a test pinning the subcommand output to the flag output keeps the two from drifting", "issue": "ako/mxcli#534", "file": "cmd/mxcli/cmd_version.go", "test": "cmd/mxcli/cmd_version_test.go"} +{"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli check`/`exec`/`fmt`/`diff` fail on a script saved by Windows PowerShell 5.1: a UTF-8 BOM gives `line 1:0 token recognition error at: '\\ufeff'` (an invisible character), UTF-16LE gives a token error on almost every character; the same through stdin and in .test.mdl files", "cause": "Every script reader passed the raw file bytes to the lexer, which reads UTF-8 without a BOM; there was no shared reader (fmt, diff, the multi-file check pass, the test runner and EXECUTE SCRIPT each called os.ReadFile on their own)", "fix": "New mdl/srctext.Decode (strip a leading UTF-8 BOM, decode UTF-16LE/BE by BOM); readMDLSource calls it and fmt, diff and parseScriptSet now read through readMDLSource; testrunner.ParseTestFile and EXECUTE SCRIPT call it directly", "insight": "A BOM also hides a `mdl 1;` header from langver.ScanWrittenHeader, so stripping it in the parser alone would have left the language version wrong: decode where the bytes are read, before anything inspects the text. Enumerate the readers (grep os.ReadFile / io.ReadAll(os.Stdin)), not just the one the report names", "issue": "mendixlabs/mxcli#1253", "file": "mdl/srctext/srctext.go; cmd/mxcli/mdlsource.go", "test": "mdl/srctext/srctext_test.go; cmd/mxcli/mdlsource_encoding_test.go"} diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 7f892f8fe..4e004c216 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -476,7 +476,7 @@ func parseScriptSet(files []string) []setScript { var out []setScript for _, f := range files { sc := setScript{Path: f} - if data, err := os.ReadFile(f); err == nil && !testrunner.IsTestFile(f) { + if data, err := readMDLSource(f); err == nil && !testrunner.IsTestFile(f) { sc.Source = string(data) if prog, errs := visitor.Build(sc.Source); len(errs) == 0 { sc.Prog = prog diff --git a/cmd/mxcli/cmd_diff.go b/cmd/mxcli/cmd_diff.go index 6a89c83d7..ea8e83678 100644 --- a/cmd/mxcli/cmd_diff.go +++ b/cmd/mxcli/cmd_diff.go @@ -52,7 +52,7 @@ Examples: } // Read the script file - content, err := os.ReadFile(filePath) + content, err := readMDLSource(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) os.Exit(1) diff --git a/cmd/mxcli/cmd_fmt.go b/cmd/mxcli/cmd_fmt.go index 540c73644..62c593694 100644 --- a/cmd/mxcli/cmd_fmt.go +++ b/cmd/mxcli/cmd_fmt.go @@ -148,9 +148,9 @@ func fmtFile(cmd *cobra.Command, args []string, declineHeader string) error { var data []byte var err error if fromStdin { - data, err = io.ReadAll(os.Stdin) + data, err = readMDLSource(stdinPath) } else { - data, err = os.ReadFile(filePath) + data, err = readMDLSource(filePath) } if err != nil { return fmt.Errorf("failed to read input: %w", err) diff --git a/cmd/mxcli/mdlsource.go b/cmd/mxcli/mdlsource.go index e2fb5c854..2511e0ed9 100644 --- a/cmd/mxcli/mdlsource.go +++ b/cmd/mxcli/mdlsource.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "os" + + "github.com/mendixlabs/mxcli/mdl/srctext" ) // stdinPath is the conventional spelling for "read from standard input". @@ -18,15 +20,27 @@ const stdinPath = "-" // `-` is how every other Unix tool spells it — without this the dash was taken // literally and the command failed with "open -: no such file or directory", // forcing a temp file. (mxcli-todo findings #5) +// +// The bytes are decoded by srctext.Decode: a leading UTF-8 byte-order mark is +// dropped and a UTF-16 file is decoded, so a script saved by Windows +// PowerShell 5.1 or Notepad reads the same as one saved without a BOM +// (mendixlabs/mxcli#1253). func readMDLSource(path string) ([]byte, error) { + var content []byte + var err error if path == stdinPath { - content, err := io.ReadAll(os.Stdin) + content, err = io.ReadAll(os.Stdin) if err != nil { return nil, fmt.Errorf("reading MDL from stdin: %w", err) } - return content, nil + } else if content, err = os.ReadFile(path); err != nil { + return nil, err + } + decoded, err := srctext.Decode(content) + if err != nil { + return nil, fmt.Errorf("%s: %w", mdlSourceLabel(path), err) } - return os.ReadFile(path) + return decoded, nil } // mdlSourceLabel names the source in messages: a real path, or "". diff --git a/cmd/mxcli/mdlsource_encoding_test.go b/cmd/mxcli/mdlsource_encoding_test.go new file mode 100644 index 000000000..03836c29e --- /dev/null +++ b/cmd/mxcli/mdlsource_encoding_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf16" + + "github.com/mendixlabs/mxcli/cmd/mxcli/testrunner" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +func encodeUTF16LE(s string) []byte { + out := []byte{0xFF, 0xFE} + for _, u := range utf16.Encode([]rune(s)) { + out = append(out, byte(u), byte(u>>8)) + } + return out +} + +// mendixlabs/mxcli#1253: a script written by Windows PowerShell 5.1 — a UTF-8 +// BOM from `Set-Content -Encoding UTF8`, UTF-16LE from `>` — failed check and +// exec with "token recognition error at: 'U+FEFF'". Every script reader the +// CLI has must hand the parser the decoded text. The plain-UTF-8 file is the +// control: it must parse under the same readers, or a pass proves nothing. +func TestScriptReadersDecodeBOMAndUTF16(t *testing.T) { + const script = "mdl 1;\r\ncreate module BomTest;\r\n" + dir := t.TempDir() + files := map[string][]byte{ + "plain.mdl": []byte(script), + "bom.mdl": append([]byte{0xEF, 0xBB, 0xBF}, script...), + "utf16.mdl": encodeUTF16LE(script), + } + for name, body := range files { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, body, 0o644); err != nil { + t.Fatal(err) + } + + // check / exec / fmt / diff + src, err := readMDLSource(p) + if err != nil { + t.Fatalf("%s: readMDLSource: %v", name, err) + } + if _, errs := visitor.Build(string(src)); len(errs) > 0 { + t.Errorf("%s: readMDLSource text does not parse: %v", name, errs) + } + if !strings.HasPrefix(string(src), "mdl 1;") { + t.Errorf("%s: the header is not the first thing in the text: %q", name, src) + } + + // the multi-file check's set-level pass + set := parseScriptSet([]string{p}) + if len(set) != 1 || set[0].Prog == nil { + t.Errorf("%s: parseScriptSet did not parse the file", name) + } + } +} + +// The test runner reads .test.mdl files on its own path. +func TestTestRunnerDecodesBOM(t *testing.T) { + const body = "/**\n * @test bom\n */\nbegin\n return true;\nend;\n" + dir := t.TempDir() + plain := filepath.Join(dir, "plain.test.mdl") + bom := filepath.Join(dir, "bom.test.mdl") + _ = os.WriteFile(plain, []byte(body), 0o644) + _ = os.WriteFile(bom, append([]byte{0xEF, 0xBB, 0xBF}, body...), 0o644) + want, err := testrunner.ParseTestFile(plain) + if err != nil { + t.Fatal(err) + } + got, err := testrunner.ParseTestFile(bom) + if err != nil { + t.Fatal(err) + } + if len(got.Tests) != len(want.Tests) || len(want.Tests) == 0 { + t.Fatalf("bom file: %d tests, plain control: %d", len(got.Tests), len(want.Tests)) + } + if got.Tests[0].MDL != want.Tests[0].MDL { + t.Errorf("bom file test body %q, plain %q", got.Tests[0].MDL, want.Tests[0].MDL) + } +} diff --git a/cmd/mxcli/testrunner/parser.go b/cmd/mxcli/testrunner/parser.go index c431bb10b..adcf2297e 100644 --- a/cmd/mxcli/testrunner/parser.go +++ b/cmd/mxcli/testrunner/parser.go @@ -11,6 +11,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/mendixlabs/mxcli/mdl/srctext" ) // TestCase represents a single test extracted from a test file. @@ -97,7 +99,12 @@ type FileError struct { // ParseTestFile parses a test file (.test.mdl or .test.md) and extracts test cases. func ParseTestFile(path string) (*TestSuite, error) { - content, err := os.ReadFile(path) + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading test file: %w", err) + } + // A BOM or UTF-16 is decoded as for every other script (mendixlabs/mxcli#1253). + content, err := srctext.Decode(raw) if err != nil { return nil, fmt.Errorf("reading test file: %w", err) } diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index c9f7a5f1e..01168c2d9 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -12,6 +12,7 @@ import ( "github.com/mendixlabs/mxcli/cmd/mxcli/syntax" "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/srctext" "github.com/mendixlabs/mxcli/mdl/visitor" ) @@ -402,7 +403,13 @@ func execExecuteScript(ctx *ExecContext, s *ast.ExecuteScriptStmt) error { } // Read the script file - content, readErr := os.ReadFile(scriptPath) + raw, readErr := os.ReadFile(scriptPath) + if readErr != nil { + return mdlerrors.NewBackend("read script file '"+s.Path+"'", readErr) + } + // A BOM or UTF-16 is decoded as for a script given to check/exec + // (mendixlabs/mxcli#1253). + content, readErr := srctext.Decode(raw) if readErr != nil { return mdlerrors.NewBackend("read script file '"+s.Path+"'", readErr) } diff --git a/mdl/srctext/srctext.go b/mdl/srctext/srctext.go new file mode 100644 index 000000000..7e66c3637 --- /dev/null +++ b/mdl/srctext/srctext.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package srctext turns the bytes of an MDL script file into the UTF-8 text the +// lexer reads. +// +// The lexer reads UTF-8 without a byte-order mark. Windows PowerShell 5.1 — +// the default shell on Windows 10/11 — writes neither by default: +// `Set-Content -Encoding UTF8` writes a UTF-8 BOM and `'…' > file.mdl` writes +// UTF-16LE. Both failed with a token-recognition error at an invisible +// character (mendixlabs/mxcli#1253). A BOM carries no content, so stripping it +// and decoding UTF-16 changes no meaning. Every reader of a script file — +// check, exec, fmt, diff, the test runner, `execute script` — goes through +// Decode, so they cannot disagree about what a file says. +package srctext + +import ( + "bytes" + "fmt" + "unicode/utf16" + "unicode/utf8" +) + +var ( + bomUTF8 = []byte{0xEF, 0xBB, 0xBF} + bomUTF16LE = []byte{0xFF, 0xFE} + bomUTF16BE = []byte{0xFE, 0xFF} +) + +// Decode returns b as UTF-8 text without a leading byte-order mark. A UTF-8 BOM +// is stripped; a UTF-16 (LE or BE) BOM selects UTF-16 decoding. Input without +// a BOM is returned unchanged. +func Decode(b []byte) ([]byte, error) { + switch { + case bytes.HasPrefix(b, bomUTF8): + return b[len(bomUTF8):], nil + case bytes.HasPrefix(b, bomUTF16LE): + return decodeUTF16(b[2:], false) + case bytes.HasPrefix(b, bomUTF16BE): + return decodeUTF16(b[2:], true) + } + return b, nil +} + +// DecodeString is Decode for a reader that wants a string. +func DecodeString(b []byte) (string, error) { + out, err := Decode(b) + return string(out), err +} + +func decodeUTF16(b []byte, bigEndian bool) ([]byte, error) { + if len(b)%2 != 0 { + return nil, fmt.Errorf("file starts with a UTF-16 byte-order mark but has an odd number of bytes; save it as UTF-8") + } + units := make([]uint16, len(b)/2) + for i := range units { + if bigEndian { + units[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1]) + } else { + units[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i]) + } + } + runes := utf16.Decode(units) + out := make([]byte, 0, len(runes)) + for _, r := range runes { + out = utf8.AppendRune(out, r) + } + return out, nil +} diff --git a/mdl/srctext/srctext_test.go b/mdl/srctext/srctext_test.go new file mode 100644 index 000000000..18d550f63 --- /dev/null +++ b/mdl/srctext/srctext_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package srctext + +import ( + "testing" + "unicode/utf16" +) + +func utf16Bytes(s string, bigEndian bool) []byte { + var out []byte + if bigEndian { + out = []byte{0xFE, 0xFF} + } else { + out = []byte{0xFF, 0xFE} + } + for _, u := range utf16.Encode([]rune(s)) { + if bigEndian { + out = append(out, byte(u>>8), byte(u)) + } else { + out = append(out, byte(u), byte(u>>8)) + } + } + return out +} + +// TestDecode is mendixlabs/mxcli#1253: Windows PowerShell 5.1 writes a UTF-8 +// BOM (Set-Content -Encoding UTF8) or UTF-16LE ('>' redirection), and the +// lexer rejected both with an invisible token-recognition error. +func TestDecode(t *testing.T) { + const script = "create module BomTest;\r\n-- café \U0001F600\r\n" + cases := []struct { + name string + in []byte + }{ + {"plain UTF-8 (control)", []byte(script)}, + {"UTF-8 BOM", append([]byte{0xEF, 0xBB, 0xBF}, script...)}, + {"UTF-16LE BOM", utf16Bytes(script, false)}, + {"UTF-16BE BOM", utf16Bytes(script, true)}, + } + for _, c := range cases { + got, err := Decode(c.in) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + if string(got) != script { + t.Errorf("%s: got %q, want %q", c.name, got, script) + } + } +} + +func TestDecodeRejectsTruncatedUTF16(t *testing.T) { + in := utf16Bytes("create", false) + if _, err := Decode(in[:len(in)-1]); err == nil { + t.Error("an odd-length UTF-16 file decoded without error") + } +} + +// A BOM character in the middle of a file is content, not an encoding mark; +// only a leading one is stripped. +func TestDecodeKeepsInteriorBOM(t *testing.T) { + in := []byte("-- a" + "\xef\xbb\xbf" + "b\n") + got, err := Decode(in) + if err != nil || string(got) != string(in) { + t.Errorf("got %q, %v; want the input unchanged", got, err) + } +} From e494590ba3bc74e1e2deed1c3f2236f0a59511bc Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:00:44 +0000 Subject: [PATCH 06/23] fix(alter-page): a data view footer is addressed as .footer (ako/mxcli#528, mendixlabs/mxcli#293) A data view's footer is a region: its widgets live in the data view's FooterWidgets and the footer has no stored Name, so the name a script wrote and describe's invented footer1 both named nothing ALTER could find. - ALTER PAGE: .footer resolves as a region; INSERT INTO appends, REPLACE swaps the whole content (a describe-style footer { } block is unwrapped), DROP empties it. A not-found names the footer addresses the page has. - REPLACE may reuse the names of the widgets it removes (the target's descendants), which was refused as a duplicate. - A name on a data view footer is MDL-DEPR005 (unstored widget name), like a layout grid row's; describe prints footer { } unnamed. The AST is identical, so writes do not change. Docs/examples/skills rewritten. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/alter-page/SKILL.md | 35 ++- .../mendix/create-page/reference/examples.md | 4 +- .../mendix/create-page/reference/widgets.md | 12 +- .../mendix/master-detail-pages/SKILL.md | 4 +- .../skills/mendix/migrate-k2-nintex/SKILL.md | 2 +- .../mendix/migrate-oracle-forms/SKILL.md | 2 +- .claude/skills/mendix/overview-pages/SKILL.md | 6 +- README.md | 2 +- cmd/mxcli/syntax/features_page.go | 6 +- docs-site/src/appendixes/quick-reference.md | 2 +- docs-site/src/examples/alter-page.md | 4 +- docs-site/src/examples/crm-module.md | 2 +- docs-site/src/examples/master-detail.md | 2 +- docs-site/src/examples/validation.md | 2 +- docs-site/src/language/page-patterns.md | 10 +- docs-site/src/language/page-structure.md | 2 +- docs-site/src/language/pages.md | 2 +- docs-site/src/language/widget-types.md | 9 +- docs-site/src/reference/page/create-page.md | 4 +- .../src/reference/page/create-snippet.md | 2 +- .../src/reference/query/describe-page.md | 2 +- docs-site/src/tutorial/create-page.md | 2 +- docs-site/src/tutorial/describe-search.md | 2 +- docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- .../01-language-reference.md | 2 +- .../1140-flow-arg-page-parameter.mdl | 2 +- .../bug-tests/762-813-dataview-properties.mdl | 2 +- .../pages-541-roundtrip-property-drift.mdl | 2 +- .../pages-550-describe-input-properties.mdl | 2 +- .../doctype-tests/03-page-examples.mdl | 30 +-- .../doctype-tests/12-styling-examples.mdl | 10 +- .../doctype-tests/15-fragment-examples.mdl | 6 +- .../17-custom-widget-examples.mdl | 4 +- .../doctype-tests/29-datagrid-examples.mdl | 2 +- mdl-examples/use-cases/02-agentic-search.mdl | 2 +- mdl-examples/widgetdemo/03-showcase-page.mdl | 2 +- mdl/backend/pagemutator/alter_target.go | 3 + mdl/backend/pagemutator/footer_region_test.go | 157 +++++++++++++ mdl/backend/pagemutator/footerregion.go | 206 ++++++++++++++++++ mdl/backend/pagemutator/mutator.go | 11 +- mdl/backend/pagemutator/probe.go | 3 + mdl/deprecation/deprecation.go | 7 +- mdl/executor/cmd_alter_page.go | 63 +++++- mdl/executor/cmd_pages_describe_output.go | 6 +- mdl/executor/cmd_pages_describe_parse.go | 6 +- .../page_silent_changes_integration_test.go | 114 ++++++++++ mdl/grammar/MDLParser.g4 | 2 +- mdl/upgrade/unstored_widget_name_test.go | 23 ++ mdl/visitor/visitor_unstored_widget_name.go | 9 +- .../visitor_unstored_widget_name_test.go | 33 +++ 50 files changed, 721 insertions(+), 110 deletions(-) create mode 100644 mdl/backend/pagemutator/footer_region_test.go create mode 100644 mdl/backend/pagemutator/footerregion.go create mode 100644 mdl/executor/page_silent_changes_integration_test.go diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index 7c6a9ce61..7c49f677b 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -314,15 +314,20 @@ Removes widgets and their entire subtree from the page. ```sql -- Replace a single widget with new content -replace footer1 with { - footer newFooter { +replace btnSave with { + actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) +} + +-- A data view's footer has no name: address it by its data view +replace dvMain.footer with { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } } ``` -Replaces the target widget with one or more new widgets. The new widgets use the same syntax as `create page`. +Replaces the target widget with one or more new widgets. The new widgets use the same syntax as `create page`, and may reuse the names of the widgets the replace removes. `insert into dvMain.footer { … }` appends to a footer and `drop dvMain.footer` empties it. ### DataGrid Column Operations @@ -428,8 +433,8 @@ alter page MyModule.Customer_Edit { ```sql mdl 1; alter page MyModule.Customer_Edit { - replace footer1 with { - footer newFooter { + replace dvMain.footer with { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnDelete (caption: 'Delete', action: delete, buttonstyle: danger) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) @@ -565,8 +570,8 @@ adds. Both still fail at exec if they are genuinely wrong. ## Limitations — prefer binding at page creation (ledger finding #45) -`ALTER PAGE` is best for *content* edits (add/remove/retitle widgets). Three things -it cannot do; when you hit them, define the referenced microflows **before** the +`ALTER PAGE` is best for *content* edits (add/remove/retitle widgets). When you hit +the limit below, define the referenced microflows **before** the page and bind the buttons at creation time instead of rewiring afterwards: 1. **`SET` cannot rewire a button's action.** `set` accepts a fixed property list @@ -574,17 +579,11 @@ page and bind the buttons at creation time instead of rewiring afterwards: `set Action = call microflow … on btnSave` is a parse error. Set the button's action when the button is created (or `REPLACE` the button subtree). -2. **`REPLACE` cannot reuse a widget name that lives inside the subtree being - replaced.** The replacement is *built* (registering its widget names) before the - old subtree is removed, so reusing e.g. `btnSave` collides with the still-present - old `btnSave` ("duplicate widget name 'btnSave'"). Give the replacement widgets - fresh names, or rebuild the whole page with `create or modify page`. - -3. **A footer is not addressable by its author-given name.** A `footer myName { … }` - is a *marker*: its children are hoisted into the data view's footer and the - footer itself is serialized as `footer1`, so `drop myName` (and even - `drop footer1`) report "not found". To change footer contents, edit the - children by their own names, or `create or modify page`. +**A data view's footer has no name.** Its widgets are stored in the data + view, and the footer itself is not, so a name written on it is never kept + (MDL-DEPR005) and `describe` prints `footer { … }`. Address it by its data + view: `replace dvMain.footer with { footer { … } }`, `insert into + dvMain.footer { … }`, `drop dvMain.footer`. **Recommended pattern**: put save/reset microflows in a file that runs *before* the page definition, and bind the popup/footer buttons to them at creation. The diff --git a/.claude/skills/mendix/create-page/reference/examples.md b/.claude/skills/mendix/create-page/reference/examples.md index 36786d7b2..11f5b3256 100644 --- a/.claude/skills/mendix/create-page/reference/examples.md +++ b/.claude/skills/mendix/create-page/reference/examples.md @@ -28,7 +28,7 @@ create or modify page CRM.CustomerEdit textbox txtPhone (label: 'Phone', attribute: Phone) checkbox cbActive (label: 'Active', attribute: IsActive) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -99,7 +99,7 @@ create page CRM.Customer_MasterDetail textbox txtEmail (label: 'Email', attribute: Email) textbox txtPhone (label: 'Phone', attribute: Phone) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index be36334b4..cbf40567d 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -447,7 +447,7 @@ dataview dvName (datasource: $VariableName) { textbox txtName (label: 'Name', attribute: Name) textarea txtDescription (label: 'Description', attribute: description) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -507,7 +507,7 @@ dataview dataView1 (datasource: $Customer) { checkbox cbActive (label: 'Active', attribute: IsActive) datepicker dpCreated (label: 'Created', attribute: CreateDate) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -531,7 +531,7 @@ the property is only needed when the two would disagree: ```sql dataview dv (datasource: $Customer, showFooter: true) -- empty footer, shown dataview dv (datasource: $Customer, showFooter: false) { -- widgets declared, hidden - footer f { dynamictext t (content: 'hidden') } + footer { dynamictext t (content: 'hidden') } } ``` @@ -979,10 +979,12 @@ wrapper per value (`ACT_Set1`…`ACT_Set9`), each calling the shared implementat ### FOOTER Widget -Container for form action buttons: +Container for form action buttons. Inside a data view it is the data view's +footer region, which stores no name — write `footer { … }` and address it as +`dvName.footer` in `alter page`: ```sql -footer footerName { +footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/.claude/skills/mendix/master-detail-pages/SKILL.md b/.claude/skills/mendix/master-detail-pages/SKILL.md index 1c2605ed4..e0a4ae2ec 100644 --- a/.claude/skills/mendix/master-detail-pages/SKILL.md +++ b/.claude/skills/mendix/master-detail-pages/SKILL.md @@ -39,7 +39,7 @@ create page Module.Entity_MasterDetail dataview entityDetail (datasource: selection entityList) { textbox txtName (label: 'Name', attribute: Name) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) } } @@ -125,7 +125,7 @@ create page CRM.Customer_MasterDetail textbox txtEmail (label: 'Email', attribute: Email) textbox txtPhone (label: 'Phone', attribute: Phone) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/.claude/skills/mendix/migrate-k2-nintex/SKILL.md b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md index fd18ba3a5..ca444ba1b 100644 --- a/.claude/skills/mendix/migrate-k2-nintex/SKILL.md +++ b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md @@ -198,7 +198,7 @@ create page CRM.Customer_Edit checkbox chkActive (label: 'Active', attribute: IsActive) -- Button bar (SmartForms action buttons) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/.claude/skills/mendix/migrate-oracle-forms/SKILL.md b/.claude/skills/mendix/migrate-oracle-forms/SKILL.md index c8e3a2103..c31c12eac 100644 --- a/.claude/skills/mendix/migrate-oracle-forms/SKILL.md +++ b/.claude/skills/mendix/migrate-oracle-forms/SKILL.md @@ -127,7 +127,7 @@ create page MyModule.Customer_Edit ( textbox txtCode (Label: 'Customer Code', Attribute: CustomerCode) textbox txtName (Label: 'Name', Attribute: CustomerName) textbox txtEmail (Label: 'Email', Attribute: Email) - footer footer1 { + footer { -- Reference to the microflow created in Phase 2 actionbutton btnSave (Caption: 'Save', Action: call microflow MyModule.ACT_Customer_Save(Customer = $Customer), ButtonStyle: Primary) actionbutton btnCancel (Caption: 'Cancel', Action: close page) diff --git a/.claude/skills/mendix/overview-pages/SKILL.md b/.claude/skills/mendix/overview-pages/SKILL.md index 91953f2f0..f6cad2908 100644 --- a/.claude/skills/mendix/overview-pages/SKILL.md +++ b/.claude/skills/mendix/overview-pages/SKILL.md @@ -253,7 +253,7 @@ create page Module.Entity_NewEdit folder 'OverviewPages' datepicker dpDueDate (label: 'Due Date', attribute: DueDate) combobox cbStatus (label: 'Status', attribute: status) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -370,7 +370,7 @@ create page MdlTemplates.Store_NewEdit folder 'OverviewPages' textbox txtName (label: 'Name', attribute: Name) textbox txtLocation (label: 'Location', attribute: Location) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -428,7 +428,7 @@ create page MdlTemplates.Car_NewEdit folder 'OverviewPages' datepicker dpDate (label: 'Purchase date', attribute: PurchaseDate) radiobuttons rbType (label: 'Car type', attribute: CarType) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/README.md b/README.md index 1f72824ac..80eb953c3 100644 --- a/README.md +++ b/README.md @@ -442,7 +442,7 @@ create page MyModule.Product_Edit textbox txtPrice (label: 'Price', attribute: Price) checkbox cbActive (label: 'Active', attribute: IsActive) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) } diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index be56cade3..46f1dee75 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -13,7 +13,7 @@ func init() { "widget", "layout", "screen", }, Syntax: "CREATE PAGE Module.Name [FOLDER 'FolderPath']\n (\n Title: 'Page Title',\n Layout: Module.LayoutName\n [, Params: ( $Param: Module.Entity )]\n [, Url: 'page-url']\n [, Variables: ( $var: Boolean = 'true' )]\n [, PopupWidth: 800, PopupHeight: 480, PopupResizable: true]\n [, PopupCloseAction: cancelButton1]\n [, Class: 'css-class', Style: 'css: rule']\n )\n {\n -- widgets\n }", - Example: "CREATE PAGE MyModule.EditCustomer\n (\n Params: ( $Customer: MyModule.Customer ),\n Title: 'Edit Customer',\n Layout: Atlas_Core.PopupLayout,\n Class: 'container-fluid'\n )\n {\n DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n }\n };", + Example: "CREATE PAGE MyModule.EditCustomer\n (\n Params: ( $Customer: MyModule.Customer ),\n Title: 'Edit Customer',\n Layout: Atlas_Core.PopupLayout,\n Class: 'container-fluid'\n )\n {\n DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n FOOTER {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n }\n };", SeeAlso: []string{"page.create", "page.widgets", "page.alter", "snippet"}, }) @@ -129,7 +129,7 @@ LIST IMPACT OF htmlelement; "legacydatagrid", "visible", "editable", "conditional visibility", }, Syntax: "-- Containers\nLAYOUTGRID name { ROW r { COLUMN c (DesktopWidth: 6) { ... } } }\nCONTAINER name (Class: 'cls') { ... }\nCONTAINER name (OnClick: CALL MICROFLOW Module.MF) { ... } -- clickable container\nCUSTOMCONTAINER name (Class: 'cls') { ... }\nGROUPBOX name (Caption: 'C') { ... }\nTABCONTAINER name { TABPAGE tp (Caption: 'One') { ... } TABPAGE tp2 (Caption: 'Two') { ... } }\n\n" + - "-- Data widgets\nDATAVIEW name (DataSource: $Param) { ... FOOTER f { ... } }\nDATAGRID name (DataSource: DATABASE Module.Entity) { COLUMN c (Attribute: A) }\nGALLERY name (DataSource: DATABASE Module.Entity, DesktopColumns: 3) { ... }\nLISTVIEW name (DataSource: DATABASE Module.Entity) { ... }\nLISTVIEW name (...) { ... TEMPLATE FOR Module.Specialization { ... } }\n\n" + + "-- Data widgets\nDATAVIEW name (DataSource: $Param) { ... FOOTER { ... } }\nDATAGRID name (DataSource: DATABASE Module.Entity) { COLUMN c (Attribute: A) }\nGALLERY name (DataSource: DATABASE Module.Entity, DesktopColumns: 3) { ... }\nLISTVIEW name (DataSource: DATABASE Module.Entity) { ... }\nLISTVIEW name (...) { ... TEMPLATE FOR Module.Specialization { ... } }\n\n" + "-- Data grid 2 column filters go INSIDE the column's own braces\nDATAGRID dg (...) { COLUMN c (Attribute: A) { TEXTFILTER tf (Attribute: A) } }\nTEXTFILTER | NUMBERFILTER | DATEFILTER | DROPDOWNFILTER | DROPDOWNSORT\n" + "-- Match the filter to the column's type, or MxBuild refuses it: String ->\n" + "-- TEXTFILTER, Integer/Long/Decimal -> NUMBERFILTER, Date and time -> DATEFILTER,\n" + @@ -199,7 +199,7 @@ LIST IMPACT OF htmlelement; "-- Use DYNAMICTEXT with a literal Content.)\n" + "-- REFERENCESELECTOR (unsupported widget type)\n" + "-- LEGACYDATAGRID (use DATAGRID for the pluggable equivalent on Mendix 11+)", - Example: "DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n COMBOBOX cbStatus (Label: 'Status', Attribute: Status)\n FOOTER footer1 {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n}", + Example: "DATAVIEW dvCustomer (DataSource: $Customer) {\n TEXTBOX txtName (Label: 'Name', Attribute: Name)\n COMBOBOX cbStatus (Label: 'Status', Attribute: Status)\n FOOTER {\n ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)\n ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)\n }\n}", SeeAlso: []string{"page.create", "page.datasource"}, }) diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 7c4ccd93c..634fef26e 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -403,7 +403,7 @@ CREATE PAGE MyModule.Customer_Edit TEXTBOX txtEmail (Label: 'Email', Attribute: Email) COMBOBOX cbStatus (Label: 'Status', Attribute: Status) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/examples/alter-page.md b/docs-site/src/examples/alter-page.md index ee9784e8c..66d9d1937 100644 --- a/docs-site/src/examples/alter-page.md +++ b/docs-site/src/examples/alter-page.md @@ -44,8 +44,8 @@ ALTER PAGE CRM.Customer_Edit { ```sql ALTER PAGE CRM.Customer_Edit { - REPLACE footer1 WITH { - FOOTER newFooter { + REPLACE dvMain.footer WITH { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Success) ACTIONBUTTON btnDelete (Caption: 'Delete', Action: DELETE, ButtonStyle: Danger) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) diff --git a/docs-site/src/examples/crm-module.md b/docs-site/src/examples/crm-module.md index 4cb5ec40c..311780ead 100644 --- a/docs-site/src/examples/crm-module.md +++ b/docs-site/src/examples/crm-module.md @@ -142,7 +142,7 @@ CREATE PAGE CRM.Customer_NewEdit ( TEXTBOX txtEmail (Label: 'Email', Attribute: Email) TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone) TEXTAREA txtNotes (Label: 'Notes', Attribute: Notes) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave ( Caption: 'Save', Action: CALL MICROFLOW CRM.ACT_Customer_Save, diff --git a/docs-site/src/examples/master-detail.md b/docs-site/src/examples/master-detail.md index 8c6f342f4..458c2db5d 100644 --- a/docs-site/src/examples/master-detail.md +++ b/docs-site/src/examples/master-detail.md @@ -35,7 +35,7 @@ CREATE PAGE CRM.Customer_MasterDetail ( TEXTBOX txtEmail (Label: 'Email', Attribute: Email) TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone) TEXTAREA txtNotes (Label: 'Notes', Attribute: Notes) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave ( Caption: 'Save', Action: SAVE CHANGES, diff --git a/docs-site/src/examples/validation.md b/docs-site/src/examples/validation.md index f34bc58b9..9cd987e62 100644 --- a/docs-site/src/examples/validation.md +++ b/docs-site/src/examples/validation.md @@ -84,7 +84,7 @@ CREATE PAGE Sales.Order_Edit ( DATEPICKER dpDelivery (Label: 'Delivery Date', Attribute: DeliveryDate) TEXTBOX txtQuantity (Label: 'Quantity', Attribute: Quantity) TEXTBOX txtDiscount (Label: 'Discount %', Attribute: DiscountPercent) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave ( Caption: 'Save', Action: CALL MICROFLOW Sales.ACT_Order_Save, diff --git a/docs-site/src/language/page-patterns.md b/docs-site/src/language/page-patterns.md index db6a0eab6..2e1b00002 100644 --- a/docs-site/src/language/page-patterns.md +++ b/docs-site/src/language/page-patterns.md @@ -63,7 +63,7 @@ CREATE PAGE MyModule.Customer_Edit FOLDER 'Customers' TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone) COMBOBOX cbStatus (Label: 'Status', Attribute: Status) CHECKBOX cbActive (Label: 'Active', Attribute: IsActive) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } @@ -101,7 +101,7 @@ CREATE PAGE MyModule.Customer_Detail FOLDER 'Customers' } } } - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnEdit ( Caption: 'Edit', Action: SHOW PAGE MyModule.Customer_Edit, @@ -148,7 +148,7 @@ CREATE PAGE MyModule.Product_MasterDetail FOLDER 'Products' TEXTBOX txtDescription (Label: 'Description', Attribute: Description) TEXTBOX txtPrice (Label: 'Price', Attribute: Price) COMBOBOX cbCategory (Label: 'Category', Attribute: Category) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) } } @@ -191,7 +191,7 @@ CREATE PAGE MyModule.Order_MasterDetail FOLDER 'Orders' COLUMN (Attribute: LineTotal, Caption: 'Total', Alignment: right) } - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnEdit ( Caption: 'Edit Order', Action: SHOW PAGE MyModule.Order_Edit, @@ -252,7 +252,7 @@ CREATE PAGE HR.Employee_Edit FOLDER 'Employees' TEXTBOX txtDept (Label: 'Department', Attribute: Department) DATEPICKER dpHireDate (Label: 'Hire Date', Attribute: HireDate) TEXTBOX txtEmail (Label: 'Email', Attribute: Email) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/language/page-structure.md b/docs-site/src/language/page-structure.md index dd6a367e9..24a4364d7 100644 --- a/docs-site/src/language/page-structure.md +++ b/docs-site/src/language/page-structure.md @@ -48,7 +48,7 @@ CREATE PAGE MyModule.Customer_Edit { DATAVIEW dvCustomer (DataSource: $Customer) { TEXTBOX txtName (Label: 'Name', Attribute: Name) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/language/pages.md b/docs-site/src/language/pages.md index b85405f7c..82ca06ba7 100644 --- a/docs-site/src/language/pages.md +++ b/docs-site/src/language/pages.md @@ -58,7 +58,7 @@ CREATE PAGE MyModule.Customer_Edit DATAVIEW dvCustomer (DataSource: $Customer) { TEXTBOX txtName (Label: 'Name', Attribute: Name) TEXTBOX txtEmail (Label: 'Email', Attribute: Email) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 86a4cf83b..9a641733e 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -87,7 +87,7 @@ DATAVIEW dvCustomer (DataSource: $Customer) { TEXTBOX txtName (Label: 'Name', Attribute: Name) TEXTBOX txtEmail (Label: 'Email', Attribute: Email) COMBOBOX cbStatus (Label: 'Status', Attribute: Status) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } @@ -433,16 +433,17 @@ DATAVIEW dvOrder (DataSource: $Order) { DYNAMICTEXT txtOrderTitle (Content: 'Order #{1}', Attribute: OrderId) } TEXTBOX txtStatus (Label: 'Status', Attribute: Status) - FOOTER ftr1 { ... } + FOOTER { ... } } ``` ### FOOTER -Footer section of a DataView. Typically contains save/cancel buttons: +Footer section of a DataView. Typically contains save/cancel buttons. It +stores no name of its own; `ALTER PAGE` addresses it as `.footer`: ```sql -FOOTER footer1 { +FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/reference/page/create-page.md b/docs-site/src/reference/page/create-page.md index 829fde60b..09260ab88 100644 --- a/docs-site/src/reference/page/create-page.md +++ b/docs-site/src/reference/page/create-page.md @@ -258,7 +258,7 @@ CREATE PAGE MyModule.Customer_Edit TEXTBOX txtEmail (Label: 'Email', Attribute: Email) COMBOBOX cbStatus (Label: 'Status', Attribute: Status) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } @@ -378,7 +378,7 @@ CREATE PAGE MyModule.AdvancedForm CONTAINER cntAdvanced (Visible: '$showAdvanced') { TEXTAREA taNotes (Label: 'Notes', Attribute: Notes) } - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/reference/page/create-snippet.md b/docs-site/src/reference/page/create-snippet.md index 39b07e991..99623d447 100644 --- a/docs-site/src/reference/page/create-snippet.md +++ b/docs-site/src/reference/page/create-snippet.md @@ -102,7 +102,7 @@ CREATE PAGE MyModule.Customer_Edit { DATAVIEW dvCustomer (DataSource: $Customer) { SNIPPETCALL snpAddress (Snippet: MyModule.AddressFields) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) } } diff --git a/docs-site/src/reference/query/describe-page.md b/docs-site/src/reference/query/describe-page.md index c6c91421b..7f2fabd52 100644 --- a/docs-site/src/reference/query/describe-page.md +++ b/docs-site/src/reference/query/describe-page.md @@ -38,7 +38,7 @@ CREATE PAGE Sales.Customer_Edit DATAVIEW dvCustomer (DataSource: $Customer) { TEXTBOX txtName (Label: 'Name', Attribute: Name) TEXTBOX txtEmail (Label: 'Email', Attribute: Email) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/tutorial/create-page.md b/docs-site/src/tutorial/create-page.md index 8b9e69fdf..8250ab0a7 100644 --- a/docs-site/src/tutorial/create-page.md +++ b/docs-site/src/tutorial/create-page.md @@ -84,7 +84,7 @@ CREATE PAGE MyModule.Product_Edit TEXTBOX txtPrice (Label: 'Price', Attribute: Price) CHECKBOX cbActive (Label: 'Active', Attribute: IsActive) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs-site/src/tutorial/describe-search.md b/docs-site/src/tutorial/describe-search.md index 694667b7a..5128f3d9c 100644 --- a/docs-site/src/tutorial/describe-search.md +++ b/docs-site/src/tutorial/describe-search.md @@ -93,7 +93,7 @@ CREATE PAGE MyFirstModule.Customer_Edit TEXTBOX txtEmail (Label: 'Email', Attribute: Email) TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone) - FOOTER footer1 { + FOOTER { ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary) ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES) } diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 4ae3eb277..8f2c0a794 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1575,7 +1575,7 @@ create page MyModule.Customer_Edit textbox txtEmail (label: 'Email', attribute: Email) combobox cbStatus (label: 'Status', attribute: status) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/docs/05-mdl-specification/01-language-reference.md b/docs/05-mdl-specification/01-language-reference.md index f90249a43..9e4314281 100644 --- a/docs/05-mdl-specification/01-language-reference.md +++ b/docs/05-mdl-specification/01-language-reference.md @@ -796,7 +796,7 @@ create page MyModule.Customer_Edit dataview dvCustomer (datasource: $Customer) { textbox txtName (label: 'Name', attribute: Name) textbox txtEmail (label: 'Email', attribute: Email) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) } diff --git a/mdl-examples/bug-tests/1140-flow-arg-page-parameter.mdl b/mdl-examples/bug-tests/1140-flow-arg-page-parameter.mdl index 3edf47075..6b4bf129d 100644 --- a/mdl-examples/bug-tests/1140-flow-arg-page-parameter.mdl +++ b/mdl-examples/bug-tests/1140-flow-arg-page-parameter.mdl @@ -86,7 +86,7 @@ create or modify page CustomModule.PANEL_BufferDefinition_Edit ( ) { placeholder Main { dataview dvEdit (DataSource: $Dto, FormOrientation: Vertical) { - footer footer1 { + footer { actionbutton btnSave ( Caption: 'Save', Action: call nanoflow CustomModule.ACT_BufferDefinition_SaveEdit_NF( diff --git a/mdl-examples/bug-tests/762-813-dataview-properties.mdl b/mdl-examples/bug-tests/762-813-dataview-properties.mdl index bb8953638..67d45f4cb 100644 --- a/mdl-examples/bug-tests/762-813-dataview-properties.mdl +++ b/mdl-examples/bug-tests/762-813-dataview-properties.mdl @@ -67,7 +67,7 @@ create or modify page Issue762.DVTest ( -- wins over the `footer { … }` block, and hiding must not discard the widgets. dataview dvHidden (datasource: $Thing, showFooter: false) { textbox t5 (attribute: Name) - footer f1 { + footer { dynamictext ft1 (content: 'hidden footer') } } diff --git a/mdl-examples/bug-tests/pages-541-roundtrip-property-drift.mdl b/mdl-examples/bug-tests/pages-541-roundtrip-property-drift.mdl index 5ff4d2ed3..d33546fbf 100644 --- a/mdl-examples/bug-tests/pages-541-roundtrip-property-drift.mdl +++ b/mdl-examples/bug-tests/pages-541-roundtrip-property-drift.mdl @@ -89,7 +89,7 @@ create or modify page Issue541.RuleAction_NewEdit ( -- All four actions whose DisabledDuringExecution was missing, plus -- save_changes' SyncAutomatically (class 2). - footer footerButtons { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: Success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page) actionbutton btnClose (caption: 'Close', action: close page) diff --git a/mdl-examples/bug-tests/pages-550-describe-input-properties.mdl b/mdl-examples/bug-tests/pages-550-describe-input-properties.mdl index a7ef137bf..c0e6f9ab2 100644 --- a/mdl-examples/bug-tests/pages-550-describe-input-properties.mdl +++ b/mdl-examples/bug-tests/pages-550-describe-input-properties.mdl @@ -80,7 +80,7 @@ create or modify page Issue550.ChangePasswordForm ( ValidationMessage: 'The password cannot be empty.' ) - footer footerButtons { + footer { actionbutton okButton1 (caption: 'OK', action: call microflow Issue550.ACT_ChangePassword) actionbutton cancelButton1 (caption: 'Cancel', action: cancel changes close page) } diff --git a/mdl-examples/doctype-tests/03-page-examples.mdl b/mdl-examples/doctype-tests/03-page-examples.mdl index 55c7fc3a6..e4d69081f 100644 --- a/mdl-examples/doctype-tests/03-page-examples.mdl +++ b/mdl-examples/doctype-tests/03-page-examples.mdl @@ -280,7 +280,7 @@ create page PgTest.P006_DataForm folder 'Forms' radiobuttons radioButtons1 (label: 'Is active', attribute: IsActive) datepicker datePicker1 (label: 'Create date', attribute: CreateDate) - footer footer1 { + footer { actionbutton actionButton1 (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton actionButton2 (caption: 'Cancel', action: cancel changes, buttonstyle: default) } @@ -387,7 +387,7 @@ create page PgTest.P007_DataForm_2 folder 'Forms' textbox txtName (label: 'Name', attribute: Name) textbox txtEmail (label: 'Email', attribute: Email) - footer footer1 { + footer { actionbutton actionButton1 (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton actionButton2 (caption: 'Cancel', action: cancel changes, buttonstyle: default) } @@ -716,7 +716,7 @@ create page PgTest.P012_Product_Manage_Edit folder 'DataGrid' textbox txtCode (label: 'Product Code', attribute: Code) textarea txtDescription (label: 'Description', attribute: description) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } @@ -1009,7 +1009,7 @@ create or modify page PgTest.P014_Product_Edit folder 'DataView' ( } } } - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -1044,7 +1044,7 @@ create page PgTest.P015_Product_EditFull folder 'DataView' } } } - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes, buttonstyle: default) } @@ -1083,7 +1083,7 @@ create or modify page PgTest.P018_DataView_FormOrientation_Vertical folder 'Data dataview ProductForm (datasource: $Product, FormOrientation: Vertical) { textbox txtName (label: 'Product Name', attribute: Name) textbox txtCode (label: 'Product Code', attribute: Code) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -1103,7 +1103,7 @@ create or modify page PgTest.P018_DataView_FormOrientation_Horizontal folder 'Da dataview ProductForm (datasource: $Product, FormOrientation: Horizontal) { textbox txtName (label: 'Product Name', attribute: Name) textbox txtCode (label: 'Product Code', attribute: Code) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -1123,7 +1123,7 @@ create or modify page PgTest.P018_DataView_LabelWidth folder 'DataView' ( dataview ProductForm (datasource: $Product, LabelWidth: 5) { textbox txtName (label: 'Product Name', attribute: Name) textbox txtCode (label: 'Product Code', attribute: Code) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -1477,7 +1477,7 @@ create page PgTest.P020_Master_Detail folder 'MasterDetail' textbox txtAddress (label: 'Address', attribute: Address) textbox txtCity (label: 'City', attribute: City) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes, buttonstyle: default) } @@ -1820,7 +1820,7 @@ create page PgTest.P023c_Detail_MicroflowAction folder 'MicroflowActions' textbox txtPrice (label: 'Price', attribute: Price) textbox txtStock (label: 'Stock', attribute: Stock) - footer footer1 { + footer { -- Microflow action with page parameter passes the DataView's object actionbutton btnProcess ( caption: 'Process Product', @@ -1942,7 +1942,7 @@ create page PgTest.StyledPage folder 'Styling' dynamictext dtCity (content: 'City: {1}', contentparams: ({1} = City), class: 'text-muted') } - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary, class: 'btn-block') actionbutton btnCancel (caption: 'Cancel', action: cancel changes, class: 'btn-outline') } @@ -2299,7 +2299,7 @@ create page PgTest.P034_ComboBox_Enum folder 'ComboBox' -- ComboBox (pluggable widget) selecting from the Country enumeration combobox cmbCountry (label: 'Country (ComboBox)', attribute: Country) }}} - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } @@ -2332,7 +2332,7 @@ create page PgTest.P035_Order_Edit folder 'ComboBox' -- TEXTBOX txtStatus (Label: 'Status', Attribute: Status) textarea txtNotes (label: 'Notes', attribute: Notes) }}} - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } @@ -2422,7 +2422,7 @@ create page PgTest.P037_GroupBox_Example folder 'GroupBox' datepicker dpCreateDate (label: 'Created On', attribute: CreateDate) } - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } @@ -2460,7 +2460,7 @@ create page PgTest.P036_ComboBox_Association folder 'ComboBox' textbox txtTotal (label: 'Total Amount', attribute: TotalAmount) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } diff --git a/mdl-examples/doctype-tests/12-styling-examples.mdl b/mdl-examples/doctype-tests/12-styling-examples.mdl index 2873a5d39..16ca64a35 100644 --- a/mdl-examples/doctype-tests/12-styling-examples.mdl +++ b/mdl-examples/doctype-tests/12-styling-examples.mdl @@ -86,7 +86,7 @@ create page StyleTest.P001_CSS_Classes textbox txtName (label: 'Name', attribute: Name, class: 'form-control-lg') textbox txtEmail (label: 'Email', attribute: Email, class: 'text-muted') - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary, class: 'btn-lg') actionbutton btnCancel (caption: 'Cancel', action: cancel changes, class: 'btn-sm') } @@ -241,7 +241,7 @@ create page StyleTest.P003_Design_Properties datepicker dpDue (label: 'Due Date', attribute: DueDate) checkbox chkDone (label: 'Completed', attribute: IsCompleted) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary, designproperties: ('Full width': on)) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -342,7 +342,7 @@ create page StyleTest.P004_Combined_Styling } } - footer footer1 { + footer { actionbutton btnSave ( caption: 'Save Employee', action: save changes, @@ -543,7 +543,7 @@ create page StyleTest.P006_Roundtrip textbox txtEmail (label: 'Email', attribute: Email) checkbox chkActive (label: 'Active', attribute: IsActive) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } @@ -582,7 +582,7 @@ create or modify page StyleTest.P006_Roundtrip textbox txtEmail (label: 'Email', attribute: Email) checkbox chkActive (label: 'Active', attribute: IsActive) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/mdl-examples/doctype-tests/15-fragment-examples.mdl b/mdl-examples/doctype-tests/15-fragment-examples.mdl index 6d5111e5e..f15916039 100644 --- a/mdl-examples/doctype-tests/15-fragment-examples.mdl +++ b/mdl-examples/doctype-tests/15-fragment-examples.mdl @@ -236,7 +236,7 @@ create page FragTest.P005_Quick_View dataview dvCustomer (datasource: $Customer) { dynamictext txtHeader (content: 'Customer Details', rendermode: H3) use fragment CustomerFields - footer footer1 { + footer { actionbutton btnClose (caption: 'Close', action: close page) } } @@ -319,8 +319,8 @@ create snippet FragTest.CustomerCard -- Replace a widget subtree -- ALTER PAGE FragTest.P001_Customer_Edit { --- REPLACE footer1 WITH { --- FOOTER newFooter { +-- REPLACE dvMain.footer WITH { +-- FOOTER { -- ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE_CHANGES, ButtonStyle: Primary) -- ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL_CHANGES) -- ACTIONBUTTON btnDelete (Caption: 'Delete', Action: CANCEL_CHANGES, ButtonStyle: Danger) diff --git a/mdl-examples/doctype-tests/17-custom-widget-examples.mdl b/mdl-examples/doctype-tests/17-custom-widget-examples.mdl index 8c3761e01..1250ab53d 100644 --- a/mdl-examples/doctype-tests/17-custom-widget-examples.mdl +++ b/mdl-examples/doctype-tests/17-custom-widget-examples.mdl @@ -158,7 +158,7 @@ create page CWTest.P_ComboBox_Enum folder 'CustomWidgets' } } } - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } @@ -201,7 +201,7 @@ create page CWTest.P_ComboBox_Assoc folder 'CustomWidgets' } } } - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: success) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } diff --git a/mdl-examples/doctype-tests/29-datagrid-examples.mdl b/mdl-examples/doctype-tests/29-datagrid-examples.mdl index fd10e9e1d..bfedd2e85 100644 --- a/mdl-examples/doctype-tests/29-datagrid-examples.mdl +++ b/mdl-examples/doctype-tests/29-datagrid-examples.mdl @@ -87,7 +87,7 @@ create page DgTest.Product_Edit folder 'DataGrid' ( dataview dvProduct (datasource: $Product) { textbox txtName (label: 'Name', attribute: Name) textbox txtCode (label: 'Code', attribute: Code) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes close page, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes close page, buttonstyle: default) } diff --git a/mdl-examples/use-cases/02-agentic-search.mdl b/mdl-examples/use-cases/02-agentic-search.mdl index e70a5ca21..fdd680a3f 100644 --- a/mdl-examples/use-cases/02-agentic-search.mdl +++ b/mdl-examples/use-cases/02-agentic-search.mdl @@ -395,7 +395,7 @@ create page TaskMgmt.Task_Detail datepicker dpDue (label: 'Due Date', attribute: DueDate) textbox txtHours (label: 'Estimated Hours', attribute: EstimatedHours) - footer footer1 { + footer { actionbutton btnSave (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCancel (caption: 'Cancel', action: cancel changes) } diff --git a/mdl-examples/widgetdemo/03-showcase-page.mdl b/mdl-examples/widgetdemo/03-showcase-page.mdl index 857186439..c415600ae 100644 --- a/mdl-examples/widgetdemo/03-showcase-page.mdl +++ b/mdl-examples/widgetdemo/03-showcase-page.mdl @@ -156,7 +156,7 @@ create or modify page WidgetDemo.WidgetDemo_Showcase ( } } } - footer ftr1 { + footer { actionbutton btnSv (caption: 'Save', action: save changes, buttonstyle: primary) actionbutton btnCn (caption: 'Cancel', action: cancel changes) } diff --git a/mdl/backend/pagemutator/alter_target.go b/mdl/backend/pagemutator/alter_target.go index 07cd5cb06..0a150d363 100644 --- a/mdl/backend/pagemutator/alter_target.go +++ b/mdl/backend/pagemutator/alter_target.go @@ -28,6 +28,9 @@ func (m *Mutator) ResolveAlterTarget(t backend.AlterTarget) (backend.AlterTarget if kind, ok, err := m.resolveScrollRegion(container, member); ok { return backend.AlterTargetMatch{Kind: kind, Name: name}, err } + if _, ok := m.findDataViewFooter(container, member); ok { + return backend.AlterTargetMatch{Kind: "region", Name: name}, nil + } if _, err := findBsonColumn(m.rawData, container, member, m.widgetFinder); err != nil { return backend.AlterTargetMatch{}, err } diff --git a/mdl/backend/pagemutator/footer_region_test.go b/mdl/backend/pagemutator/footer_region_test.go new file mode 100644 index 000000000..bb8db58e7 --- /dev/null +++ b/mdl/backend/pagemutator/footer_region_test.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/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" +) + +// A data view's footer is a region, not a widget: Studio Pro stores its +// widgets in the data view's FooterWidgets list and the footer itself has no +// Name. ako/mxcli#528 / mendixlabs/mxcli#293: neither the name the script +// wrote nor describe's invented `footer1` resolved, so a footer could not be +// edited at all. It is addressed as `.footer`. +func footerPage(footer ...bson.D) bson.D { + fw := bson.A{int32(2)} + for _, w := range footer { + fw = append(fw, w) + } + return makeRawPage( + bson.D{ + {Key: "$Type", Value: "Forms$DataView"}, + {Key: "Name", Value: "dvMain"}, + {Key: "ShowFooter", Value: len(footer) > 0}, + {Key: "Widgets", Value: bson.A{int32(2), bson.D{{Key: "$Type", Value: "Forms$TextBox"}, {Key: "Name", Value: "txtReason"}}}}, + {Key: "FooterWidgets", Value: fw}, + }, + bson.D{{Key: "$Type", Value: "Forms$DivContainer"}, {Key: "Name", Value: "ctn"}}, + ) +} + +func button(name string) bson.D { + return bson.D{{Key: "$Type", Value: "Forms$ActionButton"}, {Key: "Name", Value: name}} +} + +func newFooterMutator(raw bson.D) *Mutator { + return New(raw, model.ID("page-1"), &stubWidgetDeps{}) +} + +func footerNames(t *testing.T, m *Mutator) []string { + t.Helper() + dv := findBsonWidget(m.rawData, "dvMain") + if dv == nil { + t.Fatal("dvMain not found") + } + var names []string + for _, el := range bsonnav.DGetArrayElements(bsonnav.DGet(dv.widget, "FooterWidgets")) { + if d, ok := el.(bson.D); ok { + names = append(names, bsonnav.DGetString(d, "Name")) + } + } + return names +} + +func textBoxes(names ...string) []pages.Widget { + var ws []pages.Widget + for _, n := range names { + ws = append(ws, &pages.TextBox{BaseWidget: pages.BaseWidget{Name: n}}) + } + return ws +} + +func TestDataViewFooter_Resolves(t *testing.T) { + m := newFooterMutator(footerPage(button("btnSave"))) + got, err := m.ResolveAlterTarget(backend.AlterTarget{Path: []string{"dvMain", "footer"}}) + if err != nil { + t.Fatalf("dvMain.footer did not resolve: %v", err) + } + if got.Kind != "region" { + t.Errorf("kind = %q, want region", got.Kind) + } + // Control: a member a data view does not have is still refused. + if _, err := m.ResolveAlterTarget(backend.AlterTarget{Path: []string{"dvMain", "header"}}); err == nil { + t.Error("dvMain.header resolved; a data view has no header region") + } + if !m.ResolvesTarget("dvMain", "footer") { + t.Error("ResolvesTarget(dvMain, footer) = false; check would refuse what exec does") + } +} + +func TestDataViewFooter_InsertInto(t *testing.T) { + m := newFooterMutator(footerPage(button("btnSave"))) + if err := m.InsertWidget("dvMain", "footer", backend.InsertPosition("into"), textBoxes("added")); err != nil { + t.Fatalf("insert into dvMain.footer: %v", err) + } + if got := strings.Join(footerNames(t, m), ","); got != "btnSave,added" { + t.Errorf("FooterWidgets = %s, want btnSave,added", got) + } + // BEFORE/AFTER a region means nothing: refuse rather than guess. + if err := m.InsertWidget("dvMain", "footer", backend.InsertPosition("after"), textBoxes("x")); err == nil { + t.Error("insert after dvMain.footer succeeded; only INTO addresses a region") + } +} + +// Inserting into an EMPTY footer turns it on, as a `footer { … }` block does in +// CREATE — otherwise the widgets are stored where nothing renders them. +func TestDataViewFooter_InsertIntoEmptyShowsFooter(t *testing.T) { + m := newFooterMutator(footerPage()) + if err := m.InsertWidget("dvMain", "footer", backend.InsertPosition("into"), textBoxes("added")); err != nil { + t.Fatalf("insert into empty dvMain.footer: %v", err) + } + dv := findBsonWidget(m.rawData, "dvMain").widget + if bsonnav.DGet(dv, "ShowFooter") != true { + t.Errorf("ShowFooter = %v after filling an empty footer, want true", bsonnav.DGet(dv, "ShowFooter")) + } + if got := bsonnav.ToBsonA(bsonnav.DGet(dv, "FooterWidgets")); len(got) == 0 || got[0] != int32(2) { + t.Errorf("FooterWidgets lost its list marker: %v", got) + } +} + +func TestDataViewFooter_Replace(t *testing.T) { + m := newFooterMutator(footerPage(button("btnSave"), button("btnCancel"))) + if err := m.ReplaceWidget("dvMain", "footer", textBoxes("btnS2")); err != nil { + t.Fatalf("replace dvMain.footer: %v", err) + } + if got := strings.Join(footerNames(t, m), ","); got != "btnS2" { + t.Errorf("FooterWidgets = %s, want btnS2 (the whole footer replaced)", got) + } + // The data view's body is untouched. + if findBsonWidget(m.rawData, "txtReason") == nil { + t.Error("replacing the footer removed the data view's body") + } +} + +func TestDataViewFooter_Drop(t *testing.T) { + m := newFooterMutator(footerPage(button("btnSave"))) + if err := m.DropWidget([]backend.WidgetRef{{Widget: "dvMain", Column: "footer"}}); err != nil { + t.Fatalf("drop dvMain.footer: %v", err) + } + if got := footerNames(t, m); len(got) != 0 { + t.Errorf("FooterWidgets = %v after drop, want empty", got) + } + dv := findBsonWidget(m.rawData, "dvMain").widget + if got := bsonnav.ToBsonA(bsonnav.DGet(dv, "FooterWidgets")); len(got) != 1 || got[0] != int32(2) { + t.Errorf("FooterWidgets = %v after drop, want the bare list marker", got) + } + if findBsonWidget(m.rawData, "dvMain") == nil { + t.Error("dropping the footer dropped the data view") + } +} + +// A miss on a name that is not stored names the address that works, when the +// page has a data view footer to point at. +func TestDataViewFooter_NotFoundNamesTheRegion(t *testing.T) { + m := newFooterMutator(footerPage(button("btnSave"))) + err := m.ReplaceWidget("footer1", "", textBoxes("x")) + if err == nil || !strings.Contains(err.Error(), "dvMain.footer") { + t.Errorf("replace footer1: error %v should name dvMain.footer", err) + } +} diff --git a/mdl/backend/pagemutator/footerregion.go b/mdl/backend/pagemutator/footerregion.go new file mode 100644 index 000000000..46d142868 --- /dev/null +++ b/mdl/backend/pagemutator/footerregion.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// A data view's footer is a REGION, not a widget (ako/mxcli#528, +// mendixlabs/mxcli#293). Studio Pro keeps its widgets in the data view's own +// FooterWidgets list and stores no name for it, so `footer footerButtons { … }` +// had nowhere to keep `footerButtons`, and describe's `footer1` was invented. +// Neither resolved, and a footer could not be edited at all. +// +// It is addressed the way a scroll container's region is — positionally, by the +// owner and the slot: `dvMain.footer`. INSERT INTO appends to it, REPLACE swaps +// its whole content, DROP empties it; BEFORE/AFTER a region mean nothing and are +// refused, as they are for a scroll-container region. + +const dataViewFooterSlot = "footer" + +// isDataViewFooterRef reports whether a dotted member names the footer slot. +func isDataViewFooterRef(member string) bool { + return strings.EqualFold(member, dataViewFooterSlot) +} + +// findDataViewFooter resolves `.footer`. ok is false when the owner is +// not a data view, so the caller falls through to the other dotted forms (a +// DataGrid 2 column called "footer" stays addressable). +func (m *Mutator) findDataViewFooter(owner, member string) (dv *bsonWidgetResult, ok bool) { + if !isDataViewFooterRef(member) { + return nil, false + } + result := m.widgetFinder(m.rawData, owner) + if result == nil { + return nil, false + } + if t := bsonnav.DGetString(result.widget, "$Type"); t != "Forms$DataView" && t != "Pages$DataView" { + return nil, false + } + return result, true +} + +// setDataViewFooter stores the footer's new content, keeping the list marker +// (2, as CREATE writes it — widget_write.go MandatoryListMarkers), and writes +// the data view back into its parent slot: bson.D is a slice, and a grown field +// list that is not written back is a silent no-op reported as success. +func setDataViewFooter(dv *bsonWidgetResult, widgets []any) { + out := bson.A{int32(2)} + if raw := bsonnav.ToBsonA(bsonnav.DGet(dv.widget, "FooterWidgets")); len(raw) > 0 && isListMarker(raw[0]) { + out = bson.A{raw[0]} + } + out = append(out, widgets...) + doc := dv.widget + if !bsonnav.DSet(doc, "FooterWidgets", out) { + doc = append(doc, bson.E{Key: "FooterWidgets", Value: out}) + } + dv.widget = doc + dv.parentArr[dv.index] = doc + bsonnav.DSetArray(dv.parentDoc, dv.parentKey, dv.parentArr) +} + +// dataViewFooterWidgets is the footer's current content, without the marker. +func dataViewFooterWidgets(dv bson.D) []any { + return bsonnav.DGetArrayElements(bsonnav.DGet(dv, "FooterWidgets")) +} + +// insertIntoDataViewFooter handles `insert into .footer { … }`. +func (m *Mutator) insertIntoDataViewFooter(owner, member string, position backend.InsertPosition, widgets []pages.Widget) (bool, error) { + dv, ok := m.findDataViewFooter(owner, member) + if !ok { + return false, nil + } + if !strings.EqualFold(string(position), "into") { + return true, fmt.Errorf("a data view footer can only be an INSERT INTO target; "+ + "to place a widget relative to another, name that widget: `insert %s { … }`", + strings.ToLower(string(position))) + } + newBson, err := m.serializeWidgets(widgets) + if err != nil { + return true, fmt.Errorf("serialize widgets: %w", err) + } + existing := dataViewFooterWidgets(dv.widget) + if len(existing) == 0 && len(newBson) > 0 { + // Filling an empty footer turns it on, as a `footer { … }` block does in + // CREATE; otherwise the widgets are stored where nothing renders them. + bsonnav.DSet(dv.widget, "ShowFooter", true) + } + setDataViewFooter(dv, append(append([]any{}, existing...), newBson...)) + return true, nil +} + +// replaceDataViewFooter handles `replace .footer with { … }`: the +// footer's whole content becomes the new widgets. +func (m *Mutator) replaceDataViewFooter(owner, member string, widgets []pages.Widget) (bool, error) { + dv, ok := m.findDataViewFooter(owner, member) + if !ok { + return false, nil + } + newBson, err := m.serializeWidgets(widgets) + if err != nil { + return true, fmt.Errorf("serialize widgets: %w", err) + } + if len(dataViewFooterWidgets(dv.widget)) == 0 && len(newBson) > 0 { + bsonnav.DSet(dv.widget, "ShowFooter", true) + } + setDataViewFooter(dv, newBson) + return true, nil +} + +// dropDataViewFooter handles `drop .footer`: the footer is emptied. +// The data view's ShowFooter is its own property and is left as stored. +func (m *Mutator) dropDataViewFooter(owner, member string) bool { + dv, ok := m.findDataViewFooter(owner, member) + if !ok { + return false + } + setDataViewFooter(dv, nil) + return true +} + +// dataViewFooterHint names the footer addresses a page has, for a not-found +// message: the name a script wrote on a data view footer, and describe's old +// `footer1`, were never stored, so a miss on one should say what does resolve. +func (m *Mutator) dataViewFooterHint() string { + var owners []string + var walk func(v any) + walk = func(v any) { + switch x := v.(type) { + case bson.D: + if t := bsonnav.DGetString(x, "$Type"); (t == "Forms$DataView" || t == "Pages$DataView") && + len(dataViewFooterWidgets(x)) > 0 { + if n := bsonnav.DGetString(x, "Name"); n != "" { + owners = append(owners, n+"."+dataViewFooterSlot) + } + } + for _, e := range x { + walk(e.Value) + } + case bson.A: + for _, e := range x { + walk(e) + } + } + } + walk(m.rawData) + if len(owners) == 0 { + return "" + } + return ". A data view footer stores no name — address it by its data view: " + strings.Join(owners, ", ") +} + +// ContainedWidgetNames lists the names stored INSIDE what a reference addresses +// — a widget's descendants, or a data view footer's widgets and theirs. A +// REPLACE removes them with its target, so the replacement may reuse them; the +// duplicate-name scope used to count them as taken (mendixlabs/mxcli#293). +func (m *Mutator) ContainedWidgetNames(widgetRef, columnRef string) []string { + var roots []any + switch { + case columnRef != "": + dv, ok := m.findDataViewFooter(widgetRef, columnRef) + if !ok { + return nil + } + roots = dataViewFooterWidgets(dv.widget) + default: + result := m.widgetFinder(m.rawData, widgetRef) + if result == nil { + return nil + } + // The widget's own fields, not the widget: its own name is the target. + for _, e := range result.widget { + roots = append(roots, e.Value) + } + } + var names []string + var walk func(v any) + walk = func(v any) { + switch x := v.(type) { + case bson.D: + if strings.HasPrefix(bsonnav.DGetString(x, "$Type"), "Forms$") || strings.HasPrefix(bsonnav.DGetString(x, "$Type"), "CustomWidgets$CustomWidget") { + if n := bsonnav.DGetString(x, "Name"); n != "" { + names = append(names, n) + } + } + for _, e := range x { + walk(e.Value) + } + case bson.A: + for _, e := range x { + walk(e) + } + } + } + for _, r := range roots { + walk(r) + } + return names +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index a92749a29..7c76b1a6b 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -429,6 +429,9 @@ func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position back if handled, err := m.insertIntoScrollRegion(widgetRef, columnRef, position, widgets); handled { return err } + if handled, err := m.insertIntoDataViewFooter(widgetRef, columnRef, position, widgets); handled { + return err + } // Resolve first, so a mistyped column still reports "not found" (with the // available names) rather than the refusal below. if _, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder); err != nil { @@ -558,6 +561,9 @@ func (m *Mutator) DropWidget(refs []backend.WidgetRef) error { for _, ref := range refs { // Re-find widget each iteration because previous drops mutate the tree. var result *bsonWidgetResult + if ref.IsColumn() && m.dropDataViewFooter(ref.Widget, ref.Column) { + continue + } if ref.IsColumn() { r, err := findBsonColumn(m.rawData, ref.Widget, ref.Column, m.widgetFinder) if err != nil { @@ -583,6 +589,9 @@ func (m *Mutator) DropWidget(refs []backend.WidgetRef) error { func (m *Mutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pages.Widget) error { if columnRef != "" { + if handled, err := m.replaceDataViewFooter(widgetRef, columnRef, widgets); handled { + return err + } // Resolve first, so a mistyped column still reports "not found" (with the // available names) rather than the refusal below. if _, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder); err != nil { @@ -1902,7 +1911,7 @@ func (m *Mutator) widgetNotFoundError(name string) error { "available columns: %s (run DESCRIBE PAGE to confirm)", name, formatColumnNameList(cols)) } - return fmt.Errorf("widget %q not found", name) + return fmt.Errorf("widget %q not found%s", name, m.dataViewFooterHint()) } // formatColumnNameList renders derived column names for an error message: each diff --git a/mdl/backend/pagemutator/probe.go b/mdl/backend/pagemutator/probe.go index 08c62d95b..9575ffc9e 100644 --- a/mdl/backend/pagemutator/probe.go +++ b/mdl/backend/pagemutator/probe.go @@ -59,6 +59,9 @@ func (m *Mutator) ResolvesTarget(widgetRef, columnRef string) bool { return true // page-level SET addresses the document itself } if columnRef != "" { + if _, ok := m.findDataViewFooter(widgetRef, columnRef); ok { + return true + } _, err := findBsonColumn(m.rawData, widgetRef, columnRef, m.widgetFinder) return err == nil } diff --git a/mdl/deprecation/deprecation.go b/mdl/deprecation/deprecation.go index 69cf53731..418115a00 100644 --- a/mdl/deprecation/deprecation.go +++ b/mdl/deprecation/deprecation.go @@ -97,7 +97,8 @@ const ( AggregateFunctionForm = "MDL-DEPR004" // UnstoredWidgetName is a name written on a page element Mendix stores no // name for: a layout grid's rows and columns, a data grid's columns and - // control bar, a gallery's template and filter (R12, ako/mxcli#749). + // control bar, a gallery's template and filter (R12, ako/mxcli#749), a + // data view's footer (ako/mxcli#528). UnstoredWidgetName = "MDL-DEPR005" // R10: document types named as Studio Pro names them (ako/mxcli#755). The @@ -419,8 +420,8 @@ var entries = []Entry{ Rewrite: Rewrite{Structural: "name out of the element: `row row1 {` becomes `row {`"}, RemovedIn: 2, Note: "Mendix stores no name on a layout grid's row, a row's column, a data grid's column or control bar, " + - "or a gallery's template or filter, so the name was never written and describe no longer invents one. " + - "A data grid column is addressed as `grid column(Attr)` or `grid column('Caption')`.", + "a gallery's template or filter, or a data view's footer, so the name was never written and describe no longer invents one. " + + "A data grid column is addressed as `grid column(Attr)` or `grid column('Caption')`, a data view's footer as `dv.footer`.", Example: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { datagrid dg (DataSource: database from M.E) { column Name (Attribute: Name) } };", CanonicalExample: "create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default) { datagrid dg (DataSource: database from M.E) { column (Attribute: Name) } };", }, diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 642242a6e..782a815bf 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -478,12 +478,18 @@ func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op return err } expanded := *op - expanded.NewWidgets = newWidgets + expanded.NewWidgets = unwrapFooterReplacement(op.Target, newWidgets) op = &expanded + footerRegion := isFooterRegionTarget(op.Target) + + // The widgets the replace removes are not duplicates of what replaces them: + // restating a footer's or a container's own children under their names is + // the ordinary edit, and was refused as "duplicate widget name" (#293). + replaced := replacedWidgetNames(mutator, op.Target) // Check for duplicate widget names (skip the widget being replaced) for _, w := range op.NewWidgets { - if w.Name != "" && w.Name != op.Target.Widget && w.Name != columnRefOf(op.Target) && mutator.FindWidget(w.Name) { + if w.Name != "" && w.Name != op.Target.Widget && w.Name != columnRefOf(op.Target) && !replaced[w.Name] && mutator.FindWidget(w.Name) { return mdlerrors.NewAlreadyExistsMsg("widget", w.Name, fmt.Sprintf("duplicate widget name '%s': a widget with this name already exists on the page", w.Name)) } } @@ -500,12 +506,19 @@ func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op return mutator.ReplaceColumn(op.Target.Widget, columnRefOf(op.Target), specs) } - // Find entity context from enclosing DataView/DataGrid/ListView for regular widget replace. - entityCtx, _ := alterEntityContext(ctx, mutator, op.Target.Widget, false, moduleName, moduleID) + // Find entity context from enclosing DataView/DataGrid/ListView for regular + // widget replace. A data view footer's widgets sit INSIDE the data view, so + // they take its own context, as INSERT INTO does. + entityCtx, _ := alterEntityContext(ctx, mutator, op.Target.Widget, footerRegion, moduleName, moduleID) - // Build new widgets from AST, excluding the target widget/column from the - // duplicate-name scope so a same-name replacement is allowed. - widgets, err := buildWidgetsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator, op.Target.Widget, columnRefOf(op.Target)) + // Build new widgets from AST, excluding the target widget/column — and what + // it contains — from the duplicate-name scope so a same-name replacement is + // allowed. + exclude := []string{op.Target.Widget, columnRefOf(op.Target)} + for n := range replaced { + exclude = append(exclude, n) + } + widgets, err := buildWidgetsFromAST(ctx, op.NewWidgets, moduleName, moduleID, entityCtx, mutator, exclude...) if err != nil { return mdlerrors.NewBackend("build replacement widgets", err) } @@ -513,6 +526,42 @@ func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op return mutator.ReplaceWidget(op.Target.Widget, columnRefOf(op.Target), widgets) } +// isFooterRegionTarget reports whether an ALTER target is `.footer` — +// a data view's footer region (ako/mxcli#528). Whether the owner IS a data view +// is the mutator's call; on anything else the dotted form keeps its other +// meanings (a scroll-container region, a grid column). +func isFooterRegionTarget(t ast.WidgetRef) bool { + return t.Widget != "" && !t.IsColumnAddress() && strings.EqualFold(t.Column, "footer") +} + +// unwrapFooterReplacement lets `replace dv.footer with { footer { … } }` — the +// footer block as describe prints it — mean its content: a footer region holds +// widgets, and a `footer` built outside a data view would be a container nested +// inside the region rather than the region's content. +func unwrapFooterReplacement(target ast.WidgetRef, widgets []*ast.WidgetV3) []*ast.WidgetV3 { + if !isFooterRegionTarget(target) || len(widgets) != 1 || !strings.EqualFold(widgets[0].Type, "footer") { + return widgets + } + return widgets[0].Children +} + +// replacedWidgetNames lists the names a REPLACE removes along with its target: +// everything inside it. Only mutators that can walk the stored tree answer; the +// others keep the old, stricter scope. +func replacedWidgetNames(mutator backend.PageMutator, target ast.WidgetRef) map[string]bool { + walker, ok := mutator.(interface { + ContainedWidgetNames(widgetRef, columnRef string) []string + }) + if !ok { + return nil + } + names := map[string]bool{} + for _, n := range walker.ContainedWidgetNames(target.Widget, columnRefOf(target)) { + names[n] = true + } + return names +} + // allColumns returns true if all widgets in the slice have type "column". // Used to dispatch ALTER PAGE INSERT/REPLACE into a DataGrid2 column to the // column-specific mutator path. diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 4dd2d09e7..058d52237 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -1073,7 +1073,11 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { fmt.Fprintf(ctx.Output, "%s}\n", prefix) case "Footer": - fmt.Fprintf(ctx.Output, "%sfooter %s {\n", prefix, mdlIdent(w.Name)) + if w.Name == "" { + fmt.Fprintf(ctx.Output, "%sfooter {\n", prefix) + } else { + fmt.Fprintf(ctx.Output, "%sfooter %s {\n", prefix, mdlIdent(w.Name)) + } for _, child := range w.Children { outputWidgetMDLV3(ctx, child, indent+1) } diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 38213ff7a..e59a545fc 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -905,8 +905,10 @@ func parseDataViewChildren(ctx *ExecContext, w map[string]any, entityContext ... // Get footer widgets footerWidgets := getBsonArrayElements(w["FooterWidgets"]) if len(footerWidgets) > 0 { - // Create a special footer container with synthetic name - footer := rawWidget{Type: "Footer", Name: "footer1"} + // The footer is a region of the data view, stored without a name: it is + // printed unnamed and addressed as `.footer` (ako/mxcli#528). + // The `footer1` printed here before named nothing ALTER could find. + footer := rawWidget{Type: "Footer"} for _, child := range footerWidgets { if childMap, ok := child.(map[string]any); ok { footer.Children = append(footer.Children, parseRawWidget(ctx, childMap, entCtx)...) diff --git a/mdl/executor/page_silent_changes_integration_test.go b/mdl/executor/page_silent_changes_integration_test.go new file mode 100644 index 000000000..3f351ff39 --- /dev/null +++ b/mdl/executor/page_silent_changes_integration_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package executor + +import ( + "strings" + "testing" +) + +// mendixlabs/mxcli#1214: `alter page … set Editable = true on ` +// printed "Altered page" and stored nothing. describe is the read the user has. +func TestAlterPage_SetListViewEditable(t *testing.T) { + env := setupTestEnv(t) + defer env.teardown() + + entity := testModule + ".EditRow" + page := testModule + ".EditRowsPage" + if err := env.executeMDL(`create or modify persistent entity ` + entity + ` (Note: String(200));`); err != nil { + t.Fatal(err) + } + if err := env.executeMDL(`create page ` + page + ` (Title: 'Rows', Layout: Atlas_Core.Atlas_Default) { + listview lvRows (DataSource: database ` + entity + `) { textbox txtNote (Attribute: Note) } + }`); err != nil { + t.Fatal(err) + } + before, err := env.describeMDL(`describe page ` + page + `;`) + if err != nil { + t.Fatal(err) + } + // Control: the list view starts read-only, so the assertion below can fail. + if strings.Contains(before, "Editable: true") { + t.Fatalf("control: list view already editable before the alter:\n%s", before) + } + if err := env.executeMDL(`alter page ` + page + ` { set (Editable: true) on lvRows };`); err != nil { + t.Fatal(err) + } + after, err := env.describeMDL(`describe page ` + page + `;`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(after, "listview lvRows (DataSource: database from "+entity+", Editable: true)") { + t.Errorf("set Editable = true on a list view was not stored:\n%s", after) + } + // The input enum is not a list view's value: refused, not swallowed. + if err := env.executeMDL(`alter page ` + page + ` { set (Editable: true) on txtNote };`); err == nil { + t.Error("set Editable = true on a text box succeeded; its Editable is Always/Never") + } +} + +// ako/mxcli#528, mendixlabs/mxcli#293: a data view's footer is a region with no +// stored name. It is addressed as `.footer`, a replacement may restate +// the names of the widgets it removes, and describe prints the footer unnamed — +// so its output re-executes onto the same page. +func TestAlterPage_DataViewFooterRegion(t *testing.T) { + env := setupTestEnv(t) + defer env.teardown() + env.requireMinVersion(t, 11, 0) + + entity := testModule + ".FootRow" + page := testModule + ".FootRowPage" + if err := env.executeMDL(`create or modify persistent entity ` + entity + ` (Note: String(200));`); err != nil { + t.Fatal(err) + } + if err := env.executeMDL(`create page ` + page + ` (Title: 'Foot', Layout: Atlas_Core.Atlas_Default, Params: { $Row: ` + entity + ` }) { + dataview dvMain (DataSource: $Row) { + textbox txtNote (Attribute: Note) + footer footerButtons { actionbutton btnSave (Caption: 'Save', Action: save_changes) } + } + }`); err != nil { + t.Fatal(err) + } + if err := env.executeMDL(`alter page ` + page + ` { + insert into dvMain.footer { actionbutton btnCancel (Caption: 'Cancel', Action: cancel_changes) } + };`); err != nil { + t.Fatalf("insert into dvMain.footer: %v", err) + } + if err := env.executeMDL(`alter page ` + page + ` { + replace dvMain.footer with { footer { + actionbutton btnSave (Caption: 'Save it', Action: save_changes) + actionbutton btnClose (Caption: 'Close', Action: close_page) + } } + };`); err != nil { + t.Fatalf("replace dvMain.footer restating btnSave: %v", err) + } + out, err := env.describeMDL(`describe page ` + page + `;`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "footer {") || strings.Contains(out, "footer1") || strings.Contains(out, "footerButtons") { + t.Errorf("describe should print the footer unnamed:\n%s", out) + } + if !strings.Contains(out, "'Save it'") || !strings.Contains(out, "btnClose") || strings.Contains(out, "btnCancel") { + t.Errorf("the footer was not replaced as a whole:\n%s", out) + } + // The name a script wrote on the footer, and the old `footer1`, name + // nothing; the miss points at the address that works. + err = env.executeMDL(`alter page ` + page + ` { drop footer1 };`) + if err == nil || !strings.Contains(err.Error(), "dvMain.footer") { + t.Errorf("drop footer1: %v, want a not-found naming dvMain.footer", err) + } + // describe's output re-executes onto the page it came from. + if err := env.executeMDL(stripDescribeArtifacts(out)); err != nil { + t.Fatalf("re-executing describe output: %v\n%s", err, out) + } + again, err := env.describeMDL(`describe page ` + page + `;`) + if err != nil { + t.Fatal(err) + } + if again != out { + t.Errorf("describe changed after re-executing its own output:\n--- before\n%s\n--- after\n%s", out, again) + } +} diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 3fe55a28b..a9efa5df4 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -346,7 +346,7 @@ alterStylingAssignment * set (Title: 'Edit order'); -- the document itself * insert after txtName { textbox txtNew (Label: 'New', Attribute: Attr) } * insert into ctnMain { … } - * replace footer1 with { footer f1 { … } } + * replace dvMain.footer with { footer { … } } * drop txtOld, dgOrders.Total; * } * ``` diff --git a/mdl/upgrade/unstored_widget_name_test.go b/mdl/upgrade/unstored_widget_name_test.go index d968a3b45..fc51950a5 100644 --- a/mdl/upgrade/unstored_widget_name_test.go +++ b/mdl/upgrade/unstored_widget_name_test.go @@ -63,3 +63,26 @@ func TestUpgrade_DropsUnstoredWidgetNames(t *testing.T) { t.Errorf("a second upgrade changed the script again:\n%s", again.Source) } } + +// ako/mxcli#528: the name on a data view's footer is dropped; a footer outside +// a data view is a container and keeps it. +func TestUpgrade_DropsDataViewFooterName(t *testing.T) { + src := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ( $E: M.E )) { + dataview dvMain (DataSource: $E) { + footer footer1 { actionbutton btnSave (Caption: 'Save', Action: save changes) } + } + footer pageFooter { dynamictext t (Content: 'x') } +}; +` + want := `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ( $E: M.E )) { + dataview dvMain (DataSource: $E) { + footer { actionbutton btnSave (Caption: 'Save', Action: save changes) } + } + footer pageFooter { dynamictext t (Content: 'x') } +}; +` + res := mustUpgrade(t, src, Options{}) + if res.Source != want { + t.Fatalf("got:\n%s\nwant:\n%s", res.Source, want) + } +} diff --git a/mdl/visitor/visitor_unstored_widget_name.go b/mdl/visitor/visitor_unstored_widget_name.go index 7303d727e..993229b1c 100644 --- a/mdl/visitor/visitor_unstored_widget_name.go +++ b/mdl/visitor/visitor_unstored_widget_name.go @@ -13,8 +13,9 @@ import ( // A name on a page element Mendix stores no name for (R12, ako/mxcli#749). // -// A layout grid's rows and columns, a data grid's columns and control bar, and -// a gallery's template and filter have no Name in the model: the builder of +// A layout grid's rows and columns, a data grid's columns and control bar, a +// gallery's template and filter, and a data view's footer have no Name in the +// model: the builder of // the parent builds them and never reads one. describe used to invent one // (`row1`, `col3`, a column named after its attribute) and scripts copied it, // so a name there is the old spelling of the same page — MDL-DEPR005. It is @@ -45,6 +46,10 @@ var unstoredChildKinds = map[string]map[string]bool{ "row": {"column": true}, "datagrid": {"column": true, "controlbar": true}, "gallery": {"template": true, "filter": true}, + // A data view's footer is a region: its widgets go to the data view's + // FooterWidgets and the footer itself is not stored, so its name could + // never be addressed (ako/mxcli#528). `dvMain.footer` addresses it. + "dataview": {"footer": true}, } // rememberWidgetName records where a widget's name was written, for its parent. diff --git a/mdl/visitor/visitor_unstored_widget_name_test.go b/mdl/visitor/visitor_unstored_widget_name_test.go index 5432b2faa..95ba03d15 100644 --- a/mdl/visitor/visitor_unstored_widget_name_test.go +++ b/mdl/visitor/visitor_unstored_widget_name_test.go @@ -121,3 +121,36 @@ func sameMultiset(a, b []string) bool { } return true } + +// ako/mxcli#528: a data view's footer is a region — its widgets are stored in +// the data view's FooterWidgets and the footer has no Name — so `footer +// footerButtons { … }` inside a data view named nothing ALTER could find. It is +// the same deprecated spelling; a footer anywhere else is a container that +// keeps its name. +func TestDataViewFooterNameIsDeprecated(t *testing.T) { + old := mustBuild(t, `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ( $E: M.E )) { + dataview dvMain (DataSource: $E) { + footer footerButtons { actionbutton btnSave (Caption: 'Save', Action: save changes) } + } + footer pageFooter { dynamictext t (Content: 'x') } +};`) + canon := mustBuild(t, `create page M.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ( $E: M.E )) { + dataview dvMain (DataSource: $E) { + footer { actionbutton btnSave (Caption: 'Save', Action: save changes) } + } + footer pageFooter { dynamictext t (Content: 'x') } +};`) + if got := deprecationCodes(old); !reflect.DeepEqual(got, []string{deprecation.UnstoredWidgetName}) { + t.Errorf("recorded %v, want one %s for the data view's footer", got, deprecation.UnstoredWidgetName) + } + if got := deprecationCodes(canon); len(got) != 0 { + t.Errorf("the canonical spelling recorded %v", got) + } + if !reflect.DeepEqual(old.Statements, canon.Statements) { + t.Errorf("the two spellings build different pages") + } + page := canon.Statements[0].(*ast.CreatePageStmtV3) + if got := page.Widgets[1].Name; got != "pageFooter" { + t.Errorf("a footer outside a data view lost its stored name: %q", got) + } +} From 76ece771b4414c0602cbbc769aff6e30a2ea625c Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:01:26 +0000 Subject: [PATCH 07/23] fix(cli): -c "" is an error, and a failing -c statement says what was not run (mendixlabs/mxcli#1218) An empty -c fell through to the interactive REPL and hung callers with an open stdin. A failing statement in a ;-separated -c stopped the run silently. -c keeps exec's fail-fast semantics but now reports 'statement N of M' and how many later statements were not run, and takes --continue-on-error like exec. describe entity/association of an unqualified name names it instead of the create-path message. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + cmd/mxcli/main.go | 57 ++-------- cmd/mxcli/oneliner.go | 104 ++++++++++++++++++ cmd/mxcli/oneliner_test.go | 76 +++++++++++++ docs-site/src/appendixes/quick-reference.md | 1 + docs-site/src/tutorial/opening-project.md | 2 + .../describe_unqualified_name_test.go | 31 ++++++ mdl/executor/executor.go | 25 ++++- mdl/executor/executor_query.go | 9 ++ 9 files changed, 253 insertions(+), 53 deletions(-) create mode 100644 cmd/mxcli/oneliner.go create mode 100644 cmd/mxcli/oneliner_test.go create mode 100644 mdl/executor/describe_unqualified_name_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index ce5852126..cbceaf90f 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -137,3 +137,4 @@ {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "fmt --upgrade over a stub-then-real set still split the pair when the stub's file carried a written `mdl 0;`: the real file took `mdl 1;`. With the real file already `mdl 1;`, fmt printed 'no language header added' about it while the pair stayed split.", "cause": "canTakeHeader returned true for ANY written header (langver.ScanWrittenHeader's bool), reading a pinned `mdl 0;` as 'already has the header'; and the group decline was applied to a file that already carries the header, which fmt never removes.", "fix": "canTakeHeader: a written header can take it only when it is langver.Latest. decideSetHeaders: a file already under the header is not declined; fmt says the pair stays under different headers and to drop the stub or take the header off.", "insight": "ScanWrittenHeader's bool means 'a header is written', not 'the header is mdl 1'; a written mdl 0 pin is the strongest 'cannot take it' there is. Test the group decision with every header state of each file, not only headerless ones.", "issue": "ako/mxcli#905", "file": "cmd/mxcli/cmd_fmt.go", "test": "cmd/mxcli/script_set_test.go TestFmtUpgrade_ScriptSetPinnedMdl0StubHoldsTheRealFileBack, TestFmtUpgrade_ScriptSetAlreadySplitPairIsReportedAsSplit"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli version` fails with `unknown command \"version\" for \"mxcli\"` while `mxcli --version` works", "cause": "cobra's `Version:` field registers only the --version flag; no subcommand existed, though shouldSuppressWarning already listed a `version` argument", "fix": "Add a `version` subcommand (cmd/mxcli/cmd_version.go) printing exactly cobra's --version text (name, version, build time)", "insight": "Code that special-cases an argument (warning suppression) is evidence of an intended command; a test pinning the subcommand output to the flag output keeps the two from drifting", "issue": "ako/mxcli#534", "file": "cmd/mxcli/cmd_version.go", "test": "cmd/mxcli/cmd_version_test.go"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli check`/`exec`/`fmt`/`diff` fail on a script saved by Windows PowerShell 5.1: a UTF-8 BOM gives `line 1:0 token recognition error at: '\\ufeff'` (an invisible character), UTF-16LE gives a token error on almost every character; the same through stdin and in .test.mdl files", "cause": "Every script reader passed the raw file bytes to the lexer, which reads UTF-8 without a BOM; there was no shared reader (fmt, diff, the multi-file check pass, the test runner and EXECUTE SCRIPT each called os.ReadFile on their own)", "fix": "New mdl/srctext.Decode (strip a leading UTF-8 BOM, decode UTF-16LE/BE by BOM); readMDLSource calls it and fmt, diff and parseScriptSet now read through readMDLSource; testrunner.ParseTestFile and EXECUTE SCRIPT call it directly", "insight": "A BOM also hides a `mdl 1;` header from langver.ScanWrittenHeader, so stripping it in the parser alone would have left the language version wrong: decode where the bytes are read, before anything inspects the text. Enumerate the readers (grep os.ReadFile / io.ReadAll(os.Stdin)), not just the one the report names", "issue": "mendixlabs/mxcli#1253", "file": "mdl/srctext/srctext.go; cmd/mxcli/mdlsource.go", "test": "mdl/srctext/srctext_test.go; cmd/mxcli/mdlsource_encoding_test.go"} +{"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli -p App.mpr -c \"\"` opens the interactive REPL (a generator spawning mxcli with an open stdin hangs at `mdl>`); `-c \"describe entity System.User; describe entity String; describe entity System.FileDocument\"` stops at statement 2 with `module name is required: objects must be created within a module` and the third statement is silently never run", "cause": "Root Run tested `commands != \"\"` to choose -c over the REPL, so an empty flag value was indistinguishable from no flag; the -c path used ExecuteProgram, which returns the first error without its position, and describe entity/association reached findModule(\"\"), whose message is written for the create path", "fix": "`cmd.Flags().Changed(\"command\")` selects the one-liner path; runCommandLine (cmd/mxcli/oneliner.go) refuses empty input, reports `statement N of M` and how many later statements were not run (via new Executor.ExecuteProgramReportingStop), and takes --continue-on-error like exec; execDescribe names an unqualified entity/association name", "insight": "A flag's zero value is not its absence: use Changed() whenever an empty value must mean something other than not given. Decided semantics: -c is fail-fast like exec (a later statement may depend on an earlier one), but a stop is never silent", "issue": "mendixlabs/mxcli#1218", "file": "cmd/mxcli/oneliner.go; cmd/mxcli/main.go; mdl/executor/executor.go; mdl/executor/executor_query.go", "test": "cmd/mxcli/oneliner_test.go; mdl/executor/describe_unqualified_name_test.go"} diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 317be7b5b..ed9137c11 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -13,7 +13,6 @@ import ( "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/mdl/diaglog" "github.com/mendixlabs/mxcli/mdl/executor" - "github.com/mendixlabs/mxcli/mdl/langver" "github.com/mendixlabs/mxcli/mdl/repl" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/spf13/cobra" @@ -143,51 +142,13 @@ beta language; --mdl 0 starts them in the alpha language, and in the REPL an commands, _ := cmd.Flags().GetString("command") projectPath, _ := cmd.Flags().GetString("project") - if commands != "" { - // Execute commands from -c flag - exec, logger := newLoggedExecutor("batch") - defer logger.Close() - defer exec.Close() - - // Suppress status messages when stdout is a pipe so that - // output can be piped directly to other tools (e.g. mxcli fmt). - if fi, statErr := os.Stdout.Stat(); statErr == nil && (fi.Mode()&os.ModeCharDevice) == 0 { - exec.SetQuiet(true) - } - - // The one-liner's language (freeze decision 6): mdl 1 unless - // --mdl 0, or a header the commands state themselves. It is what - // headerless input is read as and what describe writes. - lang := mdlFlag(cmd) - if v, written := langver.ScanWrittenHeader(commands); written { - lang = v - } - exec.SetDescribeLanguage(lang) - - // Auto-connect if project specified. CONNECT runs on its own so - // that a header in the commands stays their first statement. - if projectPath != "" { - connectProg, _ := visitor.Build(fmt.Sprintf("CONNECT LOCAL '%s';", visitor.QuoteString(projectPath))) - if err := exec.ExecuteProgram(connectProg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - } - - prog, errs := visitor.BuildSession(commands, lang) - if len(errs) > 0 { - for _, err := range errs { - fmt.Fprintf(os.Stderr, "Parse error: %v\n", err) - } - os.Exit(1) - } - - if err := exec.ExecuteProgram(prog); err != nil { - if errors.Is(err, executor.ErrExit) { - return - } - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + // -c given at all — even empty — is a one-liner run, never the REPL: + // an empty -c from a generator used to open the interactive prompt and + // hang its caller (mendixlabs/mxcli#1218). + if cmd.Flags().Changed("command") { + continueOnError, _ := cmd.Flags().GetBool("continue-on-error") + if code := runCommandLine(cmd, commands, projectPath, continueOnError, os.Stderr); code != 0 { + os.Exit(code) } } else { // Start interactive REPL @@ -353,7 +314,9 @@ func init() { rootCmd.PersistentFlags().Bool("mcp-verbose", false, "Print each PED tool call the MCP backend makes (requires --mcp)") rootCmd.PersistentFlags().Bool("mcp-trace", false, "Print each MDL command with the PED tool calls it makes (implies --mcp-verbose; requires --mcp)") rootCmd.PersistentFlags().String("engine", "", "Deprecated and ignored: there is one model engine. Kept so scripts pinning the old one keep running.") - rootCmd.Flags().StringP("command", "c", "", "Execute MDL command(s) and exit") + rootCmd.Flags().StringP("command", "c", "", "Execute MDL command(s) and exit (an empty value is an error, never the REPL)") + rootCmd.Flags().Bool("continue-on-error", false, + "With -c: run every statement, reporting each failure instead of stopping at the first (exits non-zero if any failed)") // Check command flags checkCmd.Flags().BoolP("references", "r", false, "Validate references against the project (implied by -p; kept for compatibility)") diff --git a/cmd/mxcli/oneliner.go b/cmd/mxcli/oneliner.go new file mode 100644 index 000000000..fef5bf553 --- /dev/null +++ b/cmd/mxcli/oneliner.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/spf13/cobra" +) + +// runCommandLine runs the statements of `mxcli -c` and returns the process exit +// code. Failures are written to errOut. +// +// The failure semantics are those of `mxcli exec` (mendixlabs/mxcli#1218): +// +// - An empty or whitespace-only -c is an error. It used to fall through to the +// interactive REPL, which a generator that produced an empty statement list +// experienced as a hang. +// - By default the run stops at the first failing statement, read-only or not +// — a later statement may depend on an earlier one, and a script that +// carries on past a failure can write on a wrong premise. The error says +// which statement failed and how many were not run, so the stop is never +// silent. +// - --continue-on-error attempts every statement, reports each failure with +// its statement number, and exits non-zero if any failed. +func runCommandLine(cmd *cobra.Command, commands, projectPath string, continueOnError bool, errOut io.Writer) int { + if strings.TrimSpace(commands) == "" { + fmt.Fprintln(errOut, "Error: -c was given no MDL to run. Pass one or more statements, "+ + "e.g. -c \"list modules;\"; run mxcli without -c for the interactive REPL.") + return 1 + } + + exec, logger := newLoggedExecutor("batch") + defer logger.Close() + defer exec.Close() + + // Suppress status messages when stdout is a pipe so that + // output can be piped directly to other tools (e.g. mxcli fmt). + if fi, statErr := os.Stdout.Stat(); statErr == nil && (fi.Mode()&os.ModeCharDevice) == 0 { + exec.SetQuiet(true) + } + + // The one-liner's language (freeze decision 6): mdl 1 unless + // --mdl 0, or a header the commands state themselves. It is what + // headerless input is read as and what describe writes. + lang := mdlFlag(cmd) + if v, written := langver.ScanWrittenHeader(commands); written { + lang = v + } + exec.SetDescribeLanguage(lang) + + // Auto-connect if project specified. CONNECT runs on its own so + // that a header in the commands stays their first statement. + if projectPath != "" { + connectProg, _ := visitor.Build(fmt.Sprintf("CONNECT LOCAL '%s';", visitor.QuoteString(projectPath))) + if err := exec.ExecuteProgram(connectProg); err != nil { + fmt.Fprintf(errOut, "Error: %v\n", err) + return 1 + } + } + + prog, errs := visitor.BuildSession(commands, lang) + if len(errs) > 0 { + for _, err := range errs { + fmt.Fprintf(errOut, "Parse error: %v\n", err) + } + return 1 + } + + if continueOnError { + res, err := exec.ExecuteProgramContinueOnError(prog, errOut) + if err != nil && !errors.Is(err, executor.ErrExit) { + fmt.Fprintf(errOut, "Error: %v\n", err) + return 1 + } + if res.Failed > 0 { + fmt.Fprintf(errOut, "%d statements: %d succeeded, %d failed\n", res.Total, res.Succeeded, res.Failed) + return 1 + } + return 0 + } + + stoppedAt, err := exec.ExecuteProgramReportingStop(prog) + if err == nil || errors.Is(err, executor.ErrExit) { + return 0 + } + total := len(prog.Statements) + if stoppedAt >= 0 && total > 1 { + fmt.Fprintf(errOut, "Error: statement %d of %d: %v\n", stoppedAt+1, total, err) + if skipped := total - stoppedAt - 1; skipped > 0 { + fmt.Fprintf(errOut, "Stopped: %d later statement(s) not run. --continue-on-error runs every statement.\n", skipped) + } + return 1 + } + fmt.Fprintf(errOut, "Error: %v\n", err) + return 1 +} diff --git a/cmd/mxcli/oneliner_test.go b/cmd/mxcli/oneliner_test.go new file mode 100644 index 000000000..967c66f46 --- /dev/null +++ b/cmd/mxcli/oneliner_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "strings" + "testing" +) + +// runOneLinerForTest runs `mxcli -c ` without a project and returns +// the exit code and everything written to the error stream. +func runOneLinerForTest(t *testing.T, commands string, continueOnError bool) (int, string, string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) // the session log is not the developer's + var errOut bytes.Buffer + var code int + out, _ := captureStdout(t, func() error { + code = runCommandLine(rootCmd, commands, "", continueOnError, &errOut) + return nil + }) + return code, out, errOut.String() +} + +// mendixlabs/mxcli#1218 (1): `-c ""` fell through to the interactive REPL, +// which hangs a generator that spawned mxcli with an open stdin. Empty or +// whitespace-only input is an error, and nothing waits on stdin. +func TestOneLinerEmptyIsAnError(t *testing.T) { + for _, c := range []string{"", " ", "\n\t"} { + code, _, errOut := runOneLinerForTest(t, c, false) + if code == 0 { + t.Errorf("-c %q: exit 0, want an error", c) + } + if !strings.Contains(errOut, "-c was given no MDL") { + t.Errorf("-c %q: error output %q does not say the command was empty", c, errOut) + } + } +} + +// mendixlabs/mxcli#1218 (2): a failing statement in a ;-separated -c stopped +// the run with nothing saying the later statements were skipped. The default +// stays fail-fast (as exec) but the stop is reported with the statement's +// position; --continue-on-error runs the rest. +// +// No project: `describe entity` fails (not connected) while `show features for +// version` needs none, so statement 3 is observable. +func TestOneLinerFailureSemantics(t *testing.T) { + const batch = "show features for version 10.0; describe entity String; show features for version 11.0;" + + code, out, errOut := runOneLinerForTest(t, batch, false) + if code == 0 { + t.Fatalf("exit 0 with a failing statement; stderr %q", errOut) + } + if !strings.Contains(out, "Features for Mendix 10.0") { + t.Errorf("statement 1 did not run (control): %q", out) + } + if strings.Contains(out, "Features for Mendix 11.0") { + t.Errorf("fail-fast ran statement 3 after statement 2 failed") + } + for _, want := range []string{"statement 2 of 3", "1 later statement(s) not run", "--continue-on-error"} { + if !strings.Contains(errOut, want) { + t.Errorf("stderr %q does not contain %q", errOut, want) + } + } + + code, out, errOut = runOneLinerForTest(t, batch, true) + if code == 0 { + t.Errorf("--continue-on-error: exit 0 although statement 2 failed") + } + if !strings.Contains(out, "Features for Mendix 11.0") { + t.Errorf("--continue-on-error did not run statement 3: %q", out) + } + if !strings.Contains(errOut, "statement 2:") || !strings.Contains(errOut, "1 failed") { + t.Errorf("--continue-on-error stderr %q does not report statement 2 and the tally", errOut) + } +} diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 85f10b8ee..34a8f639a 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -541,6 +541,7 @@ Cross-reference commands require `REFRESH CATALOG FULL` to populate reference da |---------|--------|-------| | Interactive REPL | `mxcli` | Interactive MDL shell | | Inline commands | `mxcli -p app.mpr -c "CMD1; CMD2; CMD3"` | Semicolons batch multiple commands | +| Inline, keep going | `mxcli -p app.mpr -c "CMD1; CMD2" --continue-on-error` | Without it a batch stops at the first failing statement and says how many were not run; an empty `-c` is an error | | Execute script | `mxcli exec script.mdl -p app.mpr` | Script file | | Stdin piping | `echo "CMD" \| mxcli -p app.mpr` | Quiet mode, pipe-friendly | | Check syntax | `mxcli check script.mdl` | Parse-only validation | diff --git a/docs-site/src/tutorial/opening-project.md b/docs-site/src/tutorial/opening-project.md index e7e3bdcf5..6c7469803 100644 --- a/docs-site/src/tutorial/opening-project.md +++ b/docs-site/src/tutorial/opening-project.md @@ -14,6 +14,8 @@ mxcli -p /path/to/app.mpr -c "LIST MODULES" This opens the project in read-only mode, runs the command, and exits. The `-p` flag works with all mxcli subcommands. +`-c` takes several statements separated by `;`. Like `mxcli exec`, it stops at the first statement that fails, prints `statement 2 of 3: …` and how many later statements were not run, and exits non-zero; add `--continue-on-error` to run every statement and report each failure. An empty `-c ""` is an error: it never opens the interactive REPL, so a script that generates an empty command list cannot hang waiting on input. + ## Opening in the REPL You can also open a project from inside the interactive REPL: diff --git a/mdl/executor/describe_unqualified_name_test.go b/mdl/executor/describe_unqualified_name_test.go new file mode 100644 index 000000000..d14feae47 --- /dev/null +++ b/mdl/executor/describe_unqualified_name_test.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1218 (3): `describe entity String` failed with "module name +// is required: objects must be created within a module" — the create path's +// message, which reads as if describe were creating something. It now names +// the unqualified name. +func TestDescribeUnqualifiedEntityNamesTheName(t *testing.T) { + for _, typ := range []ast.DescribeObjectType{ast.DescribeEntity, ast.DescribeAssociation} { + ctx, _ := newMockCtx(t) + err := execDescribe(ctx, &ast.DescribeStmt{ObjectType: typ, Name: ast.QualifiedName{Name: "String"}}) + if err == nil { + t.Fatalf("%v: no error for an unqualified name", typ) + } + msg := err.Error() + if strings.Contains(msg, "created") { + t.Errorf("%v: %q still speaks of creating objects", typ, msg) + } + if !strings.Contains(msg, `"String" is not a qualified`) { + t.Errorf("%v: %q does not name the unqualified name", typ, msg) + } + } +} diff --git a/mdl/executor/executor.go b/mdl/executor/executor.go index 19fd3c4ec..abafb70f7 100644 --- a/mdl/executor/executor.go +++ b/mdl/executor/executor.go @@ -376,7 +376,20 @@ func (e *Executor) Execute(stmt ast.Statement) error { } // ExecuteProgram runs all statements in a program. -func (e *Executor) ExecuteProgram(prog *ast.Program) (err error) { +func (e *Executor) ExecuteProgram(prog *ast.Program) error { + _, err := e.ExecuteProgramReportingStop(prog) + return err +} + +// ExecuteProgramReportingStop is ExecuteProgram that also says where it +// stopped: the 0-based index of the statement whose error ended the run, or +// -1 when no statement failed (an error from the run's end-of-program work — +// the access-rule flush, reconciliation — is not one statement's). A caller +// running several statements uses it to say which one failed and how many +// were not run, rather than leaving the rest silently skipped +// (mendixlabs/mxcli#1218). +func (e *Executor) ExecuteProgramReportingStop(prog *ast.Program) (stoppedAt int, err error) { + stoppedAt = -1 if e.beginTally() { defer e.flushTally() } @@ -401,19 +414,19 @@ func (e *Executor) ExecuteProgram(prog *ast.Program) (err error) { } }() - for _, stmt := range prog.Statements { + for i, stmt := range prog.Statements { if err := rules.step(e, stmt); err != nil { - return err + return -1, err } if err := e.Execute(stmt); err != nil { - return annotateForwardRef(err, stmt, created, allDefined) + return i, annotateForwardRef(err, stmt, created, allDefined) } created.collectSingle(stmt) } if err := rules.end(); err != nil { - return err + return -1, err } - return e.finalizeProgramExecution() + return -1, e.finalizeProgramExecution() } // enterLanguage runs the following statements under a program's language diff --git a/mdl/executor/executor_query.go b/mdl/executor/executor_query.go index 57e13c1af..a0352f458 100644 --- a/mdl/executor/executor_query.go +++ b/mdl/executor/executor_query.go @@ -201,6 +201,15 @@ func execDescribe(ctx *ExecContext, s *ast.DescribeStmt) error { s.ObjectType = resolved } + // An entity or association is only ever found by its module. Without one + // the lookup failed with the create-path message "objects must be created + // within a module", which reads as if describe were creating something + // (mendixlabs/mxcli#1218). + if (s.ObjectType == ast.DescribeEntity || s.ObjectType == ast.DescribeAssociation) && s.Name.Module == "" { + label := describeObjectTypeLabel(s.ObjectType) + return mdlerrors.NewValidation(fmt.Sprintf("%q is not a qualified %s name: write Module.%s", s.Name.Name, label, s.Name.Name)) + } + // Determine the object type label and name for JSON wrapping. objectType := describeObjectTypeLabel(s.ObjectType) name := s.Name.String() From 2fcf7907f5eb9a989c884b8eb6ef5061c415a1ed Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:04:25 +0000 Subject: [PATCH 08/23] fix(describe): enumeration captions and folder escape apostrophes (mendixlabs/mxcli#394) describe enumeration wrote 'It's a test', which does not re-parse. Caption and folder now go through mdlQuote. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + mdl/executor/cmd_enumerations.go | 6 +- .../cmd_enumerations_caption_quote_test.go | 73 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 mdl/executor/cmd_enumerations_caption_quote_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index db4ac1363..5327ad7c2 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -791,3 +791,4 @@ {"date": "2026-10-01", "area": "mdl/executor", "symptom": "ako/mxcli#890 (rehearsal 2, R-rep): re-running a settled script wrote nothing (units_written=0) but still printed \"Granted access on ...\", \"Set project security level to ...\", \"Added module roles ... to user role ...\", \"Updated ... settings\" / \"Updated configuration ...\", and `move ... to folder` printed \"Moved ... to new location\"; the output could not serve as the #859 'second run reports Unchanged' gate. Also: a second `move` of the same document in one session failed \"microflow not found\".", "cause": "Those handlers printed their sentence with fmt.Fprintf after the backend call instead of going through ReportMutation's write-elision evidence (WriteStats offered vs written). Grants/revokes inside a program run are deferred (#872 accessRuleRun), so even ReportMutation could not see their write at statement time. The typed movers changed a container without invalidating the cached hierarchy.", "file": "mdl/executor/report_mutation.go, mdl/executor/access_rule_run.go, mdl/executor/cmd_security_write.go, mdl/executor/cmd_settings.go, mdl/executor/cmd_move.go", "fix": "ExecContext.reportWrite(unchanged, sentence...) prints the sentence or `Unchanged ` (through the run tally) on the ReportMutation evidence rule; used for project security level/demo users/strict mode/guest access, alter user role module roles, settings section/configuration/constant updates. Access-rule reports go through reportAccessRule: held on the open accessRuleRun and printed at its flush, Unchanged when the flush offered and elided (notices like 'No access rules found' print regardless). execMove short-circuits a document already in the target container (alreadyPlaced -> Unchanged) and invalidates the hierarchy after every move.", "test": "mdl/executor/noop_reporting_pedapp_test.go TestNoopRerun_ReportsUnchanged (PedApp, per statement: run 1 reports its write = control; run 2 writes no file and reports Unchanged, for grant, security level, demo users, strict mode, user role module roles, settings runtime, configuration (alter and create or modify), move) and TestNoopRerun_ProgramReportsUnchanged (program run incl. a revoke+grant reset; run 2 writes nothing and reports no write verb; run 1's net-nothing reset reports no write). Revert check: every case fails with the write sentence on run 2; the move case with 'microflow not found'.", "insight": "A report printed after a backend call is a claim about storage the handler cannot make on its own; route every write report through the write-stats evidence, and where writes are deferred, defer the report with them. The output only becomes an idempotency gate when no statement prints a write verb by construction."} {"date": "2026-10-01", "area": "mdl/executor", "symptom": "ako/mxcli#840 (mxcli-ledger, finding 162): `mxcli describe` (no header, no option) wrote mdl 0 spellings that mdl 1 refuses, and its microflow output held `$N = count($Hits)` / `$x = find($L, …)` — call forms registered as deprecated (MDL-DEPR003/004), so subcommand output was neither mdl 1-runnable nor mdl 0-clean. At the freeze a second instance surfaced on TestApp: describe of a chart series' text template wrote `staticTooltipHoverTextParams: [{1} = X]`, the bracketed form MDL-DEPR124 deprecates, under both versions.", "cause": "formatListOperation / the AggregateListAction case gated the statement form on describeLanguage >= V1 and fell back to the call form for mdl 0, although the statement form parses with the same meaning and no warning under mdl 0. The object-list describer (cmd_pages_describe_objectlist.go) wrote a TextTemplate's Params as \"[\" + … + \"]\". The roundtrip test that should have caught both (describeUsesCanonicalSpellings) filtered deprecations to an R8 allowlist ('describe keeps them under mdl 0'), so any code outside the list was invisible.", "file": "mdl/executor/cmd_microflows_format_action.go, mdl/executor/cmd_pages_describe_objectlist.go, mdl/roundtrip/describe_canonical_spelling_test.go", "fix": "Describe writes the List operation / Aggregate list statement in every language; only an activity the statement cannot express falls back to the call. Object-list template parameters are written in ( ). The canonical-spelling roundtrip test now checks EVERY registered deprecation under both describe languages (describeAs V1 and V0) on PedApp and TestApp, with no code filter. Freeze: langver.Frozen = V1, every describe output starts with `mdl 1;`, `--mdl 0|1` on describe/context/diff-local.", "test": "mdl/executor/cmd_microflows_format_list_activity_test.go TestDescribeListActivityUnderMdl1 (both versions, plus the call-form control warning under mdl 0); mdl/roundtrip/describe_canonical_spelling_test.go TestTestAppDescribeUsesCanonicalSpellings (failed on Snip_TaskDashboard_Numbers & 2 more with MDL-DEPR124 before the objectlist fix).", "insight": "A test that filters warnings to a list of 'codes this test owns' hides every code added later; a 'never emits X' property must check the whole registry, and in every output language the command can be asked for."} {"date": "2026-10-01", "area": "mdl/executor", "symptom": "ako/mxcli#905 part 1 (#897 item 4, rehearsal 3 G2): under `mdl 1`, `create or modify microflow` that grows a stored flow by an activity whose custom error handler ends in its own `return` (`$Ok = call microflow … on error begin log …; return; end error;`) was refused on every run, as an insert or a replace: \"an error handler in the fragment ends at an end event of its own … a return inside an error handler is not spliced yet\". Created fresh the statement worked; mdl 0 rebuilt (MDL-V1-REBUILD). On CapTrack the stub-then-real pair (13-actions stub, 30-export real ACT_Export_Excel) left the placeholder stored on every run with mx check clean. Once spliced, the second run of the real CapTrack script was refused: \"replace $Written: cannot replace the ActionActivity …: it has an error handler\".", "cause": "addErrorHandlerFlow (cmd_microflows_builder_flows.go) builds a handler body with a child flowBuilder and merged its objects and flows into the parent, but not its returnEndIDs, so cutFragment saw the handler's return end event as one the builder added and refused it. Second defect, exposed by the grow: a log/show message/validation feedback message written as an expression (`'failed for ' + $User/Name`) is stored as template '{1}' with the expression as parameter, and describe prints that form; declaredMatches compared the two spellings as different statements. Where builtAsStored is false (a spliced or Studio Pro-drawn flow), the statement diff then replaced the activity: absorbed by write elision on the main path, but refused when the message sits in a stored activity's error handler.", "file": "mdl/executor/cmd_microflows_builder_flows.go, mdl/executor/flow_declared_match.go", "fix": "addErrorHandlerFlow copies errBuilder.returnEndIDs into the parent's (lastReturnEndID untouched), so a handler's return is a new end event of the flow like a guard's (#888); placement/room checks (checkRoom/checkBranches) apply unchanged and refuse where the handler's return branch would cross a stored flow. matchValue normalises LogStmt/ShowMessageStmt/ValidationFeedbackStmt (messageAsTemplate) to the builder's stored form: a non-literal message becomes '{1}' with the expression as first parameter (a log stating its own `with (...)` keeps its message, as the builder does).", "test": "mdl/executor/cmd_alter_flow_handler_return_test.go TestCutFragment_HandlerReturnIsANewEndEvent; mdl/executor/flow_message_respelling_test.go (with controls); mdl/roundtrip/flow_splice_handler_return_test.go TestSpliceRerun_GrowByHandlerReturn (replace/insert under mdl 1, insert under mdl 0, verdict agreement check/diff/exec, twice-exec, a changed-flow control), TestSpliceRerun_GrowStudioProFlowByHandlerReturn (PedApp ShowPasswordForm, all stored IDs kept, description fixed point), TestSpliceRerun_HandlerReturnWithNoRoomIsRefused. Revert checks: returnEndIDs not copied -> every grow refused 'not spliced yet' (check predicts it); messageAsTemplate off -> second run refused 'replace $Ok … it has an error handler'. CapTrack copy: fmt --upgrade -p 13+30, exec 13, 30, 30 -> real flow stored, third run 0 units; mx check identical to baseline (0 errors). PedApp repros mx check identical to baseline.", "insight": "Child builders (error handler, loop) each keep builder state the parent's consumers rely on; when a new piece of builder state is added for the splice (returnEndIDs, #888), enumerate the child builders and decide per child whether it propagates. A grow test that only re-runs on an mxcli-authored flow can pass because builtAsStored short-circuits the statement diff; the respelling only surfaced on the real project and the Studio Pro-drawn flow, where the statement diff decides."} +{"date": "2026-10-01", "area": "mdl/executor", "symptom": "`describe enumeration` writes a caption with an apostrophe as `V1 'It's a test'`, which does not re-parse (`the statement ending at '' has no terminating ;`); a folder path with an apostrophe the same", "cause": "describeEnumeration hand-rolled `'%s'` for the caption and the folder clause instead of mdlQuote; cmd_enumerations.go was not in the list scanned by TestDescribers_HaveNoHandRolledStringLiterals because the file also holds many prose error messages containing `'%s'`", "fix": "Caption and folder go through mdlQuote(ctx, …), so the escape follows the describe language (mdl 1 doubles, mdl 0 also escapes backslash/line break)", "insight": "The #1006 source-scan guard only covers the files it lists; a describer living in a file dominated by validation prose escaped it. A re-parse test on the emitted statement, under both describe languages, is the guard that does not depend on a file list. Twice-exec checked on a PedApp copy: describe -> exec reports Unchanged and touches no file", "issue": "mendixlabs/mxcli#394", "file": "mdl/executor/cmd_enumerations.go (describeEnumeration)", "test": "mdl/executor/cmd_enumerations_caption_quote_test.go"} diff --git a/mdl/executor/cmd_enumerations.go b/mdl/executor/cmd_enumerations.go index f01068889..58b68da66 100644 --- a/mdl/executor/cmd_enumerations.go +++ b/mdl/executor/cmd_enumerations.go @@ -447,14 +447,16 @@ func describeEnumeration(ctx *ExecContext, name ast.QualifiedName) error { comma = "" } caption := pickTextTranslation(v.Caption, lang) - fmt.Fprintf(ctx.Output, " %s '%s'%s\n", v.Name, caption, comma) + // mdlQuote doubles an apostrophe (mendixlabs/mxcli#394): a + // caption like "Won't fix" must re-parse. + fmt.Fprintf(ctx.Output, " %s %s%s\n", v.Name, mdlQuote(ctx, caption), comma) } // Emit the module folder so a moved enumeration round-trips (Bug 12b). // BuildFolderPath returns the module name at the module root and the // folder path when the enum lives in a folder. folderClause := "" if fp := h.BuildFolderPath(enum.ContainerID); fp != "" && fp != modName { - folderClause = fmt.Sprintf(" folder '%s'", fp) + folderClause = " folder " + mdlQuote(ctx, fp) } fmt.Fprintf(ctx.Output, ")%s;\n", folderClause) return nil diff --git a/mdl/executor/cmd_enumerations_caption_quote_test.go b/mdl/executor/cmd_enumerations_caption_quote_test.go new file mode 100644 index 000000000..d469a4898 --- /dev/null +++ b/mdl/executor/cmd_enumerations_caption_quote_test.go @@ -0,0 +1,73 @@ +// 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/langver" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#394: DESCRIBE ENUMERATION wrote a caption with an apostrophe +// as `'It's a test'`, which does not re-parse — the round trip of any caption a +// person would type ("Won't fix") was broken. The output must re-parse, under +// both describe languages, to the same caption; a folder path with an +// apostrophe is the same emit. +func TestDescribeEnumeration_CaptionApostropheRoundTrips(t *testing.T) { + const caption = "It's a test" + for _, lang := range []langver.Version{langver.V0, langver.V1} { + mod := mkModule("MyFirstModule") + folderID := nextID("folder") + enum := &model.Enumeration{ + BaseElement: model.BaseElement{ID: nextID("enum")}, + ContainerID: folderID, + Name: "TestEnum", + Values: []model.EnumerationValue{ + {BaseElement: model.BaseElement{ID: nextID("ev")}, Name: "Value1", + Caption: &model.Text{Translations: map[string]string{"en_US": caption}}}, + {BaseElement: model.BaseElement{ID: nextID("ev")}, Name: "Value2", + Caption: &model.Text{Translations: map[string]string{"en_US": "plain"}}}, + }, + } + h := mkHierarchy(mod) + withContainer(h, folderID, mod.ID) + h.folderNames[folderID] = "Bob's" + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return []*model.Enumeration{enum}, nil }, + } + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + ctx.describeLang = &lang + assertNoError(t, describeEnumeration(ctx, ast.QualifiedName{Module: "MyFirstModule", Name: "TestEnum"})) + out := buf.String() + + src := out + if lang >= langver.V1 { + src = "mdl 1;\n" + out + } + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("%v: describe output does not re-parse: %v\n%s", lang, errs, out) + } + var stmt *ast.CreateEnumerationStmt + for _, s := range prog.Statements { + if c, ok := s.(*ast.CreateEnumerationStmt); ok { + stmt = c + } + } + if stmt == nil || len(stmt.Values) != 2 { + t.Fatalf("%v: re-parsed to %#v\n%s", lang, prog.Statements, out) + } + if stmt.Values[0].Caption != caption { + t.Errorf("%v: caption re-parses as %q, want %q\n%s", lang, stmt.Values[0].Caption, caption, out) + } + if !strings.Contains(out, "Bob''s") { + t.Errorf("%v: folder path apostrophe not doubled:\n%s", lang, out) + } + } +} From dcb324b46a9ffab1415db5b8c8b8f356ec4ec9fc Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:06:32 +0000 Subject: [PATCH 09/23] fix(lsp): a non-file document no longer panics the server (mendixlabs/mxcli#1245) uri.URI.Filename panics on any scheme but file, and the VS Code extension opens describe previews as mendix-mdl: documents: the server crashed on every didOpen. documentPath reads the path component of a non-file URI; the on-disk semantic check skips virtual documents. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + cmd/mxcli/lsp_diagnostics.go | 8 +++- cmd/mxcli/lsp_helpers.go | 28 +++++++++++ cmd/mxcli/lsp_language.go | 2 +- cmd/mxcli/lsp_virtual_uri_test.go | 46 +++++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 cmd/mxcli/lsp_virtual_uri_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index cbceaf90f..5033f17a6 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -138,3 +138,4 @@ {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli version` fails with `unknown command \"version\" for \"mxcli\"` while `mxcli --version` works", "cause": "cobra's `Version:` field registers only the --version flag; no subcommand existed, though shouldSuppressWarning already listed a `version` argument", "fix": "Add a `version` subcommand (cmd/mxcli/cmd_version.go) printing exactly cobra's --version text (name, version, build time)", "insight": "Code that special-cases an argument (warning suppression) is evidence of an intended command; a test pinning the subcommand output to the flag output keeps the two from drifting", "issue": "ako/mxcli#534", "file": "cmd/mxcli/cmd_version.go", "test": "cmd/mxcli/cmd_version_test.go"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli check`/`exec`/`fmt`/`diff` fail on a script saved by Windows PowerShell 5.1: a UTF-8 BOM gives `line 1:0 token recognition error at: '\\ufeff'` (an invisible character), UTF-16LE gives a token error on almost every character; the same through stdin and in .test.mdl files", "cause": "Every script reader passed the raw file bytes to the lexer, which reads UTF-8 without a BOM; there was no shared reader (fmt, diff, the multi-file check pass, the test runner and EXECUTE SCRIPT each called os.ReadFile on their own)", "fix": "New mdl/srctext.Decode (strip a leading UTF-8 BOM, decode UTF-16LE/BE by BOM); readMDLSource calls it and fmt, diff and parseScriptSet now read through readMDLSource; testrunner.ParseTestFile and EXECUTE SCRIPT call it directly", "insight": "A BOM also hides a `mdl 1;` header from langver.ScanWrittenHeader, so stripping it in the parser alone would have left the language version wrong: decode where the bytes are read, before anything inspects the text. Enumerate the readers (grep os.ReadFile / io.ReadAll(os.Stdin)), not just the one the report names", "issue": "mendixlabs/mxcli#1253", "file": "mdl/srctext/srctext.go; cmd/mxcli/mdlsource.go", "test": "mdl/srctext/srctext_test.go; cmd/mxcli/mdlsource_encoding_test.go"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli -p App.mpr -c \"\"` opens the interactive REPL (a generator spawning mxcli with an open stdin hangs at `mdl>`); `-c \"describe entity System.User; describe entity String; describe entity System.FileDocument\"` stops at statement 2 with `module name is required: objects must be created within a module` and the third statement is silently never run", "cause": "Root Run tested `commands != \"\"` to choose -c over the REPL, so an empty flag value was indistinguishable from no flag; the -c path used ExecuteProgram, which returns the first error without its position, and describe entity/association reached findModule(\"\"), whose message is written for the create path", "fix": "`cmd.Flags().Changed(\"command\")` selects the one-liner path; runCommandLine (cmd/mxcli/oneliner.go) refuses empty input, reports `statement N of M` and how many later statements were not run (via new Executor.ExecuteProgramReportingStop), and takes --continue-on-error like exec; execDescribe names an unqualified entity/association name", "insight": "A flag's zero value is not its absence: use Changed() whenever an empty value must mean something other than not given. Decided semantics: -c is fail-fast like exec (a later statement may depend on an earlier one), but a stop is never silent", "issue": "mendixlabs/mxcli#1218", "file": "cmd/mxcli/oneliner.go; cmd/mxcli/main.go; mdl/executor/executor.go; mdl/executor/executor_query.go", "test": "cmd/mxcli/oneliner_test.go; mdl/executor/describe_unqualified_name_test.go"} +{"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli lsp --stdio` exits rc=2 with `panic: only file URIs are supported, got mendix-mdl` on textDocument/didOpen of a `mendix-mdl:` virtual document (the VS Code extension's describe previews); VS Code restarts it, it crashes again, and after 5 crashes it stops restarting the server", "cause": "checkableDocument (diagnostics) and CodeAction called go.lsp.dev/uri URI.Filename(), which panics on any scheme but file, to decide whether the document is a .test.mdl", "fix": "documentPath(uri) returns Filename() only for file: URIs and the URI's path component otherwise; runSemanticCheck (which shells out `mxcli check `) skips non-file documents; virtual documents are still diagnosed in memory", "insight": "Third-party helpers that panic on unexpected input are a crash path in a long-running server; every URI an LSP client sends is untrusted shape. A test with a non-file URI plus a file-URI control (same diagnostics) proves the virtual case is handled, not skipped", "issue": "mendixlabs/mxcli#1245", "file": "cmd/mxcli/lsp_helpers.go (documentPath, isFileURI); cmd/mxcli/lsp_diagnostics.go; cmd/mxcli/lsp_language.go", "test": "cmd/mxcli/lsp_virtual_uri_test.go"} diff --git a/cmd/mxcli/lsp_diagnostics.go b/cmd/mxcli/lsp_diagnostics.go index 684fe6695..8ad4c3723 100644 --- a/cmd/mxcli/lsp_diagnostics.go +++ b/cmd/mxcli/lsp_diagnostics.go @@ -29,7 +29,7 @@ var errLineRegexp = regexp.MustCompile(`^line (\d+):(\d+) (.+)$`) // (mendixlabs/mxcli#1103). A test block is a microflow body; testrunner renders // it as one, on the same lines, so the positions below need no adjustment. func checkableDocument(docURI uri.URI, text string) (string, []protocol.Diagnostic) { - path := docURI.Filename() + path := documentPath(docURI) if !testrunner.IsTestFile(path) { return text, nil } @@ -196,7 +196,11 @@ func (s *mdlServer) runSemanticCheck(ctx context.Context, docURI uri.URI, text s return } - // Write to a temp file path based on the URI + // The check reads the document from disk; a virtual document + // (mendix-mdl:, mendixlabs/mxcli#1245) has no file to read. + if !isFileURI(docURI) { + return + } filePath := uriToPath(string(docURI)) if filePath == "" { return diff --git a/cmd/mxcli/lsp_helpers.go b/cmd/mxcli/lsp_helpers.go index 13751a9e4..df20b3fe4 100644 --- a/cmd/mxcli/lsp_helpers.go +++ b/cmd/mxcli/lsp_helpers.go @@ -7,6 +7,8 @@ import ( "context" "encoding/json" "fmt" + "go.lsp.dev/uri" + "net/url" "os" "os/exec" "path/filepath" @@ -220,3 +222,29 @@ func parseTableOutput(output string) []projectElement { } return elems } + +// documentPath is the path a document's URI names, for deciding what kind of +// document it is (a `.test.mdl` file, say). uri.URI.Filename panics on any +// scheme but file, and the VS Code extension opens describe previews as +// `mendix-mdl:` virtual documents, so calling it directly took the server down +// on every didOpen (mendixlabs/mxcli#1245). A non-file URI yields its path +// component; it is a name, not a file on disk. +func documentPath(docURI uri.URI) string { + if isFileURI(docURI) { + return docURI.Filename() + } + if u, err := url.Parse(string(docURI)); err == nil { + if u.Path != "" { + return u.Path + } + return u.Opaque + } + return "" +} + +// isFileURI reports whether a document lives on disk. Only such a document can +// be handed to `mxcli check` by path. +func isFileURI(docURI uri.URI) bool { + u, err := url.Parse(string(docURI)) + return err == nil && u.Scheme == uri.FileScheme +} diff --git a/cmd/mxcli/lsp_language.go b/cmd/mxcli/lsp_language.go index ed37703eb..72a06082d 100644 --- a/cmd/mxcli/lsp_language.go +++ b/cmd/mxcli/lsp_language.go @@ -192,7 +192,7 @@ func (s *mdlServer) migrationHover(docURI uri.URI, text string, pos protocol.Pos // `fmt --upgrade` is offered too. func (s *mdlServer) CodeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CodeAction, error) { docURI := uri.URI(params.TextDocument.URI) - if testrunner.IsTestFile(docURI.Filename()) { + if testrunner.IsTestFile(documentPath(docURI)) { return nil, nil // diagnosed as the MDL rendered from it, not as written } s.mu.Lock() diff --git a/cmd/mxcli/lsp_virtual_uri_test.go b/cmd/mxcli/lsp_virtual_uri_test.go new file mode 100644 index 000000000..f2fddad2f --- /dev/null +++ b/cmd/mxcli/lsp_virtual_uri_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +// mendixlabs/mxcli#1245: the VS Code extension opens describe previews as +// `mendix-mdl:` virtual documents, and uri.URI.Filename panics on any scheme +// but file — "panic: only file URIs are supported, got mendix-mdl". The server +// exited on every didOpen until VS Code gave up restarting it. A virtual +// document is diagnosed in memory like any other; the file control proves the +// same text yields the same diagnostics, so the virtual case is not passing by +// being skipped. +func TestLSPVirtualDocumentDoesNotPanic(t *testing.T) { + const virtual = uri.URI("mendix-mdl:/MyModule/Foo.mdl") + s := newMDLServer(nil) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("a %s document panicked the server: %v", virtual, r) + } + }() + + got := s.documentDiagnostics(virtual, replaceEntity) + want := s.documentDiagnostics(lspTestURI, replaceEntity) + if len(diagsWithCode(got, "MDL-DEPR001")) != 1 || len(got) != len(want) { + t.Errorf("virtual document diagnostics %+v, file control %+v", got, want) + } + + if _, err := s.CodeAction(context.Background(), &protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(virtual)}, + }); err != nil { + t.Errorf("CodeAction on a virtual document: %v", err) + } + + // A virtual .test.mdl is still recognised as a test file by its name. + if p := documentPath(uri.URI("mendix-mdl:/MyModule/Foo.test.mdl")); p != "/MyModule/Foo.test.mdl" { + t.Errorf("documentPath = %q", p) + } +} From 9e6d3feb54308e73d2aadda67012421feb628c99 Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:23:06 +0000 Subject: [PATCH 10/23] fix(check): check -p predicts exec's refusals by calling exec's decision (#906, #558, #563) Seven places where check -p passed a statement exec then refused part-way (or both accepted a write that broke the project). Each prediction now calls the decision exec makes instead of restating it: - demo user that exists: into the create registry (stmtCreateKind/setFor) - jar dependency that exists: alter module jar actions replayed through applyJarDepAction on a copy of the stored settings, in script order - translations into the source language / plain create of a language with translations: translationsRefusal, shared with execCreateTranslations - task queue created earlier in the script satisfies `in queue` (mendixlabs/mxcli#1211); one created later is MDL-ORDER01 or, for a later create or modify, reported against the project (validateForwardDefRefs) - page/snippet widget naming a microflow/nanoflow created later: MDL-ORDER01 via eagerDefRefs (mendixlabs/mxcli#1212) - variable passed to a Microflow-typed Java action parameter: microflowParamArgRefusal, used by the flow builder and the reference pass (mendixlabs/mxcli#1210, unloadable project: refused under both versions) - referenceselector: MDL-WIDGET38 from formsWidgetsWithoutWriter, which the builder's fall-through uses too (no more `widget init` hint) - retrieve constraints (mendixlabs/mxcli#1213): MDL047 also matches `!= empty`, MDL091 startsWith()/endsWith(), unknown bare members and CreatedDate-for-createdDate are reference errors. Each measured CE0161 on mxbuild 11.13.0 against a clean control. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + .../skills/mendix/xpath-constraints/SKILL.md | 17 +- CHANGELOG.md | 1 + docs-site/src/language/widget-types.md | 10 +- .../check_exec_agreement_pedapp_test.go | 275 ++++++++++++++++++ mdl/executor/cmd_microflows_builder_calls.go | 6 + mdl/executor/cmd_pages_builder_v3.go | 6 + mdl/executor/cmd_translations.go | 21 +- mdl/executor/javaaction_flow_param.go | 74 +++++ mdl/executor/validate.go | 52 +++- mdl/executor/validate_create_shape.go | 4 + mdl/executor/validate_definition_order.go | 149 ++++++++++ mdl/executor/validate_duplicates.go | 26 ++ mdl/executor/validate_exec_refusals.go | 178 ++++++++++++ mdl/executor/validate_microflow.go | 112 +++++-- mdl/executor/validate_retrieve_members.go | 101 +++++++ mdl/executor/validate_widget_member_refs.go | 38 ++- mdl/executor/validate_widget_unbuildable.go | 72 +++++ .../validate_widget_unbuildable_test.go | 79 +++++ mdl/executor/validate_widgets.go | 10 +- 20 files changed, 1167 insertions(+), 65 deletions(-) create mode 100644 mdl/executor/check_exec_agreement_pedapp_test.go create mode 100644 mdl/executor/javaaction_flow_param.go create mode 100644 mdl/executor/validate_exec_refusals.go create mode 100644 mdl/executor/validate_retrieve_members.go create mode 100644 mdl/executor/validate_widget_unbuildable.go create mode 100644 mdl/executor/validate_widget_unbuildable_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index db4ac1363..598810380 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -791,3 +791,4 @@ {"date": "2026-10-01", "area": "mdl/executor", "symptom": "ako/mxcli#890 (rehearsal 2, R-rep): re-running a settled script wrote nothing (units_written=0) but still printed \"Granted access on ...\", \"Set project security level to ...\", \"Added module roles ... to user role ...\", \"Updated ... settings\" / \"Updated configuration ...\", and `move ... to folder` printed \"Moved ... to new location\"; the output could not serve as the #859 'second run reports Unchanged' gate. Also: a second `move` of the same document in one session failed \"microflow not found\".", "cause": "Those handlers printed their sentence with fmt.Fprintf after the backend call instead of going through ReportMutation's write-elision evidence (WriteStats offered vs written). Grants/revokes inside a program run are deferred (#872 accessRuleRun), so even ReportMutation could not see their write at statement time. The typed movers changed a container without invalidating the cached hierarchy.", "file": "mdl/executor/report_mutation.go, mdl/executor/access_rule_run.go, mdl/executor/cmd_security_write.go, mdl/executor/cmd_settings.go, mdl/executor/cmd_move.go", "fix": "ExecContext.reportWrite(unchanged, sentence...) prints the sentence or `Unchanged ` (through the run tally) on the ReportMutation evidence rule; used for project security level/demo users/strict mode/guest access, alter user role module roles, settings section/configuration/constant updates. Access-rule reports go through reportAccessRule: held on the open accessRuleRun and printed at its flush, Unchanged when the flush offered and elided (notices like 'No access rules found' print regardless). execMove short-circuits a document already in the target container (alreadyPlaced -> Unchanged) and invalidates the hierarchy after every move.", "test": "mdl/executor/noop_reporting_pedapp_test.go TestNoopRerun_ReportsUnchanged (PedApp, per statement: run 1 reports its write = control; run 2 writes no file and reports Unchanged, for grant, security level, demo users, strict mode, user role module roles, settings runtime, configuration (alter and create or modify), move) and TestNoopRerun_ProgramReportsUnchanged (program run incl. a revoke+grant reset; run 2 writes nothing and reports no write verb; run 1's net-nothing reset reports no write). Revert check: every case fails with the write sentence on run 2; the move case with 'microflow not found'.", "insight": "A report printed after a backend call is a claim about storage the handler cannot make on its own; route every write report through the write-stats evidence, and where writes are deferred, defer the report with them. The output only becomes an idempotency gate when no statement prints a write verb by construction."} {"date": "2026-10-01", "area": "mdl/executor", "symptom": "ako/mxcli#840 (mxcli-ledger, finding 162): `mxcli describe` (no header, no option) wrote mdl 0 spellings that mdl 1 refuses, and its microflow output held `$N = count($Hits)` / `$x = find($L, …)` — call forms registered as deprecated (MDL-DEPR003/004), so subcommand output was neither mdl 1-runnable nor mdl 0-clean. At the freeze a second instance surfaced on TestApp: describe of a chart series' text template wrote `staticTooltipHoverTextParams: [{1} = X]`, the bracketed form MDL-DEPR124 deprecates, under both versions.", "cause": "formatListOperation / the AggregateListAction case gated the statement form on describeLanguage >= V1 and fell back to the call form for mdl 0, although the statement form parses with the same meaning and no warning under mdl 0. The object-list describer (cmd_pages_describe_objectlist.go) wrote a TextTemplate's Params as \"[\" + … + \"]\". The roundtrip test that should have caught both (describeUsesCanonicalSpellings) filtered deprecations to an R8 allowlist ('describe keeps them under mdl 0'), so any code outside the list was invisible.", "file": "mdl/executor/cmd_microflows_format_action.go, mdl/executor/cmd_pages_describe_objectlist.go, mdl/roundtrip/describe_canonical_spelling_test.go", "fix": "Describe writes the List operation / Aggregate list statement in every language; only an activity the statement cannot express falls back to the call. Object-list template parameters are written in ( ). The canonical-spelling roundtrip test now checks EVERY registered deprecation under both describe languages (describeAs V1 and V0) on PedApp and TestApp, with no code filter. Freeze: langver.Frozen = V1, every describe output starts with `mdl 1;`, `--mdl 0|1` on describe/context/diff-local.", "test": "mdl/executor/cmd_microflows_format_list_activity_test.go TestDescribeListActivityUnderMdl1 (both versions, plus the call-form control warning under mdl 0); mdl/roundtrip/describe_canonical_spelling_test.go TestTestAppDescribeUsesCanonicalSpellings (failed on Snip_TaskDashboard_Numbers & 2 more with MDL-DEPR124 before the objectlist fix).", "insight": "A test that filters warnings to a list of 'codes this test owns' hides every code added later; a 'never emits X' property must check the whole registry, and in every output language the command can be asked for."} {"date": "2026-10-01", "area": "mdl/executor", "symptom": "ako/mxcli#905 part 1 (#897 item 4, rehearsal 3 G2): under `mdl 1`, `create or modify microflow` that grows a stored flow by an activity whose custom error handler ends in its own `return` (`$Ok = call microflow … on error begin log …; return; end error;`) was refused on every run, as an insert or a replace: \"an error handler in the fragment ends at an end event of its own … a return inside an error handler is not spliced yet\". Created fresh the statement worked; mdl 0 rebuilt (MDL-V1-REBUILD). On CapTrack the stub-then-real pair (13-actions stub, 30-export real ACT_Export_Excel) left the placeholder stored on every run with mx check clean. Once spliced, the second run of the real CapTrack script was refused: \"replace $Written: cannot replace the ActionActivity …: it has an error handler\".", "cause": "addErrorHandlerFlow (cmd_microflows_builder_flows.go) builds a handler body with a child flowBuilder and merged its objects and flows into the parent, but not its returnEndIDs, so cutFragment saw the handler's return end event as one the builder added and refused it. Second defect, exposed by the grow: a log/show message/validation feedback message written as an expression (`'failed for ' + $User/Name`) is stored as template '{1}' with the expression as parameter, and describe prints that form; declaredMatches compared the two spellings as different statements. Where builtAsStored is false (a spliced or Studio Pro-drawn flow), the statement diff then replaced the activity: absorbed by write elision on the main path, but refused when the message sits in a stored activity's error handler.", "file": "mdl/executor/cmd_microflows_builder_flows.go, mdl/executor/flow_declared_match.go", "fix": "addErrorHandlerFlow copies errBuilder.returnEndIDs into the parent's (lastReturnEndID untouched), so a handler's return is a new end event of the flow like a guard's (#888); placement/room checks (checkRoom/checkBranches) apply unchanged and refuse where the handler's return branch would cross a stored flow. matchValue normalises LogStmt/ShowMessageStmt/ValidationFeedbackStmt (messageAsTemplate) to the builder's stored form: a non-literal message becomes '{1}' with the expression as first parameter (a log stating its own `with (...)` keeps its message, as the builder does).", "test": "mdl/executor/cmd_alter_flow_handler_return_test.go TestCutFragment_HandlerReturnIsANewEndEvent; mdl/executor/flow_message_respelling_test.go (with controls); mdl/roundtrip/flow_splice_handler_return_test.go TestSpliceRerun_GrowByHandlerReturn (replace/insert under mdl 1, insert under mdl 0, verdict agreement check/diff/exec, twice-exec, a changed-flow control), TestSpliceRerun_GrowStudioProFlowByHandlerReturn (PedApp ShowPasswordForm, all stored IDs kept, description fixed point), TestSpliceRerun_HandlerReturnWithNoRoomIsRefused. Revert checks: returnEndIDs not copied -> every grow refused 'not spliced yet' (check predicts it); messageAsTemplate off -> second run refused 'replace $Ok … it has an error handler'. CapTrack copy: fmt --upgrade -p 13+30, exec 13, 30, 30 -> real flow stored, third run 0 units; mx check identical to baseline (0 errors). PedApp repros mx check identical to baseline.", "insight": "Child builders (error handler, loop) each keep builder state the parent's consumers rely on; when a new piece of builder state is added for the splice (returnEndIDs, #888), enumerate the child builders and decide per child whether it propagates. A grow test that only re-runs on an mxcli-authored flow can pass because builtAsStored short-circuits the statement diff; the respelling only surfaced on the real project and the Studio Pro-drawn flow, where the statement diff decides."} +{"date": "2026-10-01", "area": "mdl/executor", "symptom": "check -p passed and exec then refused (part-way, earlier statements written) or both accepted a broken write, in seven places: existing demo user, existing jar dependency, translations into the source language (ako/mxcli#906); a task queue created earlier in the script 'not found' for in queue (mendixlabs/mxcli#1211 = ako/mxcli#558); a page button calling a microflow created later (mendixlabs/mxcli#1212); a variable passed to a Microflow-typed Java action parameter, written as the reference text so the project no longer loaded (mendixlabs/mxcli#1210); referenceselector passing check, refused by the page builder with a misleading 'widget init' hint (ako/mxcli#563); retrieve constraints startsWith()/endsWith(), 'Assoc != empty', unknown bare members and CreatedDate for createdDate, CE0161 at build (mendixlabs/mxcli#1213).", "cause": "Each exec refusal was decided inside its handler (execCreateDemoUser's loop, applyJarDepAction, execCreateTranslations, buildQueueSettings, the page builder's resolveMicroflow / widget switch fall-through, addCallJavaActionAction) and check had no copy or a different copy: stmtCreateKind lacked the demo user, the scriptContext had no queues, eagerDefRefs had no widget flow refs, the whole-script reference pass counts names defined anywhere in the script (so a later create-or-modify looked resolved), MDL047's regex matched '=' only, and the retrieve walk never resolved members (the widget walk did, #1049).", "file": "mdl/executor/validate_exec_refusals.go, validate_duplicates.go, validate.go, validate_definition_order.go, javaaction_flow_param.go, validate_widget_unbuildable.go, validate_retrieve_members.go, validate_microflow.go", "fix": "Route check through exec's decision: demo user into the create registry (stmtCreateKind/setFor/stmtDropInfo); CheckExecRefusals replays alter module jar actions through applyJarDepAction on cloned settings and calls translationsRefusal (shared with exec); sc.queues; widgetFlowDefRefs + queueDefRef in eagerDefRefs (MDL-ORDER01) and validateForwardDefRefs for a later create-or-modify against the project; microflowParamArgRefusal used by the builder and the reference pass; formsWidgetsWithoutWriter used by MDL-WIDGET38 and the builder fall-through; MDL047 matches != too, MDL091 startsWith/endsWith (exec-enforced), validateRetrieveMembers reuses unresolvableXPathSteps on the stored constraint with a script-entity resolver.", "test": "mdl/executor/check_exec_agreement_pedapp_test.go (TestCheckExecAgree_*: each refusal predicted by check AND made by exec, each with a control both accept, on PedApp), validate_widget_unbuildable_test.go (structural: set vs builder switch cases). Each fix reverted: its test fails.", "insight": "A check/exec disagreement is a decision made twice; the cheap durable fix is to make check CALL exec's decision (replay applyJarDepAction on a copy, share translationsRefusal / microflowParamArgRefusal) rather than restate it. The whole-script name set is order-blind: any reference exec resolves eagerly needs an ordered pass, and a later create-or-modify can only be judged against the project. XPath shapes: measure each against a control on mxbuild before making a rule exec-enforced (startsWith, endsWith, != empty retrieve and list view, CreatedDate all CE0161 on 11.13.0; starts-with, Assoc/Target, createdDate clean). Bare createdDate is not an attribute in the model, so a member walk must exempt the implicit XPath members or it reports valid constraints."} diff --git a/.claude/skills/mendix/xpath-constraints/SKILL.md b/.claude/skills/mendix/xpath-constraints/SKILL.md index 7577188b9..50299a103 100644 --- a/.claude/skills/mendix/xpath-constraints/SKILL.md +++ b/.claude/skills/mendix/xpath-constraints/SKILL.md @@ -167,11 +167,12 @@ where [not(Module.Order_Customer/Module.Customer)] > bare name is not an attribute of the constrained entity **and** is a known > association, so attributes stay bare and XPath functions are never touched. -> **`= empty` does not work on associations (CE0161 / MDL047).** `= empty` tests -> *attribute* nullability only. To test whether an object *has no* associated -> object, use negated existence: `[not(Module.Order_Customer/Module.Customer)]` — -> **not** `[Module.Order_Customer = empty]`. `mxcli check` flags the association -> `= empty` form as **MDL047** before the build does. +> **`= empty` / `!= empty` do not work on associations (CE0161 / MDL047).** +> `empty` tests *attribute* nullability only. To test whether an object *has no* +> associated object, use negated existence: `[not(Module.Order_Customer/Module.Customer)]` +> — **not** `[Module.Order_Customer = empty]`; to test that it *has* one, the path +> itself: `[Module.Order_Customer/Module.Customer]` — **not** `!= empty`. +> `mxcli check` flags both forms as **MDL047** before the build does. ### Variable Paths @@ -213,6 +214,12 @@ where [Displayed = false()] Supported functions: `contains()`, `starts-with()`, `not()`, `true()`, `false()` +The expression functions `startsWith()` / `endsWith()` are not XPath: in a +constraint they are CE0161, and `check` reports them as **MDL091**. A member the +entity does not have is a reference error in `check -p`, and so is a system +member written the way `describe` prints the attribute: XPath spells it +`createdDate`, `changedDate`, `owner`, `changedBy` — `[CreatedDate > …]` is CE0161. + ### Tokens Mendix tokens provide runtime values. In an XPath constraint a token used as a diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9513a23..4a149df73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`check -p` predicts what `exec` refuses, in seven more places** (ako/mxcli#906, ako/mxcli#558, ako/mxcli#563, mendixlabs/mxcli#1210 – #1213) — each passed `check` and then stopped `exec` part-way, with the statements before it written, or was accepted by both and broke the project. Each prediction calls exec's own decision, so the two cannot disagree. A `create demo user` for a user that exists, and an `alter module … add jar dependency` for a coordinate the module has, are reported like any other "already exists" (the jar actions are replayed in script order, so a drop then add is fine); `create … translations` into the project's source language, or a plain `create translations` of a language that has some, is reported with exec's message. A task queue the script creates earlier now satisfies a later `in queue` (it was "not found" and exec refused the whole script), and one created later is reported. A page or snippet whose widget calls or reads a microflow or nanoflow the script creates further down is **MDL-ORDER01** (a later `create or modify` is reported against the project). A variable passed to a Java action parameter of type `Microflow` is refused by check and exec under every language version: it was written as the reference's text, and the project no longer loaded. `referenceselector` is **MDL-WIDGET38**, naming `combobox` over the association instead of `widget init`, which cannot help with a built-in Forms widget. In a retrieve constraint, **MDL047** now also catches `Module.Assoc != empty` (and in a widget's data source), **MDL091** catches `startsWith()` / `endsWith()` (the XPath functions are `starts-with()` / `ends-with()`), and a member the entity does not have — including `CreatedDate` for the system member `createdDate` — is a reference error, for an entity in the project or declared by the script. Each XPath shape was measured CE0161 on mxbuild 11.13.0 against its control. - **`create or modify` of a flow can change an activity's notes, and matches an explicit `on error rollback`** (ako/mxcli#859, part) — a statement that added, reworded or took off an `@annotation` on an activity (or dropped an annotated activity) was refused under the `mdl 1;` header ("the annotations on the replaced … change"; "… carries an annotation, which would be left behind") on every run, and rebuilt the whole flow without it. The stored notes of that activity are now replaced by the ones the statement states; a note shared with another activity (one `describe` gives an `id:`) is still refused. This is what made the Studio Pro-authored PedApp nanoflows `ACT_Feedback_TriggerScreenshotMode` and `ACT_Feedback_UploadImage` unrunnable when their plain description was put under `mdl 1;`: a note's `\r\n` is two characters under mdl 1, so it is a real change, now written once. Separately, `commit $E on error rollback` (any activity with a bare `on error rollback`, which `describe` never prints because it is the stored default) did not match its own stored activity, so it was dropped and written again whenever anything else in the flow changed. - **`describe microflow` prints a Show Page action's title override** (ako/mxcli#869) — `show page M.P with title = '…'` used to describe without its override, so describe → exec dropped it, and taking the override out of a `create or modify microflow` reported "Unchanged" and wrote nothing, under mdl 0 and mdl 1. - **Re-running a `call web service` statement unchanged writes nothing** (ako/mxcli#861) — the call's activity was re-spliced on every run, because its stored raw document holds the element IDs each build mints and was compared byte for byte; it is now compared with those IDs set aside. A stored call that lacks a key mxcli's structured form writes (such as `ErrorHandlingType`), or that refers to its service by ID, now describes as `call web service raw '…'` instead of a structured form that would write a different document. diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 86a4cf83b..5f83a5599 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -313,11 +313,11 @@ COMBOBOX cbStatus (Label: 'Status', Attribute: Status) ### REFERENCESELECTOR -Dropdown for selecting an associated object via a reference association: - -```sql -REFERENCESELECTOR rsCategory (Label: 'Category', Attribute: Category) -``` +The classic reference selector (`Forms$ReferenceSelector`) is a built-in Forms +widget mxcli has no writer for. The keyword parses, and `check` refuses it as +**MDL-WIDGET38** rather than letting `exec` stop at the page. Select an +associated object with a `COMBOBOX` over the association instead; it needs its +own `datasource:` (CE0642 without one) and a caption attribute. ## Display Widgets diff --git a/mdl/executor/check_exec_agreement_pedapp_test.go b/mdl/executor/check_exec_agreement_pedapp_test.go new file mode 100644 index 000000000..30121e894 --- /dev/null +++ b/mdl/executor/check_exec_agreement_pedapp_test.go @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// check -p and exec must not disagree (ako/mxcli#906, mendixlabs/mxcli#1210 – +// #1213, ako/mxcli#558, #563). Each case below passed `check -p` and was then +// refused by exec — after the statements before it had been written — or was +// accepted by both and broke the project. Every test pairs the case with a +// control that both accept, on the Studio Pro-authored PedApp fixture. + +// agreeCheck is what `mxcli check -p` reports as an error: the no-project rules, +// the reference pass, and the project conflicts (cmd/mxcli/cmd_check.go). +func agreeCheck(t *testing.T, exec *Executor, dir, src string) []string { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + var out []string + for _, v := range ValidateProgram(prog, filepath.Join(dir, "PedApp.mpr")) { + if v.Severity == linter.SeverityError { + out = append(out, v.RuleID+": "+v.Message) + } + } + refErrs, _ := exec.ValidateProgramWithWarnings(prog) + refErrs = append(refErrs, exec.CheckProjectConflicts(prog)...) + for _, e := range refErrs { + out = append(out, e.Error()) + } + return out +} + +// agreeExec runs the script's statements as `exec --no-check` does, so what is +// asserted is exec's own verdict, not the pre-flight it shares with check. +func agreeExec(t *testing.T, exec *Executor, src string) error { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], src) + } + return exec.ExecuteProgram(prog) +} + +// assertAgree runs check, then exec, and asserts both refuse (want != "", +// matched in both outputs) or both accept. +func assertAgree(t *testing.T, exec *Executor, dir, src, want string) { + t.Helper() + checkErrs := agreeCheck(t, exec, dir, src) + execErr := agreeExec(t, exec, src) + checkSays := strings.Join(checkErrs, "\n") + if want == "" { + if len(checkErrs) != 0 || execErr != nil { + t.Errorf("control: check and exec must both accept\ncheck: %s\nexec: %v", checkSays, execErr) + } + return + } + if !strings.Contains(checkSays, want) { + t.Errorf("check -p must predict the refusal %q, reported:\n%s", want, checkSays) + } + if execErr == nil || !strings.Contains(execErr.Error(), want) { + t.Errorf("exec must refuse with %q, got: %v", want, execErr) + } +} + +// ako/mxcli#906: an existing demo user. The control is `create or modify`. +func TestCheckExecAgree_DemoUserExists(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + const create = "mdl 1;\ncreate demo user 'agree_demo' ( Password: 'Agree!23456789', UserRoles: (User) );" + if err := agreeExec(t, exec, create); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + assertAgree(t, exec, dir, create, "demo user already exists") + assertAgree(t, exec, dir, + "mdl 1;\ncreate or modify demo user 'agree_demo' ( Password: 'Agree!23456789', UserRoles: (User) );", "") +} + +// ako/mxcli#906: an existing jar dependency. The control changes its version, +// which exec does through the same applyJarDepAction. +func TestCheckExecAgree_JarDependencyExists(t *testing.T) { + exec, out, dir := openPedAppCopy(t) + const add = `mdl 1; +alter module MyFirstModule + add jar dependency ( group = 'org.example', artifact = 'agree-lib', version = '1.0.0', included = true );` + if err := agreeExec(t, exec, add); err != nil { + t.Fatalf("setup: %v\n%s", err, out.String()) + } + assertAgree(t, exec, dir, add, "jar dependency 'org.example:agree-lib' already exists") + assertAgree(t, exec, dir, + "mdl 1;\nalter module MyFirstModule set jar dependency 'org.example:agree-lib' version '1.0.1';", "") + // The replay follows the script: an add after a drop of the same coordinate + // is fine, and check must not flag it from the stored state. + assertAgree(t, exec, dir, `mdl 1; +alter module MyFirstModule drop jar dependency 'org.example:agree-lib'; +alter module MyFirstModule + add jar dependency ( group = 'org.example', artifact = 'agree-lib', version = '1.0.2', included = true );`, "") +} + +// ako/mxcli#906: translations into the project's source language (PedApp's is +// en_US). The control is another language. +func TestCheckExecAgree_SourceLanguageTranslations(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + assertAgree(t, exec, dir, "mdl 1;\ncreate or modify translations in MyFirstModule for en_US (\n 'Hello' as 'Hi'\n);", + "en_US is the project's source language") + assertAgree(t, exec, dir, "mdl 1;\ncreate or modify translations in MyFirstModule for nl_NL (\n 'Hello' as 'Hallo'\n);", "") +} + +// mendixlabs/mxcli#1211 / ako/mxcli#558: a task queue the script creates +// before the call that names it was "not found" by check and by exec's +// pre-flight. The other half: the same queue created AFTER the call (as +// `create or modify`, which MDL-ORDER01 does not judge) is refused by both. +func TestCheckExecAgree_TaskQueueCreatedInScript(t *testing.T) { + exec, _, dir := openPedAppCopy(t) + // The reporter's script, headerless (mdl 0) as filed. + assertAgree(t, exec, dir, `create module "ModQ"; +create or modify queue "ModQ"."TQ_Work" (Parallelism: 1); +create microflow "ModQ"."SUB_Work" () +begin + log info node 'Repro' 'work'; +end; +/ +create microflow "ModQ"."ACT_Enqueue" () +begin + call microflow "ModQ"."SUB_Work"() in queue "ModQ"."TQ_Work"; +end; +/`, "") + + exec2, _, dir2 := openPedAppCopy(t) + assertAgree(t, exec2, dir2, `mdl 1; +create module ModQ2; +create microflow ModQ2.SUB_Work () +begin + log info node 'Repro' 'work'; +end; +create microflow ModQ2.ACT_Enqueue () +begin + call microflow ModQ2.SUB_Work() in queue ModQ2.TQ_Work; +end; +create or modify task queue ModQ2.TQ_Work (Parallelism: 1);`, "ModQ2.TQ_Work") +} + +// mendixlabs/mxcli#1212: a page whose button calls a microflow the script +// creates further down. Control: the microflow first — its `show page` of the +// later page resolves lazily, so that order works. +func TestCheckExecAgree_PageCallsLaterMicroflow(t *testing.T) { + const page = `create page ModO.ItemPage ( + Title: 'Item', + Layout: Atlas_Core.PopupLayout, + Params: ( $Item: ModO.Item ) +) { + dataview dvItem (DataSource: $Item) { + textbox txtName (Attribute: Name) + actionbutton btnReopen (Caption: 'Reopen', Action: call microflow ModO.ACT_OpenItem(Item = $Item)) + } +}; +` + const flow = `create microflow ModO.ACT_OpenItem ($Item: ModO.Item) +begin + show page ModO.ItemPage(Item = $Item); +end; +` + const head = "mdl 1;\ncreate module ModO;\ncreate persistent entity ModO.Item (Name: String(100));\n" + + exec, _, dir := openPedAppCopy(t) + assertAgree(t, exec, dir, head+page+flow, "ModO.ACT_OpenItem") + + exec2, _, dir2 := openPedAppCopy(t) + assertAgree(t, exec2, dir2, head+flow+page, "") + + // The later definition as `create or modify`: no project-free rule can + // judge it, the project tier must. + exec3, _, dir3 := openPedAppCopy(t) + assertAgree(t, exec3, dir3, head+page+strings.Replace(flow, "create microflow", "create or modify microflow", 1), + "ModO.ACT_OpenItem") +} + +// mendixlabs/mxcli#1210: a variable passed to a Microflow-typed Java action +// parameter was written as the reference's text and the project no longer +// loaded. Control: the literal name. +func TestCheckExecAgree_MicroflowTypedJavaActionParameter(t *testing.T) { + const head = `mdl 1; +create module ModA; +create java action ModA.RunNamed (Target: Microflow not null) returns Boolean as $$ +return true; +$$; +create microflow ModA.SUB_Target () returns Boolean as $Ok +begin + declare $Ok Boolean = true; + return $Ok; +end; +` + exec, _, dir := openPedAppCopy(t) + assertAgree(t, exec, dir, head+`create microflow ModA.SUB_Variable ($Name: String) returns Boolean as $R +begin + $R = call java action ModA.RunNamed(Target = $Name); + return $R; +end;`, "parameter Target is of type Microflow") + + exec2, _, dir2 := openPedAppCopy(t) + assertAgree(t, exec2, dir2, head+`create microflow ModA.SUB_Literal () returns Boolean as $R +begin + $R = call java action ModA.RunNamed(Target = 'ModA.SUB_Target'); + return $R; +end;`, "") +} + +// ako/mxcli#563: `referenceselector` parses, so check passed it, and the page +// builder has no writer for it. Control: the same page with a text box. +func TestCheckExecAgree_ReferenceSelector(t *testing.T) { + const head = `mdl 1; +create module ModR; +create persistent entity ModR.Sys (Name: String(100)); +create persistent entity ModR.Req (Title: String(100)); +create association ModR.Req_Sys from ModR.Req to ModR.Sys type Reference; +create page ModR.ReqPage ( + Title: 'Req', + Layout: Atlas_Core.PopupLayout, + Params: ( $Req: ModR.Req ) +) { + dataview dvReq (DataSource: $Req) { + %s + } +};` + exec, _, dir := openPedAppCopy(t) + assertAgree(t, exec, dir, strings.Replace(head, "%s", + "referenceselector rsSystem (Label: 'System', Association: ModR.Req_Sys)", 1), + "built-in Forms widget Forms$ReferenceSelector") + + exec2, _, dir2 := openPedAppCopy(t) + assertAgree(t, exec2, dir2, strings.Replace(head, "%s", "textbox txtTitle (Attribute: Title)", 1), "") +} + +// mendixlabs/mxcli#1213: retrieve constraints mxbuild rejects with CE0161 — +// measured on 11.13.0, each against the control beside it, which builds clean: +// +// startsWith(Title, 'X') starts-with(Title, 'X') +// ModX.Order_Customer != empty ModX.Order_Customer/ModX.Customer +// [NoSuchAttr = 'x'] [Title = 'x'] +// [CreatedDate > …] [createdDate > …] +// +// MDL047/MDL091 are exec-enforced rules, so exec refuses those two itself; the +// members are resolved by the reference pass exec runs as its pre-flight. +func TestCheckExecAgree_RetrieveConstraint(t *testing.T) { + const head = `mdl 1; +create module ModX; +create persistent entity ModX.Order (Title: String(100), CreatedDate: AutoCreatedDate); +create persistent entity ModX.Customer (Name: String(100)); +create association ModX.Order_Customer from ModX.Order to ModX.Customer type Reference; +create microflow ModX.SUB_Find () +begin + retrieve $A from ModX.Order where %s; +end;` + cases := []struct{ bad, want, good string }{ + {"startsWith(Title, 'X')", "MDL091", "starts-with(Title, 'X')"}, + {"ModX.Order_Customer != empty", "MDL047", "ModX.Order_Customer/ModX.Customer"}, + {"[NoSuchAttr = 'x']", `names "NoSuchAttr"`, "[Title = 'x']"}, + {"[CreatedDate > '[%CurrentDateTime%]']", "spelled `createdDate`", "[createdDate > '[%CurrentDateTime%]']"}, + } + for _, c := range cases { + exec, _, dir := openPedAppCopy(t) + bad := strings.Replace(head, "%s", c.bad, 1) + if got := strings.Join(agreeCheck(t, exec, dir, bad), "\n"); !strings.Contains(got, c.want) { + t.Errorf("%s: check -p must report %q, reported:\n%s", c.bad, c.want, got) + } + assertAgree(t, exec, dir, strings.Replace(head, "%s", c.good, 1), "") + } +} diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index f67d06ed7..30ce92311 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -373,6 +373,12 @@ func (fb *flowBuilder) addCallJavaActionAction(s *ast.CallJavaActionStmt) model. // Regular parameter: expression-based value valueExpr := fb.exprToString(arg.Value) if microflowTypeParams[arg.Name] { + // A Microflow parameter takes a name, never an expression + // (mendixlabs/mxcli#1210); the same decision reference + // validation reports. + if msg := microflowParamArgRefusal(actionQN, arg.Name, arg.Value); msg != "" { + fb.addError("%s", msg) + } value = µflows.MicroflowParameterValue{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, Microflow: strings.Trim(valueExpr, "'"), diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 7aef506c4..0048ab4d0 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -480,6 +480,12 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) { } } } + // A built-in Forms widget mxcli has no writer for: say so, rather than + // send the reader to `widget init` (ako/mxcli#563). Same message as + // MDL-WIDGET38, which refuses it before anything is written. + if msg := unbuildableWidgetMessage(w.Type); msg != "" { + return nil, mdlerrors.NewUnsupported(msg) + } if pb.pluggableEngineErr != nil { return nil, mdlerrors.NewUnsupported(fmt.Sprintf("unsupported widget type: %s (%v)", w.Type, pb.pluggableEngineErr)) } diff --git a/mdl/executor/cmd_translations.go b/mdl/executor/cmd_translations.go index 51b5f76bb..b25dae21c 100644 --- a/mdl/executor/cmd_translations.go +++ b/mdl/executor/cmd_translations.go @@ -107,31 +107,14 @@ func execCreateTranslations(ctx *ExecContext, s *ast.CreateTranslationsStmt) err return err } src := sourceLanguage(ctx) - if strings.EqualFold(s.Language, src) { - return mdlerrors.NewValidationf( - "%s is the project's source language — writing translations into it would "+ - "overwrite the strings the rest of the model is keyed on", s.Language) + if err := translationsRefusal(ctx, s, src, scope); err != nil { + return err } mode := translations.ModeMerge switch s.Mode { case ast.TranslationsReplace: mode = translations.ModeReplace - case ast.TranslationsCreate: - // The language is the thing that exists, so bare CREATE refuses when it - // already has translations — the same contract every other CREATE has. - existing, err := translations.Languages(ctx.Backend, scope) - if err != nil { - return mdlerrors.NewBackend("read languages", err) - } - for _, l := range existing { - if strings.EqualFold(l, s.Language) { - return mdlerrors.NewValidationf( - "%s already has translations — use `create or modify translations` to "+ - "merge these in, or `create or replace translations` to make this file "+ - "authoritative (which REMOVES translations it does not name)", s.Language) - } - } } dict := make(translations.Dictionary, len(s.Entries)) diff --git a/mdl/executor/javaaction_flow_param.go b/mdl/executor/javaaction_flow_param.go new file mode 100644 index 000000000..c23ac4082 --- /dev/null +++ b/mdl/executor/javaaction_flow_param.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "regexp" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// A Java action parameter of type Microflow stores a document reference — the +// microflow's qualified name — and Studio Pro only lets you pick one. There is +// no dynamic form. Given a variable, mxcli wrote the TEXT `$Name` as that +// reference; check and exec both accepted it, and the project no longer loaded +// (mendixlabs/mxcli#1210, Mendix 11.14.0): +// +// StorageLoadException: … Microflow parameter value … has an invalid value '' +// for property Microflow. The text '$Name' is not a valid MicroflowIdentifier. +// +// That is a silent wrong write — an unloadable project, recoverable only from +// version control — so it is refused under every language version (ADR-0011). +// microflowParamArgRefusal is the one decision: the flow builder refuses the +// call with it, and reference validation (check -p, and exec's pre-flight) +// reports it from the same function, so the two cannot disagree. + +// microflowIdentifierRe is the shape a stored microflow reference has. +var microflowIdentifierRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$`) + +// microflowParamArgRefusal returns why v cannot be written into the +// Microflow-typed parameter param of action, or "" when it can. `empty` is not +// passed here: it is the unbound marker, handled before the value is built. +func microflowParamArgRefusal(action, param string, v ast.Expression) string { + switch e := v.(type) { + case *ast.QualifiedNameExpr: + return "" + case *ast.LiteralExpr: + if s, ok := e.Value.(string); ok && e.Kind == ast.LiteralString && microflowIdentifierRe.MatchString(s) { + return "" + } + } + return fmt.Sprintf("call java action %s: parameter %s is of type Microflow, which takes a microflow "+ + "name ('Module.Microflow'), not %s — Mendix has no dynamic microflow reference here, and the text "+ + "would be stored as the reference, leaving a project that no longer loads", action, param, + expressionToString(v)) +} + +// microflowTypedParams names the parameters a CREATE JAVA ACTION declares as +// `Microflow` — the spelling astDataTypeToJavaActionParamType turns into a +// javaactions.MicroflowType. +func microflowTypedParams(params []ast.JavaActionParam) map[string]bool { + out := map[string]bool{} + for _, p := range params { + if bareDataTypeName(p.Type) == "Microflow" { + out[p.Name] = true + } + } + return out +} + +// microflowParamArgErrors applies microflowParamArgRefusal to the arguments of +// one call, given the action's Microflow-typed parameters. +func microflowParamArgErrors(ref codeActionCallRef, flowParams map[string]bool) []string { + var out []string + for _, a := range ref.args { + if !flowParams[a.Name] || isEmptyJavaActionArgument(a.Value) { + continue + } + if msg := microflowParamArgRefusal(ref.name, a.Name, a.Value); msg != "" { + out = append(out, msg) + } + } + return out +} diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 1926355eb..c9f383257 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -15,6 +15,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/linter" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/javaactions" ) // scriptContext holds objects defined within a script for reference validation. @@ -36,6 +37,23 @@ type scriptContext struct { menus map[string]bool // Menu documents created (Module.Menu) constants map[string]bool // Constants created (Module.Constant) workflows map[string]bool // Workflows created (Module.Workflow) + // Task queues created (Module.Queue, lower-cased — a queue is resolved + // case-insensitively, like buildQueueQualifiedNames). Missing, a queue the + // script creates was "not found" for every `in queue` call to it + // (mendixlabs/mxcli#1211). + queues map[string]bool + + // Microflow-typed parameters of the Java actions the script creates + // (Module.Action -> parameter name). Such a parameter takes a microflow + // NAME, never an expression (mendixlabs/mxcli#1210). + javaActionFlowParams map[string]map[string]bool + + // Entities the script declares, and those it alters, for resolving the + // bare members of a retrieve constraint on an entity the project does not + // have yet (mendixlabs/mxcli#1213). An altered entity's member list is + // not the declaration's, so it is not judged from it. + entityDecls map[string]*ast.CreateEntityStmt + alteredEntities map[string]bool // Java/JavaScript actions created in the script, mapped to their declared // parameter names. A bool would be enough to stop the false "not found", @@ -93,6 +111,11 @@ func newScriptContext() *scriptContext { layouts: make(map[string]bool), menus: make(map[string]bool), constants: make(map[string]bool), + queues: make(map[string]bool), + + javaActionFlowParams: make(map[string]map[string]bool), + entityDecls: make(map[string]*ast.CreateEntityStmt), + alteredEntities: make(map[string]bool), javaActions: make(map[string][]string), javaScriptActions: make(map[string][]string), @@ -172,7 +195,10 @@ func (sc *scriptContext) collectSingle(stmt ast.Statement) { sc.entities[s.Name.String()] = true sc.recordEntityAttrs(s) sc.recordEntityGeneralization(s) + sc.entityDecls[s.Name.String()] = s } + case *ast.AlterEntityStmt: + sc.alteredEntities[s.Name.String()] = true case *ast.CreateAssociationStmt: sc.recordAssociation(s) case *ast.CreateViewEntityStmt: @@ -226,6 +252,11 @@ func (sc *scriptContext) collectSingle(stmt ast.Statement) { case *ast.CreateJavaActionStmt: if s.Name.Module != "" { sc.javaActions[s.Name.String()] = codeActionParamNames(s.Parameters) + sc.javaActionFlowParams[s.Name.String()] = microflowTypedParams(s.Parameters) + } + case *ast.CreateQueueStmt: + if s.Name.Module != "" { + sc.queues[strings.ToLower(s.Name.String())] = true } case *ast.CreateJavaScriptActionStmt: if s.Name.Module != "" { @@ -333,6 +364,9 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, } } errors = append(errors, validateForwardPageRefs(ctx, prog)...) + // The same for the other references exec resolves in statement order, where + // the later definition is a `create or modify` (mendixlabs/mxcli#1211, #1212). + errors = append(errors, validateForwardDefRefs(ctx, prog)...) // Resolve icon-collection references. Needs the project (the collections // are documents in it), so it belongs here rather than in the no-project // pass — MxBuild otherwise reports the typo as CE1613. @@ -1111,7 +1145,7 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, if len(refs.queues) > 0 { known := buildQueueQualifiedNames(ctx) for _, ref := range refs.queues { - if !known[strings.ToLower(ref)] { + if !known[strings.ToLower(ref)] && !sc.queues[strings.ToLower(ref)] { errors = append(errors, fmt.Sprintf("task queue not found: %s (referenced by in queue)", ref)) } } @@ -1159,6 +1193,7 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, // output. mxcli-chat FINDINGS §37. if declared, inScript := sc.javaActions[ref.name]; inScript { errors = append(errors, validateCodeActionParams("java action", ref, declared)...) + errors = append(errors, microflowParamArgErrors(ref, sc.javaActionFlowParams[ref.name])...) continue } if !known[ref.name] { @@ -1167,10 +1202,15 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, } if ja, err := ctx.Backend.ReadJavaActionByName(ref.name); err == nil && ja != nil { var declared []string + flowParams := map[string]bool{} for _, p := range ja.Parameters { declared = append(declared, p.Name) + if _, isFlow := p.ParameterType.(*javaactions.MicroflowType); isFlow { + flowParams[p.Name] = true + } } errors = append(errors, validateCodeActionParams("java action", ref, declared)...) + errors = append(errors, microflowParamArgErrors(ref, flowParams)...) } } } @@ -1211,6 +1251,7 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, if len(refs.retrieves) > 0 { errors = append(errors, validateRetrieveConstraints(ctx, refs.retrieves)...) errors = append(errors, validateXPathAssociations(ctx, refs.retrieves, sc)...) + errors = append(errors, validateRetrieveMembers(ctx, refs.retrieves, sc)...) } return errors @@ -1333,6 +1374,7 @@ type flowRefCollector struct { type codeActionCallRef struct { name string argNames []string + args []ast.CallArgument } // callArgNames extracts the written parameter names from a code-action call's @@ -1397,6 +1439,7 @@ type entityRef struct { type retrieveConstraintRef struct { entity string // entity qualified name (database retrieve only) constraint string // bracketed XPath constraint, e.g. "[System.owner = '[%CurrentUser%]']" + stored string // the constraint as the flow builder writes it (retrieveXPathConstraint) } // addQueue records an `IN QUEUE Module.Name` target. A queue that does not exist @@ -1434,7 +1477,7 @@ func (c *flowRefCollector) collectFromStatements(stmts []ast.MicroflowStatement) case *ast.CallJavaActionStmt: if s.ActionName.Module != "" { c.javaActions = append(c.javaActions, codeActionCallRef{ - name: s.ActionName.String(), argNames: callArgNames(s.Arguments), + name: s.ActionName.String(), argNames: callArgNames(s.Arguments), args: s.Arguments, }) } c.addQueue(s.Queue) @@ -1460,6 +1503,7 @@ func (c *flowRefCollector) collectFromStatements(stmts []ast.MicroflowStatement) c.retrieves = append(c.retrieves, retrieveConstraintRef{ entity: s.Source.String(), constraint: expressionToXPath(s.Where), + stored: retrieveXPathConstraint(s.Where, s.Source.String()), }) } } @@ -1560,6 +1604,10 @@ var execEnforcedMicroflowRules = map[string]bool{ "MDL047": true, "MDL048": true, "MDL055": true, + // MDL091: `startsWith()` / `endsWith()` in a retrieve constraint are + // CE0161, measured on mxbuild 11.13.0 against `starts-with()` as the + // control (mendixlabs/mxcli#1213). + "MDL091": true, // MDL057: `synchronize` in a microflow is CE0009 at build time, verified on // mxbuild 11.13.0 — the same class of "check caught it, exec did not" gap // that #833 was about. diff --git a/mdl/executor/validate_create_shape.go b/mdl/executor/validate_create_shape.go index 562aa7205..7a3da881c 100644 --- a/mdl/executor/validate_create_shape.go +++ b/mdl/executor/validate_create_shape.go @@ -45,6 +45,10 @@ func ValidateCreateIsQualified(prog *ast.Program) []linter.Violation { continue } docType, name, _ := stmtCreateInfo(stmt) + // A demo user is named by its user name, which has no module. + if docType == "demo-user" { + continue + } if docType == "" || name == "" || strings.Contains(name, ".") { continue } diff --git a/mdl/executor/validate_definition_order.go b/mdl/executor/validate_definition_order.go index 7377f3f1f..395afa6e9 100644 --- a/mdl/executor/validate_definition_order.go +++ b/mdl/executor/validate_definition_order.go @@ -23,6 +23,7 @@ package executor import ( "fmt" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/linter" @@ -36,6 +37,7 @@ const ( defMicroflow = "microflow" defNanoflow = "nanoflow" defPage = "page" + defQueue = "task queue" ) // defKey identifies a document by kind and qualified name. Kind is part of the @@ -156,6 +158,8 @@ func definedBy(stmt ast.Statement) (key defKey, plain bool) { return qualifiedKey(defNanoflow, s.Name), !s.CreateOrModify case *ast.CreatePageStmtV3: return qualifiedKey(defPage, s.Name), !s.IsModify && !s.IsReplace + case *ast.CreateQueueStmt: + return qualifiedKey(defQueue, s.Name), !s.CreateOrModify } return defKey{}, false } @@ -217,6 +221,16 @@ func eagerDefRefs(stmt ast.Statement) []defRef { } return out + case *ast.CreatePageStmtV3: + if s.Excluded { + // An excluded page may name a flow the project lacks; the builder + // writes it by name (danglingRefOK), so the order does not fail. + return nil + } + return widgetFlowDefRefs("page "+s.Name.String(), allPageWidgets(s)) + case *ast.CreateSnippetStmtV3: + return widgetFlowDefRefs("snippet "+s.Name.String(), s.Widgets) + case *ast.GrantEntityAccessStmt: return oneDefRef(s.Entity, defEntity, "grant on entity "+s.Entity.String()) case *ast.GrantMicroflowAccessStmt: @@ -250,8 +264,45 @@ func flowDefRefs(site string, params []ast.MicroflowParam, ret *ast.MicroflowRet switch c := stmt.(type) { case *ast.CallMicroflowStmt: out = append(out, oneDefRef(c.MicroflowName, defMicroflow, site)...) + out = append(out, queueDefRef(c.Queue, site)...) case *ast.CallNanoflowStmt: out = append(out, oneDefRef(c.NanoflowName, defNanoflow, site)...) + case *ast.CallJavaActionStmt: + // The action itself resolves lazily (measured, #955); its IN QUEUE + // does not — buildQueueSettings refuses a queue it cannot find. + out = append(out, queueDefRef(c.Queue, site)...) + } + } + return out +} + +// queueDefRef is an `IN QUEUE` target, which the flow builder resolves when it +// writes the call (buildQueueSettings) — so a queue created further down the +// script fails the flow (mendixlabs/mxcli#1211). +func queueDefRef(q *ast.QualifiedName, site string) []defRef { + if q == nil { + return nil + } + return oneDefRef(*q, defQueue, site+": in queue") +} + +// widgetFlowDefRefs is the microflows and nanoflows a page or snippet's widgets +// name — action targets and data sources. The page builder resolves each when +// it writes the page, so one created further down the script stops exec at the +// page, after everything before it was written (mendixlabs/mxcli#1212). +func widgetFlowDefRefs(site string, widgets []*ast.WidgetV3) []defRef { + refs := &widgetRefCollector{} + refs.collectFromWidgets(widgets) + refs.dedupe() + var out []defRef + for _, mf := range refs.microflows { + if mf = unquoteQualifiedName(mf); strings.Contains(mf, ".") { + out = append(out, defRef{name: mf, kinds: []string{defMicroflow}, site: site}) + } + } + for _, nf := range refs.nanoflows { + if nf = unquoteQualifiedName(nf); strings.Contains(nf, ".") { + out = append(out, defRef{name: nf, kinds: []string{defNanoflow}, site: site}) } } return out @@ -326,3 +377,101 @@ func errorHandlerBody(stmt ast.MicroflowStatement) []ast.MicroflowStatement { } return nil } + +// validateForwardDefRefs is MDL-ORDER01's project-tier half. MDL-ORDER01 needs +// no project because it only judges a reference whose later definition is a +// PLAIN create — which asserts the document is not in the project yet. A later +// `create or modify` asserts nothing, so whether the earlier reference resolves +// depends on the project: when it has the document, exec resolves against it; +// when it does not, exec fails at the referring statement with the statements +// before it already written, while the whole-script reference pass, which +// counts every name the script defines anywhere, reported it clean. +// +// The references are eagerDefRefs — the same index MDL-ORDER01 walks — so the +// two tiers cannot disagree about what is resolved eagerly. +func validateForwardDefRefs(ctx *ExecContext, prog *ast.Program) []error { + if prog == nil || !ctx.Connected() { + return nil + } + plain := plainCreateIndex(prog) + anyCreate := map[defKey]int{} + for i, stmt := range prog.Statements { + if key, _ := definedBy(stmt); key.name != "" { + if _, seen := anyCreate[key]; !seen { + anyCreate[key] = i + } + } + } + if len(anyCreate) == 0 { + return nil + } + stored := map[string]map[string]bool{} + storedOf := func(kind string) map[string]bool { + if m, ok := stored[kind]; ok { + return m + } + var m map[string]bool + switch kind { + case defEntity: + m = buildEntityQualifiedNames(ctx) + case defEnumeration: + m = buildEnumerationQualifiedNames(ctx) + case defMicroflow: + m = buildMicroflowQualifiedNames(ctx) + case defNanoflow: + m = buildNanoflowQualifiedNames(ctx) + case defPage: + m = buildPageQualifiedNames(ctx) + case defQueue: + m = buildQueueQualifiedNames(ctx) + } + stored[kind] = m + return m + } + inProject := func(kind, name string) bool { + m := storedOf(kind) + // An empty listing is a backend that could not answer, as often as a + // project without any — silence, not a finding (cf. checkAlterTarget). + // Queues are the exception: a project with none is ordinary. + if len(m) == 0 && kind != defQueue { + return true + } + if kind == defQueue { + return m[strings.ToLower(name)] + } + return m[name] + } + + created := map[defKey]bool{} + var errs []error + for i, stmt := range prog.Statements { + for _, ref := range eagerDefRefs(stmt) { + resolved := false + for _, k := range ref.kinds { + if created[defKey{kind: k, name: ref.name}] || inProject(k, ref.name) { + resolved = true + break + } + } + if resolved { + continue + } + for _, k := range ref.kinds { + key := defKey{kind: k, name: ref.name} + at, later := anyCreate[key] + if !later || at <= i { + continue + } + if _, isPlain := plain[key]; isPlain { + break // MDL-ORDER01 reports it, without a project + } + errs = append(errs, fmt.Errorf( + "statement %d: %s references %s %s before it is created (statement %d), and the project does not have it — exec resolves this in statement order, so it fails here with the statements before it already written. Move the create statement for %s above this one", + i+1, ref.site, k, ref.name, at+1, ref.name)) + break + } + } + markCreated(created, stmt) + } + return errs +} diff --git a/mdl/executor/validate_duplicates.go b/mdl/executor/validate_duplicates.go index bce92772b..55858b716 100644 --- a/mdl/executor/validate_duplicates.go +++ b/mdl/executor/validate_duplicates.go @@ -163,6 +163,12 @@ func stmtCreateKind(stmt ast.Statement) (docType, name string, idempotent bool) return "agent", s.Name.String(), s.CreateOrModify case *ast.CreateImageCollectionStmt: return "image-collection", s.Name.String(), s.CreateOrModify + case *ast.CreateDemoUserStmt: + // Not a document either, and like a module role it was missed by the + // document sweep: exec refuses a plain CREATE of a user that exists + // ("demo user already exists"), and check passed it (ako/mxcli#906). + // The name is the user name, which is not module-qualified. + return "demo-user", s.UserName, s.CreateOrModify } return "", "", false } @@ -223,6 +229,8 @@ func stmtDropInfo(stmt ast.Statement) (docType, name string) { return "agent", s.Name.String() case *ast.DropImageCollectionStmt: return "image-collection", s.Name.String() + case *ast.DropDemoUserStmt: + return "demo-user", s.UserName } return "", "" } @@ -276,6 +284,8 @@ func friendlyDocType(docType string) string { return "javascript action" case "module-role": return "module role" + case "demo-user": + return "demo user" case "json-structure": return "JSON structure" case "knowledge-base": @@ -392,6 +402,7 @@ type projectNameSets struct { rules map[string]bool javaScriptActs map[string]bool moduleRoles map[string]bool + demoUsers map[string]bool } // projectSetFor returns the existence set for the given doc-type key, or nil @@ -448,6 +459,8 @@ func (ps *projectNameSets) setFor(docType string) map[string]bool { return ps.javaScriptActs case "module-role": return ps.moduleRoles + case "demo-user": + return ps.demoUsers } // "module" is deliberately absent: CREATE MODULE on an existing module is a // no-op that prints "already exists" and exits 0, so `create module M;` is @@ -590,6 +603,16 @@ func loadProjectNameSets(ctx *ExecContext) *projectNameSets { // Module roles ps.moduleRoles = buildModuleRoleQualifiedNames(ctx) + // Demo users: the same lookup execCreateDemoUser refuses on. + ps.demoUsers = make(map[string]bool) + if sec, err := ctx.Backend.GetProjectSecurity(); err == nil && sec != nil { + for _, du := range sec.DemoUsers { + if du != nil { + ps.demoUsers[du.UserName] = true + } + } + } + // Image collections ps.imageCollections = make(map[string]bool) if ics, err := ctx.Backend.ListImageCollections(); err == nil { @@ -682,5 +705,8 @@ func CheckProjectConflicts(ctx *ExecContext, prog *ast.Program) []error { // A create over a name another kind already has (ako/mxcli#793). errs = append(errs, CheckProjectNameClashes(ctx, prog)...) + // The refusals exec decides from project state other than a document + // listing: jar dependencies, translations (ako/mxcli#906). + errs = append(errs, CheckExecRefusals(ctx, prog)...) return errs } diff --git a/mdl/executor/validate_exec_refusals.go b/mdl/executor/validate_exec_refusals.go new file mode 100644 index 000000000..a71db3de8 --- /dev/null +++ b/mdl/executor/validate_exec_refusals.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/translations" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// Refusals exec makes from project state that is not a document listing, and +// that `check -p` therefore did not predict (ako/mxcli#906). check predicted +// "already exists" for every document kind, because those go through one +// registry (CheckProjectConflicts); these each had their own lookup inside the +// exec handler, so check passed a statement exec then refused: +// +// - `alter module … add jar dependency` for a coordinate the module has +// (formula1, every run after the first); +// - `create … translations … for ` (captrack, every run); +// - `create demo user` for a user that exists — that one is a create, and now +// goes through the registry like the other creates (stmtCreateKind). +// +// What keeps check and exec from disagreeing again is that the prediction CALLS +// exec's decision instead of restating it: the jar actions are replayed through +// applyJarDepAction on a copy of the stored settings, and the translations +// refusal is translationsRefusal, which execCreateTranslations calls too. A +// prediction is only made while the stored project is what exec will see; once +// an earlier statement of the script changes that state, check stays silent +// rather than guess. + +// CheckExecRefusals returns the refusals exec would make for the statements of +// prog, given the connected project. +func CheckExecRefusals(ctx *ExecContext, prog *ast.Program) []error { + if prog == nil || !ctx.Connected() { + return nil + } + var errs []error + errs = append(errs, checkJarDependencyActions(ctx, prog)...) + errs = append(errs, checkTranslationTargets(ctx, prog)...) + return errs +} + +// checkJarDependencyActions replays every `alter module … jar dependency` +// statement against a copy of the stored module settings, in script order, with +// the function exec applies them with. A refusal is reported once per module: +// after it exec has stopped, and what the module holds is no longer known. +func checkJarDependencyActions(ctx *ExecContext, prog *ast.Program) []error { + settings := map[string]*types.ModuleSettings{} + unknown := map[string]bool{} // modules whose state the script makes unpredictable + var errs []error + for i, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.CreateModuleStmt: + unknown[s.Name] = true + case *ast.DropModuleStmt: + unknown[s.Name] = true + case *ast.AlterModuleJarDepStmt: + if unknown[s.ModuleName] { + continue + } + ms := settings[s.ModuleName] + if ms == nil { + module, err := ctx.Backend.GetModuleByName(s.ModuleName) + if err != nil || module == nil { + unknown[s.ModuleName] = true // a missing module is reported as itself + continue + } + stored, err := ctx.Backend.GetModuleSettings(module.ID) + if err != nil || stored == nil { + unknown[s.ModuleName] = true + continue + } + ms = cloneJarDependencies(stored) + settings[s.ModuleName] = ms + } + for _, action := range s.Actions { + if err := applyJarDepAction(ms, action, s.ModuleName); err != nil { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + unknown[s.ModuleName] = true + break + } + } + } + } + return errs +} + +// cloneJarDependencies copies the part of the settings applyJarDepAction +// mutates, so the prediction never touches what the backend handed out. +func cloneJarDependencies(ms *types.ModuleSettings) *types.ModuleSettings { + out := *ms + out.JarDependencies = make([]*types.JarDependency, 0, len(ms.JarDependencies)) + for _, d := range ms.JarDependencies { + if d == nil { + continue + } + c := *d + c.Exclusions = make([]*types.JarDependencyExclusion, 0, len(d.Exclusions)) + for _, e := range d.Exclusions { + if e != nil { + ec := *e + c.Exclusions = append(c.Exclusions, &ec) + } + } + out.JarDependencies = append(out.JarDependencies, &c) + } + return &out +} + +// checkTranslationTargets reports a translations statement exec refuses. Once +// the script changes the language settings the stored source language is no +// longer what exec sees, and once it has written translations for a language a +// later plain CREATE of that language is decided by the script, not the +// project — the prediction stops at both. +func checkTranslationTargets(ctx *ExecContext, prog *ast.Program) []error { + src := sourceLanguage(ctx) + if src == "" { + return nil + } + written := map[string]bool{} + var errs []error + for i, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.AlterSettingsStmt: + if strings.EqualFold(s.Section, "LANGUAGE") { + return errs + } + case *ast.CreateTranslationsStmt: + lang := strings.ToLower(s.Language) + if written[lang] { + continue + } + written[lang] = true + scope, err := translationScope(ctx, s.Module) + if err != nil { + continue // a missing module is reported by exec as itself + } + if err := translationsRefusal(ctx, s, src, scope); err != nil { + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, err)) + } + } + } + return errs +} + +// translationsRefusal is the decision execCreateTranslations makes before it +// writes anything: no translations into the source language, and a plain CREATE +// does not overwrite a language that already has translations. check -p calls +// it too, so the two cannot disagree. +func translationsRefusal(ctx *ExecContext, s *ast.CreateTranslationsStmt, src string, scope translations.Scope) error { + if strings.EqualFold(s.Language, src) { + return mdlerrors.NewValidationf( + "%s is the project's source language — writing translations into it would "+ + "overwrite the strings the rest of the model is keyed on", s.Language) + } + if s.Mode != ast.TranslationsCreate { + return nil + } + // The language is the thing that exists, so bare CREATE refuses when it + // already has translations — the same contract every other CREATE has. + existing, err := translations.Languages(ctx.Backend, scope) + if err != nil { + return mdlerrors.NewBackend("read languages", err) + } + for _, l := range existing { + if strings.EqualFold(l, s.Language) { + return mdlerrors.NewValidationf( + "%s already has translations — use `create or modify translations` to "+ + "merge these in, or `create or replace translations` to make this file "+ + "authoritative (which REMOVES translations it does not name)", s.Language) + } + } + return nil +} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 5902bbfec..23edb34b6 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -361,6 +361,7 @@ func (v *microflowValidator) walkBody(body []ast.MicroflowStatement) { if stmt.Where != nil { xp := expressionToXPath(stmt.Where) v.checkXPathAssociationEmpty(stmt.Variable, xp) + v.checkXPathFunctionNames(stmt.Variable, xp) v.checkXPathIdConstraint(stmt.Variable, xp) v.checkXPathVariableTraversal(stmt.Variable, xp) } @@ -674,36 +675,99 @@ func exprHasSlashDivision(expr ast.Expression) bool { } // xpathAssocEmptyRe matches a module-qualified association compared directly to -// `empty` in an XPath constraint (`Ledger.Transaction_Category = empty`). The -// leading boundary class excludes a `/` (so an attribute-over-association path -// like `Assoc/Ledger.Category = empty` is NOT matched — that is a valid -// attribute nullability test) and a `.`/word char (so it captures the whole -// qualified name, not the tail of a 3-part enum literal). -var xpathAssocEmptyRe = regexp.MustCompile(`(^|[^\w./])([A-Za-z_]\w*\.[A-Za-z_]\w*)\s*=\s*empty\b`) - -// xpathAssociationEmptyMatches returns the module-qualified association names an -// XPath constraint compares directly to `empty` (`Ledger.Transaction_Category = -// empty`). Shared by the microflow-retrieve check (MDL047) and the page/widget -// datasource check. Empty result → nothing to flag. -func xpathAssociationEmptyMatches(xpath string) []string { - var out []string - for _, m := range xpathAssocEmptyRe.FindAllStringSubmatch(xpath, -1) { - out = append(out, m[2]) +// `empty` in an XPath constraint (`Ledger.Transaction_Category = empty`, or +// `!= empty`). The leading boundary class excludes a `/` (so an +// attribute-over-association path like `Assoc/Ledger.Category = empty` is NOT +// matched — that is a valid attribute nullability test) and a `.`/word char (so +// it captures the whole qualified name, not the tail of a 3-part enum literal). +var xpathAssocEmptyRe = regexp.MustCompile(`(^|[^\w./])([A-Za-z_]\w*\.[A-Za-z_]\w*)\s*(!=|=)\s*empty\b`) + +// xpathAssocEmptyHit is one association compared directly to `empty`. +type xpathAssocEmptyHit struct { + Assoc string // Module.Association + Op string // "=" or "!=" +} + +// test renders the comparison as written, for the message. +func (h xpathAssocEmptyHit) test() string { return h.Assoc + " " + h.Op + " empty" } + +// fix is the spelling Mendix XPath accepts: a path to the associated object +// tests its presence, `not(…)` around it its absence. +func (h xpathAssocEmptyHit) fix() string { + if h.Op == "!=" { + return fmt.Sprintf("Test for the presence of the associated object with a path: `[%s/]`.", h.Assoc) + } + return fmt.Sprintf("Test for the absence of the associated object with negation: `[not(%s/)]`.", h.Assoc) +} + +// xpathAssociationEmptyMatches returns the module-qualified associations an +// XPath constraint compares directly to `empty`. Shared by the +// microflow-retrieve check (MDL047) and the page/widget datasource check. +// Empty result → nothing to flag. +// +// `!= empty` was missed: only `=` was matched, and measured on mxbuild 11.13.0 +// `[Mod.Order_Customer != empty]` is CE0161 as well — in a retrieve and in a +// list view's database source — while `[Mod.Order_Customer/Mod.Customer]` +// builds clean (mendixlabs/mxcli#1213). +func xpathAssociationEmptyMatches(xpath string) []xpathAssocEmptyHit { + var out []xpathAssocEmptyHit + for _, m := range xpathAssocEmptyRe.FindAllStringSubmatch(blankXPathLiterals(xpath), -1) { + out = append(out, xpathAssocEmptyHit{Assoc: m[2], Op: m[3]}) } return out } -// checkXPathAssociationEmpty flags `[Module.Association = empty]` in a retrieve -// constraint. Mendix XPath has no `= empty` test for an association — it fails -// the build with CE0161; the nullability test is `not(Module.Association/Module.Target)`. -// A bare attribute (`Name = empty`) is valid and is not module-qualified, so it -// never matches. (ledger finding #25) +// checkXPathAssociationEmpty flags `[Module.Association = empty]` (and `!=`) in a +// retrieve constraint. Mendix XPath has no `empty` test for an association — it +// fails the build with CE0161; the test is a path to the associated object, +// negated with `not(…)` for absence. A bare attribute (`Name = empty`) is valid +// and is not module-qualified, so it never matches. (ledger finding #25) func (v *microflowValidator) checkXPathAssociationEmpty(variable, xpath string) { - for _, assoc := range xpathAssociationEmptyMatches(xpath) { + for _, h := range xpathAssociationEmptyMatches(xpath) { v.addViolation("MDL047", linter.SeverityError, - fmt.Sprintf("retrieve '$%s' constraint tests association `%s = empty`, which Mendix XPath does not support "+ - "(CE0161 \"Error(s) in XPath constraint\") — `= empty` works on attributes, not associations", variable, assoc), - fmt.Sprintf("Test for the absence of the associated object with negation: `[not(%s/)]`.", assoc)) + fmt.Sprintf("retrieve '$%s' constraint tests association `%s`, which Mendix XPath does not support "+ + "(CE0161 \"Error(s) in XPath constraint\") — `empty` compares attributes, not associations", variable, h.test()), + h.fix()) + } +} + +// xpathExpressionOnlyFunctions maps the Mendix EXPRESSION functions an XPath +// constraint does not have to the XPath function that does the same thing. The +// two languages sit side by side in a microflow and are easy to mix: measured on +// mxbuild 11.13.0, `startsWith(Title, 'X')` and `endsWith(Title, 'X')` in a +// retrieve constraint are CE0161 "Error(s) in XPath constraint" while +// `starts-with(Title, 'X')` builds clean (mendixlabs/mxcli#1213). The list is +// the measured pairs, not every function the two languages spell differently. +var xpathExpressionOnlyFunctions = map[string]string{ + "startsWith": "starts-with", + "endsWith": "ends-with", +} + +// xpathFunctionCallRe matches a function call and the character before it. A +// `-` or word character before the name is part of another name. +var xpathFunctionCallRe = regexp.MustCompile(`(^|[^\w.-])([A-Za-z_][\w-]*)\s*\(`) + +// xpathExpressionFunctionHits returns the expression-only functions an XPath +// constraint calls, outside string literals. +func xpathExpressionFunctionHits(xpath string) []string { + var out []string + for _, m := range xpathFunctionCallRe.FindAllStringSubmatch(blankXPathLiterals(xpath), -1) { + if _, ok := xpathExpressionOnlyFunctions[m[2]]; ok { + out = append(out, m[2]) + } + } + return out +} + +// checkXPathFunctionNames flags (MDL091) an expression-language function in a +// retrieve constraint. +func (v *microflowValidator) checkXPathFunctionNames(variable, xpath string) { + for _, fn := range xpathExpressionFunctionHits(xpath) { + xp := xpathExpressionOnlyFunctions[fn] + v.addViolation("MDL091", linter.SeverityError, + fmt.Sprintf("retrieve '$%s' constraint calls `%s()`, which is a Mendix expression function — XPath "+ + "does not have it, and mxbuild reports CE0161 \"Error(s) in XPath constraint\"", variable, fn), + fmt.Sprintf("Use the XPath function `%s()` in a retrieve constraint.", xp)) } } diff --git a/mdl/executor/validate_retrieve_members.go b/mdl/executor/validate_retrieve_members.go new file mode 100644 index 000000000..6bb63973e --- /dev/null +++ b/mdl/executor/validate_retrieve_members.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// A retrieve constraint naming a member its entity does not have passed +// `check --references` and `exec`, and mxbuild then reported CE0161 "Error(s) +// in XPath constraint" (mendixlabs/mxcli#1213). The widget data source had this +// check since #1049; the microflow retrieve did not, and neither looked at an +// entity the same script declares — the ordinary shape, since the entity and +// the microflow querying it are usually one script. +// +// Measured on mxbuild 11.13.0 (entity with `CreatedDate: AutoCreatedDate`): +// +// [NoSuchAttr = 'x'] CE0161 +// [CreatedDate > '[%CurrentDateTime%]'] CE0161 (the spelling describe prints) +// [createdDate > '[%CurrentDateTime%]'] clean +// +// The resolution is unresolvableXPathSteps, the widget check's walk, over the +// constraint exactly as the flow builder stores it (retrieveXPathConstraint), so +// what is checked is what is written. Silence where the entity cannot be +// established, as everywhere in that walk. + +// validateRetrieveMembers reports the members of each database retrieve's +// constraint that resolve to nothing on the retrieved entity. +func validateRetrieveMembers(ctx *ExecContext, retrieves []retrieveConstraintRef, sc *scriptContext) []string { + if len(retrieves) == 0 { + return nil + } + m := &execXPathModel{ctx: ctx} + var assocs map[string]string // unqualified association name -> qualified, loaded on demand + resolve := func(entityQN, member string) memberResolution { + decl := sc.entityDecls[entityQN] + if decl == nil || sc.alteredEntities[entityQN] { + return resolveMemberOnEntity(ctx, entityQN, member) + } + // Declared by the script: its attribute list is what exec writes. An + // inherited member cannot be judged from the declaration. + if decl.Generalization != nil { + return memberUnknown + } + for _, a := range decl.Attributes { + if a.Name == member && !isAutoSystemMemberType(a.Type.Kind) { + return memberFound + } + } + // A bare association is a different mistake, with its own rule and fix + // (MDL-XPATH01) — not "names nothing". + if sc.associations[member] != "" || sc.ambiguousAssc[member] { + return memberFound + } + if assocs == nil { + assocs = buildAssociationIndex(ctx) + } + if assocs[member] != "" { + return memberFound + } + return memberMissing + } + + var errs []string + for _, r := range retrieves { + if r.entity == "" || r.stored == "" { + continue + } + for _, bad := range unresolvableXPathStepsWith(ctx, m, r.stored, r.entity, resolve) { + errs = append(errs, fmt.Sprintf( + "retrieve from %s: the constraint names %q, which is neither an attribute nor an association of it — mxbuild rejects the constraint (CE0161 \"Error(s) in XPath constraint\")%s", + r.entity, bad.name, systemMemberSpellingHint(bad.name))) + } + } + return errs +} + +// isAutoSystemMemberType reports the attribute types that are not attributes in +// the model: the entity stores a flag, and XPath names the member in lower +// camel case (createdDate) whatever the declaration called it. +func isAutoSystemMemberType(k ast.DataTypeKind) bool { + switch k { + case ast.TypeAutoCreatedDate, ast.TypeAutoChangedDate, ast.TypeAutoOwner, ast.TypeAutoChangedBy: + return true + } + return false +} + +// systemMemberSpellingHint names the XPath spelling of a system member written +// the way describe prints the attribute (`CreatedDate`). +func systemMemberSpellingHint(name string) string { + for member := range xpathImplicitMembers { + if member != "id" && member != name && strings.EqualFold(member, name) { + return fmt.Sprintf(". In XPath the system member is spelled `%s`", member) + } + } + return "" +} diff --git a/mdl/executor/validate_widget_member_refs.go b/mdl/executor/validate_widget_member_refs.go index 2bc8a3d9c..aa3cc491b 100644 --- a/mdl/executor/validate_widget_member_refs.go +++ b/mdl/executor/validate_widget_member_refs.go @@ -215,6 +215,14 @@ type badStep struct { // the consequence is a corrupted constraint; here it is only a false error, but // a false error still blocks a script that builds. func unresolvableXPathSteps(ctx *ExecContext, m xpathrefs.Model, constraint, entityQN string) []badStep { + return unresolvableXPathStepsWith(ctx, m, constraint, entityQN, nil) +} + +// unresolvableXPathStepsWith is unresolvableXPathSteps with the bare-member +// lookup replaced — the microflow retrieve check passes one that also knows the +// entities the script declares. nil is resolveMemberOnEntity. +func unresolvableXPathStepsWith(ctx *ExecContext, m xpathrefs.Model, constraint, entityQN string, + resolveBare func(entityQN, member string) memberResolution) []badStep { groups := visitor.SplitXPathPredicateGroups(constraint) if len(groups) == 0 { groups = []string{constraint} @@ -225,7 +233,7 @@ func unresolvableXPathSteps(ctx *ExecContext, m xpathrefs.Model, constraint, ent if !ok || expr == nil { continue } - v := &xpathMemberVisitor{ctx: ctx, model: m} + v := &xpathMemberVisitor{ctx: ctx, model: m, resolveBare: resolveBare} v.walk(expr, entityQN) out = append(out, v.bad...) } @@ -235,10 +243,11 @@ func unresolvableXPathSteps(ctx *ExecContext, m xpathrefs.Model, constraint, ent // xpathMemberVisitor walks a parsed constraint carrying the entity each step is // evaluated against, the same traversal xpathrefs' walker performs for renames. type xpathMemberVisitor struct { - ctx *ExecContext - model xpathrefs.Model - bad []badStep - seen map[string]bool + ctx *ExecContext + model xpathrefs.Model + resolveBare func(entityQN, member string) memberResolution // nil: resolveMemberOnEntity + bad []badStep + seen map[string]bool } func (v *xpathMemberVisitor) add(s badStep) { @@ -290,7 +299,18 @@ func (v *xpathMemberVisitor) noteBare(name, cur string) { if cur == "" || name == "" { return } - if resolveMemberOnEntity(v.ctx, cur, name) == memberMissing { + // The members every entity has in XPath without declaring them. An entity + // stores createdDate as a flag, not an attribute, so the attribute lookup + // would call `[createdDate > …]` — which builds clean, measured on 11.13.0 — + // missing. + if xpathImplicitMembers[name] { + return + } + resolve := v.resolveBare + if resolve == nil { + resolve = func(e, m string) memberResolution { return resolveMemberOnEntity(v.ctx, e, m) } + } + if resolve(cur, name) == memberMissing { v.add(badStep{name: name, kind: "attribute"}) } } @@ -356,6 +376,12 @@ func (v *xpathMemberVisitor) walkPath(steps []ast.XPathStep, cur string) { } } +// xpathImplicitMembers are the bare names XPath resolves on every entity +// without the entity declaring an attribute of that name. +var xpathImplicitMembers = map[string]bool{ + "id": true, "createdDate": true, "changedDate": true, "owner": true, "changedBy": true, +} + // execXPathModel answers xpathrefs.Model from the connected project. type execXPathModel struct{ ctx *ExecContext } diff --git a/mdl/executor/validate_widget_unbuildable.go b/mdl/executor/validate_widget_unbuildable.go new file mode 100644 index 000000000..75c0f46b1 --- /dev/null +++ b/mdl/executor/validate_widget_unbuildable.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// A widget keyword the grammar accepts and the page builder has no writer for. +// +// `referenceselector` parses, so `check --references` reported "Check passed!", +// and `exec` then refused the page — after the statements before it had been +// written — with "unsupported widget type: referenceselector — … refresh the +// project's widget definitions: 'mxcli widget init'" (ako/mxcli#563). That hint +// does not apply: the classic reference selector is a built-in Forms widget, not +// a pluggable one, so there is no .mpk and `widget init` cannot help. +// +// formsWidgetsWithoutWriter is the one answer to "can mxcli build this +// keyword?" for these: the builder's fall-through reports with it, and the +// validator refuses with it, so check and exec say the same thing. +// TestFormsWidgetsWithoutWriterAreUnbuilt keeps it honest against the +// builder's dispatch: a keyword that gains a builder case must leave the set. +var formsWidgetsWithoutWriter = map[string]struct { + storedType string + replacement string +}{ + "referenceselector": { + storedType: "Forms$ReferenceSelector", + replacement: "use `combobox` over the association instead — it needs its own `datasource:` " + + "(CE0642 without one) and a caption attribute", + }, +} + +// unbuildableWidgetMessage is the refusal for a keyword mxcli has no writer +// for, or "" when it has one (or the keyword is not one of these). +func unbuildableWidgetMessage(widgetType string) string { + u, ok := formsWidgetsWithoutWriter[strings.ToLower(widgetType)] + if !ok { + return "" + } + return fmt.Sprintf("`%s` is the built-in Forms widget %s, which mxcli cannot write — it is not a "+ + "pluggable widget, so `mxcli widget init` does not help; %s", + strings.ToLower(widgetType), u.storedType, u.replacement) +} + +// validateUnbuildableWidgetKind refuses (MDL-WIDGET38) a widget keyword the page +// builder would refuse. A project's own widget definition of that MDL name wins, +// exactly as it does in the builder, whose registry lookup comes first. +func validateUnbuildableWidgetKind(w *ast.WidgetV3, registry *WidgetRegistry, locationPrefix string) []linter.Violation { + if w == nil || w.TypeIsGeneric { + return nil + } + msg := unbuildableWidgetMessage(w.Type) + if msg == "" { + return nil + } + if registry != nil { + if _, ok := registry.Get(strings.ToUpper(w.Type)); ok { + return nil + } + } + return []linter.Violation{{ + RuleID: "MDL-WIDGET38", + Severity: linter.SeverityError, + Message: fmt.Sprintf("%s: widget `%s`: %s", locationPrefix, w.Name, msg), + Suggestion: "replace the widget; exec refuses the page otherwise, after the statements before it were written", + }} +} diff --git a/mdl/executor/validate_widget_unbuildable_test.go b/mdl/executor/validate_widget_unbuildable_test.go new file mode 100644 index 000000000..b11fd00de --- /dev/null +++ b/mdl/executor/validate_widget_unbuildable_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// TestFormsWidgetsWithoutWriterAreUnbuilt keeps formsWidgetsWithoutWriter — the +// one answer check and exec share about which keywords the page builder cannot +// write (ako/mxcli#563) — in step with the builder. Each entry must be a widget +// keyword the grammar accepts, and must NOT be a case of buildWidgetV3's +// dispatch: a keyword that gains a builder has to leave the set, or check goes +// on refusing a widget exec can now write. +func TestFormsWidgetsWithoutWriterAreUnbuilt(t *testing.T) { + cases := builderSwitchCases(t) + if !cases["textbox"] || !cases["dataview"] { + t.Fatalf("could not read buildWidgetV3's dispatch (got %d cases) — the guard would pass vacuously", len(cases)) + } + grammar, err := os.ReadFile(filepath.Join("..", "grammar", "domains", "MDLPage.g4")) + if err != nil { + t.Fatal(err) + } + rule := string(grammar) + if i := strings.Index(rule, "\nwidgetTypeV3"); i >= 0 { + rule = rule[i:] + if j := strings.Index(rule, ";"); j >= 0 { + rule = rule[:j] + } + } + for kw := range formsWidgetsWithoutWriter { + if cases[kw] { + t.Errorf("%q has a case in buildWidgetV3 now — drop it from formsWidgetsWithoutWriter", kw) + } + if !strings.Contains(rule, strings.ToUpper(kw)) { + t.Errorf("%q is not a widgetTypeV3 keyword — formsWidgetsWithoutWriter lists only what parses", kw) + } + } +} + +// builderSwitchCases returns the string cases of the `switch strings.ToLower( +// w.Type)` in buildWidgetV3. +func builderSwitchCases(t *testing.T) map[string]bool { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "cmd_pages_builder_v3.go", nil, 0) + if err != nil { + t.Fatal(err) + } + out := map[string]bool{} + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Name.Name != "buildWidgetV3" { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + cc, ok := n.(*ast.CaseClause) + if !ok { + return true + } + for _, e := range cc.List { + if lit, ok := e.(*ast.BasicLit); ok && lit.Kind == token.STRING { + if s, err := strconv.Unquote(lit.Value); err == nil { + out[s] = true + } + } + } + return true + }) + } + return out +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 57709807f..38b23c759 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -184,6 +184,8 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc // A keyword whose stored $Type Mendix no longer has. Unlike MDL-WIDGET25 // this needs no project: the type is unknown to every Mendix version. out = append(out, validateRetiredWidgetKind(w, locationPrefix)...) + // A keyword the page builder has no writer for (ako/mxcli#563). + out = append(out, validateUnbuildableWidgetKind(w, registry, locationPrefix)...) out = append(out, validatePluggableWidgetProperties(w, registry, locationPrefix)...) // A repeatable property written as a property value — `attributes: // [(…)]` — which used to check clean, exec, and vanish (#999). Runs for @@ -279,14 +281,14 @@ func validateDatasourceXPathAssociationEmpty(w *ast.WidgetV3, locationPrefix str return nil } var out []linter.Violation - for _, assoc := range xpathAssociationEmptyMatches(ds.Where) { + for _, h := range xpathAssociationEmptyMatches(ds.Where) { out = append(out, linter.Violation{ RuleID: "MDL047", Severity: linter.SeverityError, Message: fmt.Sprintf( - "%s: widget `%s` datasource constraint tests association `%s = empty`, which Mendix XPath does not support (CE0161 \"Error(s) in XPath constraint\") — `= empty` works on attributes, not associations", - locationPrefix, w.Name, assoc), - Suggestion: fmt.Sprintf("Test for the absence of the associated object with negation: `[not(%s/)]`.", assoc), + "%s: widget `%s` datasource constraint tests association `%s`, which Mendix XPath does not support (CE0161 \"Error(s) in XPath constraint\") — `empty` compares attributes, not associations", + locationPrefix, w.Name, h.test()), + Suggestion: h.fix(), }) } return out From 4bca1f05702a49eb180280d8519bb70ab77ae4ac Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:23:21 +0000 Subject: [PATCH 11/23] fix(move): MOVE ENTITY handles existing cross-associations (#628) MoveEntity scanned only the plain associations of the source unit, so a cross-association created by an earlier move was invisible: moving its second endpoint left a ParentPointer naming an element absent from its unit and Studio Pro could not open the project. Handle the three shapes: convert back to a plain association when both endpoints share a module (raw transform, GUID carried), let an own cross-association travel with its FROM entity, and re-point a cross-association in another module. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-backend.jsonl | 1 + ...ove-entity-503-preserves-storage-guids.mdl | 6 +- ...entity-628-existing-cross-associations.mdl | 58 ++++ .../modelsdk/association_move_cross.go | 242 ++++++++++++++ .../modelsdk/association_move_write.go | 20 +- .../issue628_move_cross_assoc_test.go | 305 ++++++++++++++++++ mdl/executor/cmd_move.go | 22 +- mdl/types/entity_move.go | 4 + 8 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 mdl-examples/bug-tests/move-entity-628-existing-cross-associations.mdl create mode 100644 mdl/backend/modelsdk/association_move_cross.go create mode 100644 mdl/backend/modelsdk/issue628_move_cross_assoc_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index b42032884..62dc4c89c 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -149,3 +149,4 @@ {"area": "mdl/backend", "date": "2026-09-29", "symptom": "describe -> exec, CREATE OR MODIFY or an ALTER that rebuilds the document turns an API-exported document Hidden: enumerations, pages, layouts, rules, view-entity OQL source documents, import/export mappings, JSON structures, published and consumed REST services, scheduled events, workflows, database connections, business event services, data transformers, queues, regular expressions and agent-editor documents. The run reports success, mx check is clean; the module's public surface silently shrinks. A workflow's own `export level API` clause was a no-op on create and on rewrite.", "cause": "Each rewrite converter builds a fresh document and writes ExportLevel as a constant (\"Hidden\"), or passes the semantic model's value where the executor itself filled in \"Hidden\" (mappings, database connection, business events), and the unit is replaced wholesale. MDL has no export-level spelling for most of these kinds, so describe cannot print it and the executed script cannot restore it. workflowToGen ignored wf.ExportLevel entirely. The round-trip harness could not see it: every document in TestApp and PedApp is Hidden, the constant itself.", "file": "mdl/backend/modelsdk/export_level_carry.go", "fix": "One byte-level carry, keepStoredExportLevel(unitID, contents): replaces only the top-level ExportLevel element of the freshly encoded rewrite with the stored value, copying every other element verbatim, and never adds the key. Wired into every Update path that writes ExportLevel (UpdateEnumeration/Rule/Layout/ImportMapping/ExportMapping/JsonStructure/PublishedRestService/ConsumedRestService/DataTransformer/DatabaseConnection/BusinessEventService, writeCustomBlob update, WriteViewEntitySourceDocument update; page via carryStoredPageHeader). Kinds with an MDL spelling (workflow, scheduled event, queue, regular expression) use keepStoredExportLevelUnlessSet: an authored level wins. workflowToGen now writes orDefault(wf.ExportLevel, \"Hidden\").", "insight": "A fixture-driven round trip is blind to any constant that happens to equal every fixture value: 775 TestApp documents round-tripped while 10 kinds hid API documents. Set the subject to the non-default value first (here: patch ExportLevel to API on the working copy) and run both the plain describe output and an edited one, because an elided unchanged write passes a converter that still writes the constant. Carrying at the encoded-bytes level covers gen-typed, newElem-built and hand-serialized writers with one helper, where a gen setter per converter would have needed three mechanisms.", "refs": ["ako/mxcli#816", "ako/mxcli#801", "ako/mxcli#812"], "test": "mdl/backend/modelsdk/issue816_export_level_test.go (TestUpdatePaths_KeepStoredExportLevel, 18 kinds); mdl/roundtrip/export_level_test.go (TestTestAppExportLevelSurvivesRoundTrip, -tags integration)"} {"date": "2026-09-30", "area": "mdl/backend", "symptom": "ako/mxcli#859 review of PR #864: after the built comparison landed, changing or adding `show page M.P with title = 'X'` in a `create or modify microflow` reported \"Unchanged microflow\" and wrote nothing, under mdl 0 and mdl 1 (main spliced it). Nothing warned.", "cause": "builtAsStored compares the declared flow and the stored flow both READ BACK through the codec, so any property the reader drops compares equal whatever either side holds. The ShowFormAction reader never read FormSettings.TitleOverride. Probing encode(built) against encode(readback(built)) over mdl-examples found the reader also dropped ExclusiveSplit/LoopedActivity ErrorHandlingType and a REST call's bound output variable (ResultHandling.ResultVariableName -> RestCallAction.OutputVariable), plus CallWebServiceAction (#861). Before the built comparison such a loss was a visible phantom re-splice; after it, a silently dropped edit.", "fix": "ReadBackMicroflow/ReadBackNanoflow re-encode what they read back and refuse (error -> statement diff, the pre-#859 path) when it is not the document first written, $IDs aside (sameWritten). The reader now reads TitleOverride, the split's and loop's ErrorHandlingType, and a bound REST call's OutputVariable, so those flows keep matching.", "insight": "A comparison made on both sides through the same lossy reader cannot see what the reader loses; the lost property becomes a change that is never written. When equality is decided after a decode, prove the decode lossless for the value at hand (write it again and compare bytes) and fall back when it is not. The probe that found the fields: diff encode(x) with encode(decode(encode(x))) over every mdl-examples flow.", "issue": "ako/mxcli#859", "file": "mdl/backend/modelsdk/microflow_readback.go, mdl/backend/modelsdk/microflow_read_actions.go, mdl/backend/modelsdk/microflow.go, mdl/roundtrip/flow_idempotent_shapes_test.go"} {"date": "2026-09-30", "area": "mdl/backend", "symptom": "ako/mxcli#843 (rehearsal M2): under mdl 1, `create or modify nanoflow … returns Boolean as $Done` over a nanoflow stored without a return variable refuses \"the stored document has no ReturnVariableName property … set it in Studio Pro\"; the same statement on a microflow reports \"set: ReturnVariableName\".", "cause": "mfmutator.SetHeader refuses any stated header key the stored document lacks (a key the project version does not declare makes the document unopenable). mxcli's nanoflow writer omits ReturnVariableName when the statement has no `as $Var`, while the microflow writer always writes it on 10+, so only nanoflows hit the refusal.", "fix": "Optional mfmutator.PropertyDeclarer on Deps; the codec deps answer from the metamodel version data (type, then Microflows$MicroflowBase; ReturnVariableName is 10.12+) against the project version, and SetHeader inserts the key after its predecessor in the encoder's order. No answer (MCP, unknown version) keeps the refusal.", "insight": "A refusal keyed on 'the stored document lacks the key' conflates 'this version has no such property' with 'the writer left it out'; the metamodel version data separates the two. Studio Pro 11 stores ReturnVariableName on every nanoflow (PedApp: 13 of 13), so adding it matches what Studio Pro writes.", "issue": "ako/mxcli#843", "file": "mdl/backend/mfmutator/header.go"} +{"date": "2026-10-01", "area": "mdl/backend", "symptom": "ako/mxcli#628: `move entity A.Parent to B; move entity A.Child to B;` both report success, then Studio Pro cannot open the project: System.AggregateException: The given key '' was not present in the dictionary.", "cause": "MoveEntity scanned only sourceDM.AssociationsItems(); an existing cross-association (created by the first move) was invisible, so moving its FROM entity left its ParentPointer naming an element absent from the unit, and moving its TO entity left Child naming the old module. Cross-unit damage, so the #1119 write guard cannot see it.", "fix": "association_move_cross.go: own cross-associations (FROM = moved entity) travel to the target, or become a plain DomainModels$Association there when the TO entity is already in it (raw transform: $Type, Child -> ChildPointer binary id, connection points added, GUID passed through); a cross-association in the target naming the entity becomes plain; any other module's cross-association naming it is re-pointed. MovedAssociation.SameModule lets the executor report the plain conversions apart.", "insight": "Assert on the stored documents (every ParentPointer/ChildPointer resolves in its own unit, every cross Child resolves to an entity in the named module); mx check is a weak signal for this class. The four sequences (to-then-from, from-then-to, travel, re-point) are distinct shapes, not one.", "issue": "ako/mxcli#628", "file": "mdl/backend/modelsdk/association_move_cross.go"} diff --git a/mdl-examples/bug-tests/move-entity-503-preserves-storage-guids.mdl b/mdl-examples/bug-tests/move-entity-503-preserves-storage-guids.mdl index 898dec6bb..fd072be98 100644 --- a/mdl-examples/bug-tests/move-entity-503-preserves-storage-guids.mdl +++ b/mdl-examples/bug-tests/move-entity-503-preserves-storage-guids.mdl @@ -69,9 +69,9 @@ CREATE MODULE BugMoveGuid503B; -- -- Measured with a binary predating both fixes here, so it is a third defect on this -- command and not something these fixes introduced — but it is why this file keeps --- one endpoint of each pair put. Filed as ako/mxcli#628: MoveEntity scans --- AssociationsItems() and never CrossAssociationsItems(), so a cross-association --- that already exists is invisible to the move. +-- one endpoint of each pair put. Filed and fixed as ako/mxcli#628 (MoveEntity +-- never looked at CrossAssociationsItems()); the both-endpoints sequences are +-- exercised in move-entity-628-existing-cross-associations.mdl. -- Pair 1 — the TO side moves, so the cross-association STAYS in the source module -- and its qualified name does not change. diff --git a/mdl-examples/bug-tests/move-entity-628-existing-cross-associations.mdl b/mdl-examples/bug-tests/move-entity-628-existing-cross-associations.mdl new file mode 100644 index 000000000..c25d1eacb --- /dev/null +++ b/mdl-examples/bug-tests/move-entity-628-existing-cross-associations.mdl @@ -0,0 +1,58 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#628 — MOVE ENTITY must see the cross-associations that already exist +-- ============================================================================ +-- +-- MoveEntity converted the plain associations of the moved entity and never +-- looked at existing cross-associations. Moving the SECOND endpoint of an +-- association to another module — the normal way to reorganise a module one +-- entity at a time — left a cross-association whose ParentPointer named an +-- element absent from its unit, and Studio Pro could no longer open the project: +-- +-- System.AggregateException: The given key '' was not present in the dictionary. +-- +-- Three shapes, each below: +-- 1. both endpoints end up in one module -> a plain association again; +-- 2. the FROM end moves on -> the cross-association travels with it; +-- 3. the TO end moves on -> the cross-association elsewhere is re-pointed. +-- +-- mxcli exec move-entity-628-existing-cross-associations.mdl -p app.mpr +-- mxcli docker check -p app.mpr +-- +-- Expected: every statement succeeds and the project checks clean. Afterwards +-- describe association Bug628B.Child_Parent; -> from Bug628B.Child to Bug628B.Parent +-- describe association Bug628E.C2_P2; -> from Bug628E.C2 to Bug628D.P2 +-- describe association Bug628C.C3_P3; -> from Bug628C.C3 to Bug628E.P3 +-- +-- The storage GUID carry through each conversion is asserted on Studio +-- Pro-authored content in mdl/backend/modelsdk/issue628_move_cross_assoc_test.go; +-- in a script like this one every element is mxcli-created (GUID == $ID), so a +-- lost GUID is not observable here. +-- ============================================================================ + +create module Bug628A; +create module Bug628B; +create module Bug628C; +create module Bug628D; +create module Bug628E; + +-- Shape 1 (the issue's reproduction). +create or modify persistent entity Bug628A.Parent ( Code: String(50) ); +create or modify persistent entity Bug628A.Child ( Descr: String(100) ); +create or modify association Bug628A.Child_Parent from Bug628A.Child to Bug628A.Parent type Reference; +move entity Bug628A.Parent to Bug628B; +move entity Bug628A.Child to Bug628B; + +-- Shape 2. +create or modify persistent entity Bug628C.P2 ( Code: String(50) ); +create or modify persistent entity Bug628C.C2 ( Descr: String(100) ); +create or modify association Bug628C.C2_P2 from Bug628C.C2 to Bug628C.P2 type Reference; +move entity Bug628C.P2 to Bug628D; +move entity Bug628C.C2 to Bug628E; + +-- Shape 3. +create or modify persistent entity Bug628C.P3 ( Code: String(50) ); +create or modify persistent entity Bug628C.C3 ( Descr: String(100) ); +create or modify association Bug628C.C3_P3 from Bug628C.C3 to Bug628C.P3 type Reference; +move entity Bug628C.P3 to Bug628D; +move entity Bug628D.P3 to Bug628E; diff --git a/mdl/backend/modelsdk/association_move_cross.go b/mdl/backend/modelsdk/association_move_cross.go new file mode 100644 index 000000000..eec264a5c --- /dev/null +++ b/mdl/backend/modelsdk/association_move_cross.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/x/bsonx/bsoncore" + + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/element" + genDm "github.com/mendixlabs/mxcli/modelsdk/gen/domainmodels" + "github.com/mendixlabs/mxcli/modelsdk/meta" + mmpr "github.com/mendixlabs/mxcli/modelsdk/mpr" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// A MOVE ENTITY has to look at the cross-associations that already exist, not +// only at the plain associations it converts (ako/mxcli#628). A cross-association +// lives in the unit of its FROM entity, holds that entity by id (ParentPointer) +// and the TO entity by qualified name (Child). Moving entity E from S to T leaves +// three shapes that are not the same case: +// +// 1. Both endpoints end up in one module. The cross-association becomes a plain +// DomainModels$Association again in that module — the inverse of +// crossAssocRawFromAssoc. This is what moving the second endpoint of an +// association across, one entity at a time, produces. +// 2. E is the FROM entity and the TO entity is elsewhere. The cross-association +// travels with E to T (its ParentPointer would not resolve in S), so its +// qualified name changes S.X -> T.X. +// 3. E is the TO entity of a cross-association in ANOTHER module. That module's +// unit names `S.E`; it is re-pointed to `T.E`. +// +// Left alone, shape 1 and 2 leave a ParentPointer naming an element absent from +// its unit — Studio Pro fails to load the project ("The given key was not present +// in the dictionary") — and shape 3 leaves a dangling qualified name. Every +// conversion carries the stored document, GUID included: the runtime keys the +// association's data on that GUID (CLAUDE.md, "A GUID Is the Database's Identity"). + +// moveOwnCrossAssociations handles shapes 1 and 2: the cross-associations in the +// source unit whose FROM entity is the moved one. It removes them from sourceDM +// and adds them to targetDM, as plain associations when their TO entity lives in +// the target module. +func moveOwnCrossAssociations(sourceDM, targetDM *genDm.DomainModel, entityID model.ID, sourceModuleName, targetModuleName string) []types.MovedAssociation { + targetEntities := entityIDsByName(targetDM) + var out []types.MovedAssociation + var removeIdx []int + for i, el := range sourceDM.CrossAssociationsItems() { + ca, ok := el.(*genDm.CrossAssociation) + if !ok || string(ca.ParentRefID()) != string(entityID) { + continue + } + moved := types.MovedAssociation{ + Name: ca.Name(), + OldQualifiedName: sourceModuleName + "." + ca.Name(), + NewQualifiedName: targetModuleName + "." + ca.Name(), + } + mod, ent, _ := strings.Cut(ca.ChildQualifiedName(), ".") + if toID, ok := targetEntities[ent]; ok && mod == targetModuleName { + targetDM.AddAssociations(assocFromGenCrossAssoc(ca, toID)) + moved.SameModule = true + } else { + targetDM.AddCrossAssociations(cloneCrossAssoc(ca)) + } + removeIdx = append(removeIdx, i) + out = append(out, moved) + } + for i := len(removeIdx) - 1; i >= 0; i-- { + sourceDM.RemoveCrossAssociations(removeIdx[i]) + } + return out +} + +// convertIncomingCrossAssociations handles shape 1 seen from the other side: a +// cross-association in the TARGET unit whose TO entity is the moved one. Both +// endpoints are now in the target module, so it becomes a plain association +// there. Its qualified name does not change. +func convertIncomingCrossAssociations(targetDM *genDm.DomainModel, entityID model.ID, oldEntityQN, targetModuleName string) []types.MovedAssociation { + var out []types.MovedAssociation + var removeIdx []int + for i, el := range targetDM.CrossAssociationsItems() { + ca, ok := el.(*genDm.CrossAssociation) + if !ok || ca.ChildQualifiedName() != oldEntityQN { + continue + } + targetDM.AddAssociations(assocFromGenCrossAssoc(ca, string(entityID))) + removeIdx = append(removeIdx, i) + qn := targetModuleName + "." + ca.Name() + out = append(out, types.MovedAssociation{Name: ca.Name(), OldQualifiedName: qn, NewQualifiedName: qn, SameModule: true}) + } + for i := len(removeIdx) - 1; i >= 0; i-- { + targetDM.RemoveCrossAssociations(removeIdx[i]) + } + return out +} + +// repointCrossAssociationsElsewhere handles shape 3: every cross-association in a +// domain model other than the source and target units that names the moved entity +// as its TO entity is re-pointed to the entity's new qualified name. +func (b *Backend) repointCrossAssociationsElsewhere(sourceDMID, targetDMID model.ID, oldEntityQN, newEntityQN string) error { + dms, err := b.ListDomainModels() + if err != nil { + return fmt.Errorf("list domain models: %w", err) + } + for _, info := range dms { + if info.ID == sourceDMID || info.ID == targetDMID || string(info.ID) == meta.SystemDomainModelID { + continue + } + gdm, err := b.loadDomainModelGen(info.ID) + if err != nil { + return err + } + changed := false + for _, el := range gdm.CrossAssociationsItems() { + if ca, ok := el.(*genDm.CrossAssociation); ok && ca.ChildQualifiedName() == oldEntityQN { + ca.SetChildQualifiedName(newEntityQN) + changed = true + } + } + if changed { + if err := b.persistDM(info.ID, gdm); err != nil { + return err + } + } + } + return nil +} + +func entityIDsByName(dm *genDm.DomainModel) map[string]string { + out := map[string]string{} + for _, el := range dm.EntitiesItems() { + if e, ok := el.(*genDm.Entity); ok { + out[e.Name()] = string(e.ID()) + } + } + return out +} + +// cloneCrossAssoc re-homes a stored cross-association in another unit unchanged: +// a clean element over the stored bytes, so the encoder passes the whole document +// — GUID included — through verbatim. +func cloneCrossAssoc(ca *genDm.CrossAssociation) *genDm.CrossAssociation { + if raw := ca.Raw(); raw != nil { + out := genDm.NewCrossAssociation() + out.SetRaw(raw) + out.InitFromRaw(raw) + out.SetID(ca.ID()) + return out + } + return ca +} + +// assocFromGenCrossAssoc converts a cross-association back into the plain +// association both of whose endpoints now share its unit; childID is the TO +// entity's element id. Like the forward conversion it prefers a raw transform of +// the stored document, so the GUID and every untouched property survive; the +// property build is the fallback for a cross-association never persisted. +func assocFromGenCrossAssoc(ca *genDm.CrossAssociation, childID string) *genDm.Association { + if raw, ok := assocRawFromCrossAssoc(ca, childID); ok { + out := genDm.NewAssociation() + out.SetRaw(raw) + out.InitFromRaw(raw) + out.SetID(ca.ID()) + return out + } + + out := genDm.NewAssociation() + out.SetID(ca.ID()) + out.SetName(ca.Name()) + out.SetDocumentation(ca.Documentation()) + out.SetExportLevel(orDefault(ca.ExportLevel(), "Hidden")) + out.SetParentID(ca.ParentRefID()) + out.SetChildID(element.ID(childID)) + out.SetType(ca.Type()) + out.SetOwner(ca.Owner()) + out.SetStorageFormat(orDefault(ca.StorageFormat(), "Column")) + out.SetParentConnection(domainmodel.DefaultParentConnection) + out.SetChildConnection(domainmodel.DefaultChildConnection) + pdb, cdb := "DeleteMeButKeepReferences", "DeleteMeButKeepReferences" + if odb, ok := ca.DeleteBehavior().(*genDm.AssociationDeleteBehavior); ok { + pdb, cdb = orDefault(odb.ParentDeleteBehavior(), pdb), orDefault(odb.ChildDeleteBehavior(), cdb) + } + out.SetDeleteBehavior(deleteBehaviorToGen(pdb, cdb)) + if src, ok := ca.Source().(*genDm.OqlViewAssociationSource); ok && src != nil { + out.SetSource(oqlViewAssociationSourceToGen(src.Reference())) + } + assignID(out.DeleteBehavior()) + return out +} + +// assocRawFromCrossAssoc is the inverse of crossAssocRawFromAssoc: $Type becomes +// DomainModels$Association, Child (a qualified name) becomes ChildPointer (the TO +// entity's 16-byte id, resolvable now that it shares the unit), and the two +// connection points a plain association declares — the line's on-canvas anchors — +// are added with the defaults mxcli and Studio Pro write for a new association. +// Everything else, the GUID above all, passes through. The key set is the one +// `generated/metamodel` declares for DomainModelsAssociation. +func assocRawFromCrossAssoc(ca *genDm.CrossAssociation, childID string) (bson.Raw, bool) { + raw := ca.Raw() + if raw == nil { + return nil, false + } + elems, err := bsoncore.Document(raw).Elements() + if err != nil { + return nil, false + } + out := make(bson.D, 0, len(elems)+2) + child, parent := false, false + for _, e := range elems { + switch e.Key() { + case "$Type": + out = append(out, bson.E{Key: "$Type", Value: "DomainModels$Association"}) + case "Child": + out = append(out, + bson.E{Key: "ChildConnection", Value: domainmodel.DefaultChildConnection}, + bson.E{Key: "ChildPointer", Value: mmpr.IDToBsonBinary(childID)}) + child = true + case "ParentPointer": + v := e.Value() + out = append(out, + bson.E{Key: "ParentConnection", Value: domainmodel.DefaultParentConnection}, + bson.E{Key: "ParentPointer", Value: bson.RawValue{Type: bson.Type(v.Type), Value: v.Data}}) + parent = true + case "ChildConnection", "ParentConnection": + // Not declared on a cross-association; never carry a stray one twice. + default: + v := e.Value() + out = append(out, bson.E{Key: e.Key(), Value: bson.RawValue{Type: bson.Type(v.Type), Value: v.Data}}) + } + } + if !child || !parent { + return nil, false + } + b, err := bson.Marshal(out) + if err != nil { + return nil, false + } + return bson.Raw(b), true +} diff --git a/mdl/backend/modelsdk/association_move_write.go b/mdl/backend/modelsdk/association_move_write.go index 4f1e88384..2e84dbd91 100644 --- a/mdl/backend/modelsdk/association_move_write.go +++ b/mdl/backend/modelsdk/association_move_write.go @@ -368,6 +368,15 @@ func (b *Backend) MoveEntity(entity *domainmodel.Entity, sourceDMID, targetDMID sourceDM.RemoveAssociations(removeIdx[i]) } + // The cross-associations that already exist (ako/mxcli#628): the ones the moved + // entity is the FROM end of travel with it — or become plain associations again + // when their TO entity is already in the target — and the ones in the target + // that point at it become plain associations there. See association_move_cross.go. + oldEntityQN := sourceModuleName + "." + entity.Name + newEntityQN := targetModuleName + "." + entity.Name + converted = append(converted, moveOwnCrossAssociations(sourceDM, targetDM, entity.ID, sourceModuleName, targetModuleName)...) + converted = append(converted, convertIncomingCrossAssociations(targetDM, entity.ID, oldEntityQN, targetModuleName)...) + // Rewrite the moved entity's module-qualified refs (view source + validations). oldPrefix, newPrefix := sourceModuleName+".", targetModuleName+"." if entity.Source == "DomainModels$OqlViewEntitySource" && strings.HasPrefix(entity.SourceDocumentRef, oldPrefix) { @@ -390,9 +399,9 @@ func (b *Backend) MoveEntity(entity *domainmodel.Entity, sourceDMID, targetDMID // // An ATTRIBUTE reference always follows the entity, so its module prefix is // rewritten unconditionally. An ASSOCIATION reference is rewritten only for the - // associations this move actually sent to the target module: a pre-existing - // cross-association whose parent is the moved entity is not in the conversion - // list and does not travel, so a blanket prefix swap would break it. + // associations this move actually sent to the target module — a converted one, + // or a pre-existing cross-association the moved entity is the FROM end of + // (#628) — never by a blanket prefix swap. assocRenames := make(map[string]string, len(converted)) for _, m := range converted { if m.Moved() { @@ -438,5 +447,10 @@ func (b *Backend) MoveEntity(entity *domainmodel.Entity, sourceDMID, targetDMID if err := b.persistDM(targetDMID, targetDM); err != nil { return nil, fmt.Errorf("MoveEntity: persist target: %w", err) } + // A cross-association in any OTHER module that names the moved entity is + // re-pointed; nothing else in the move rewrites that module's unit (#628). + if err := b.repointCrossAssociationsElsewhere(sourceDMID, targetDMID, oldEntityQN, newEntityQN); err != nil { + return nil, fmt.Errorf("MoveEntity: re-point cross-associations: %w", err) + } return converted, nil } diff --git a/mdl/backend/modelsdk/issue628_move_cross_assoc_test.go b/mdl/backend/modelsdk/issue628_move_cross_assoc_test.go new file mode 100644 index 000000000..180a4dc9a --- /dev/null +++ b/mdl/backend/modelsdk/issue628_move_cross_assoc_test.go @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/model" + genDm "github.com/mendixlabs/mxcli/modelsdk/gen/domainmodels" +) + +// TestIssue628_MoveEntitySeesExistingCrossAssociations guards ako/mxcli#628: +// MoveEntity converted the plain associations of the moved entity and never +// looked at the cross-associations that already existed. Moving the second +// endpoint of an association across — the normal way to reorganise a module one +// entity at a time — left a cross-association whose ParentPointer named an +// element absent from its unit, and Studio Pro could no longer load the project +// ("The given key was not present in the dictionary"). +// +// The issue's honest assertion is on the stored documents: after every sequence, +// no association pointer may name an element absent from the unit it lives in, +// and no cross-association may name an entity that is not where it says. The +// association's storage GUID must survive each conversion, which only a Studio +// Pro-authored subject (PedApp, GUID != $ID) can detect. +func TestIssue628_MoveEntitySeesExistingCrossAssociations(t *testing.T) { + type step struct { + moveFrom bool // move the FROM (parent) entity; otherwise the TO (child) entity + to int // index into the three other modules + } + for _, tc := range []struct { + name string + steps []step + // wantPlainIn is the module index (into others) whose unit must hold the + // association as a plain association afterwards; -1 means it stays a + // cross-association. + wantPlainIn int + // wantCrossIn / wantChild, when the association stays cross-module: the + // unit holding it (-1 = the original module) and the module of its TO end. + wantCrossIn, wantChildIn int + }{ + // The issue's reproduction: shape 1 seen from the FROM end. + {name: "ToThenFrom_BackToPlain", steps: []step{{false, 0}, {true, 0}}, wantPlainIn: 0}, + // Shape 1 seen from the TO end: the cross-association is in the target. + {name: "FromThenTo_BackToPlain", steps: []step{{true, 0}, {false, 0}}, wantPlainIn: 0}, + // Shape 2: the FROM end moves on, the cross-association travels with it. + {name: "ToThenFromElsewhere_Travels", steps: []step{{false, 0}, {true, 1}}, wantPlainIn: -1, wantCrossIn: 1, wantChildIn: 0}, + // Shape 3: the TO end moves again; the cross-association stays in a module + // that is neither source nor target of the second move and is re-pointed. + {name: "ToThenToElsewhere_Repointed", steps: []step{{false, 0}, {false, 1}}, wantPlainIn: -1, wantCrossIn: -1, wantChildIn: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + proj := copyPedAppFixture(t) + b := New() + if err := b.Connect(proj); err != nil { + t.Fatalf("connect: %v", err) + } + srcID, srcMod, assocName, guid, childID, parentID := associationSubjectDistinct(t, b) + if guid == "" || guid == assocRawID(t, b, srcID, assocName) { + t.Fatalf("subject %s.%s: GUID %q must exist and differ from $ID", srcMod, assocName, guid) + } + others := otherDomainModels(t, b, srcID, 2) + + where := map[model.ID]int{childID: -1, parentID: -1} // -1 = srcMod + dmOf := func(i int) (model.ID, string) { + if i < 0 { + return srcID, srcMod + } + return others[i].id, others[i].name + } + for _, s := range tc.steps { + entID := childID + if s.moveFrom { + entID = parentID + } + fromDM, fromMod := dmOf(where[entID]) + toDM, toMod := dmOf(s.to) + ent := entityByID(t, b, fromDM, entID) + if _, err := b.MoveEntity(ent, fromDM, toDM, fromMod, toMod); err != nil { + t.Fatalf("MoveEntity %s.%s -> %s: %v", fromMod, ent.Name, toMod, err) + } + where[entID] = s.to + } + 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() }) + + assertNoDanglingAssociations(t, b2) + + if tc.wantPlainIn >= 0 { + dmID, mod := dmOf(tc.wantPlainIn) + if got := crossAssocGUID(t, b2, dmID, assocName); got != "" { + t.Errorf("%s is still a cross-association in %s; both endpoints are there", assocName, mod) + } + got, ok := plainAssocGUID(t, b2, dmID, assocName) + if !ok { + t.Fatalf("%s is not a plain association in %s after both endpoints moved there", assocName, mod) + } + if got != guid { + t.Errorf("%s: storage GUID changed %s -> %s", assocName, guid, got) + } + return + } + dmID, mod := dmOf(tc.wantCrossIn) + got := crossAssocGUID(t, b2, dmID, assocName) + if got != guid { + t.Errorf("%s in %s: storage GUID %q, want %s", assocName, mod, got, guid) + } + _, childMod := dmOf(tc.wantChildIn) + if ref := crossAssocChild(t, b2, dmID, assocName); !strings.HasPrefix(ref, childMod+".") { + t.Errorf("%s in %s names TO entity %q, want one in %s", assocName, mod, ref, childMod) + } + }) + } +} + +type dmRef struct { + id model.ID + name string +} + +// associationSubjectDistinct is associationSubject restricted to an association +// between two different entities, with no other association between the pair. +func associationSubjectDistinct(t *testing.T, b *Backend) (dmID model.ID, moduleName, assocName, assocGUID string, childID, parentID model.ID) { + t.Helper() + dms, err := b.ListDomainModels() + if err != nil { + t.Fatalf("ListDomainModels: %v", err) + } + for _, d := range dms { + gdm, err := b.loadDomainModelGen(d.ID) + if err != nil { + continue + } + mod, err := b.GetModule(d.ContainerID) + if err != nil || mod == nil { + continue + } + for _, el := range gdm.AssociationsItems() { + a, ok := el.(*genDm.Association) + if !ok || a.Raw() == nil || a.ChildRefID() == a.ParentRefID() { + continue + } + return d.ID, mod.Name, a.Name(), rawKeyHex(t, a.Raw(), "GUID"), + model.ID(a.ChildRefID()), model.ID(a.ParentRefID()) + } + } + t.Fatal("no loadable domain model with an association between two entities") + return +} + +func otherDomainModels(t *testing.T, b *Backend, exclude model.ID, n int) []dmRef { + t.Helper() + dms, err := b.ListDomainModels() + if err != nil { + t.Fatalf("ListDomainModels: %v", err) + } + var out []dmRef + for _, d := range dms { + if d.ID == exclude { + continue + } + if _, err := b.loadDomainModelGen(d.ID); err != nil { + continue + } + mod, err := b.GetModule(d.ContainerID) + if err != nil || mod == nil { + continue + } + out = append(out, dmRef{d.ID, mod.Name}) + if len(out) == n { + return out + } + } + t.Fatalf("fixture has fewer than %d other loadable domain models", n) + return nil +} + +func assocRawID(t *testing.T, b *Backend, dmID model.ID, name string) string { + t.Helper() + gdm, err := b.loadDomainModelGen(dmID) + if err != nil { + t.Fatalf("loadDomainModelGen: %v", err) + } + for _, el := range gdm.AssociationsItems() { + if a, ok := el.(*genDm.Association); ok && a.Name() == name { + return rawKeyHex(t, a.Raw(), "$ID") + } + } + return "" +} + +func plainAssocGUID(t *testing.T, b *Backend, dmID model.ID, name string) (string, bool) { + t.Helper() + gdm, err := b.loadDomainModelGen(dmID) + if err != nil { + t.Fatalf("loadDomainModelGen: %v", err) + } + for _, el := range gdm.AssociationsItems() { + if a, ok := el.(*genDm.Association); ok && a.Name() == name { + return rawKeyHex(t, a.Raw(), "GUID"), true + } + } + return "", false +} + +func crossAssocChild(t *testing.T, b *Backend, dmID model.ID, name string) string { + t.Helper() + gdm, err := b.loadDomainModelGen(dmID) + if err != nil { + t.Fatalf("loadDomainModelGen: %v", err) + } + for _, el := range gdm.CrossAssociationsItems() { + if ca, ok := el.(*genDm.CrossAssociation); ok && ca.Name() == name { + return ca.ChildQualifiedName() + } + } + return "" +} + +// assertNoDanglingAssociations is the stored-document form of "the project +// opens": every association pointer resolves inside its own unit, and every +// cross-association's TO name resolves to an entity in the module it names. +func assertNoDanglingAssociations(t *testing.T, b *Backend) { + t.Helper() + dms, err := b.ListDomainModels() + if err != nil { + t.Fatalf("ListDomainModels: %v", err) + } + type unit struct { + mod string + dm *genDm.DomainModel + } + var units []unit + entitiesByModule := map[string]map[string]bool{} + for _, d := range dms { + gdm, err := b.loadDomainModelGen(d.ID) + if err != nil { + continue + } + mod, err := b.GetModule(d.ContainerID) + if err != nil || mod == nil { + continue + } + units = append(units, unit{mod.Name, gdm}) + names := map[string]bool{} + for _, el := range gdm.EntitiesItems() { + if e, ok := el.(*genDm.Entity); ok { + names[e.Name()] = true + } + } + entitiesByModule[mod.Name] = names + } + for _, u := range units { + ids := map[string]bool{} + for _, el := range u.dm.EntitiesItems() { + ids[string(el.ID())] = true + } + for _, el := range u.dm.AssociationsItems() { + if a, ok := el.(*genDm.Association); ok { + if !ids[string(a.ParentRefID())] || !ids[string(a.ChildRefID())] { + t.Errorf("association %s.%s points at an entity outside its unit", u.mod, a.Name()) + } + } + } + for _, el := range u.dm.CrossAssociationsItems() { + ca, ok := el.(*genDm.CrossAssociation) + if !ok { + continue + } + if !ids[string(ca.ParentRefID())] { + t.Errorf("cross-association %s.%s: ParentPointer names an entity absent from its unit", u.mod, ca.Name()) + } + mod, ent, _ := strings.Cut(ca.ChildQualifiedName(), ".") + if mod == "System" { + continue + } + if !entitiesByModule[mod][ent] { + t.Errorf("cross-association %s.%s: Child %q does not resolve", u.mod, ca.Name(), ca.ChildQualifiedName()) + } + if mod == u.mod { + t.Errorf("cross-association %s.%s names an entity in its own module; it should be a plain association", u.mod, ca.Name()) + } + } + } +} + +// copyPedAppFixture copies the Studio Pro-authored PedApp fixture into a temp +// dir; its elements have GUID != $ID, so a lost storage GUID is observable. +func copyPedAppFixture(t *testing.T) string { + t.Helper() + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS("../../../testdata/pedapp")); err != nil { + t.Fatalf("copy PedApp fixture: %v", err) + } + return filepath.Join(dst, "PedApp.mpr") +} diff --git a/mdl/executor/cmd_move.go b/mdl/executor/cmd_move.go index 79fac38e3..7778fbdeb 100644 --- a/mdl/executor/cmd_move.go +++ b/mdl/executor/cmd_move.go @@ -410,9 +410,25 @@ func moveEntity(ctx *ExecContext, name ast.QualifiedName, sourceModule, targetMo } fmt.Fprintf(ctx.Output, "Moved entity %s to %s\n", name.String(), targetModule.Name) - if len(convertedAssocs) > 0 { - fmt.Fprintf(ctx.Output, "Converted %d association(s) to cross-module associations:\n", len(convertedAssocs)) - for _, assocName := range types.MovedAssociationNames(convertedAssocs) { + // A move that brings both endpoints of a cross-association into one module turns + // it back into a plain association (ako/mxcli#628); report the two apart. + var toCross, toPlain []types.MovedAssociation + for _, a := range convertedAssocs { + if a.SameModule { + toPlain = append(toPlain, a) + } else { + toCross = append(toCross, a) + } + } + if len(toCross) > 0 { + fmt.Fprintf(ctx.Output, "Converted %d association(s) to cross-module associations:\n", len(toCross)) + for _, assocName := range types.MovedAssociationNames(toCross) { + fmt.Fprintf(ctx.Output, " - %s\n", assocName) + } + } + if len(toPlain) > 0 { + fmt.Fprintf(ctx.Output, "Converted %d cross-module association(s) to associations within %s:\n", len(toPlain), targetModule.Name) + for _, assocName := range types.MovedAssociationNames(toPlain) { fmt.Fprintf(ctx.Output, " - %s\n", assocName) } } diff --git a/mdl/types/entity_move.go b/mdl/types/entity_move.go index e02fd24d5..ee8292e9a 100644 --- a/mdl/types/entity_move.go +++ b/mdl/types/entity_move.go @@ -27,6 +27,10 @@ type MovedAssociation struct { // NewQualifiedName is what it is called after it. Equal to OldQualifiedName // when the association did not change module. NewQualifiedName string + // SameModule reports that the move brought both endpoints into one module, so + // the association is now a plain (same-module) association rather than a + // cross-module one (ako/mxcli#628). + SameModule bool } // Moved reports whether the association's qualified name changed, i.e. whether From 63c8f877349f78250f957ffca40c58a0fb3656c0 Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:28:30 +0000 Subject: [PATCH 12/23] refactor(exec): the pre-flight checks are one function that returns the refusal exec's pre-flight (semantic checks, references, name clashes) moves out of the command into execPreflight, which prints what the checks report and returns the message exec exits with. No change in behaviour; diff is about to run the same checks. Co-Authored-By: Claude Opus 5.5 --- cmd/mxcli/cmd_exec.go | 96 +----------------------------- cmd/mxcli/exec_preflight.go | 114 ++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 93 deletions(-) create mode 100644 cmd/mxcli/exec_preflight.go diff --git a/cmd/mxcli/cmd_exec.go b/cmd/mxcli/cmd_exec.go index d50f5a2d8..a2429e997 100644 --- a/cmd/mxcli/cmd_exec.go +++ b/cmd/mxcli/cmd_exec.go @@ -9,7 +9,6 @@ import ( "path/filepath" "github.com/mendixlabs/mxcli/mdl/executor" - "github.com/mendixlabs/mxcli/mdl/linter" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/spf13/cobra" ) @@ -102,98 +101,9 @@ Example: os.Exit(1) } - // Pre-flight: refuse a script whose semantic checks report an error, - // rather than writing part of it and leaving the model to mxbuild. - // exec is not transactional, so "run it and see" means a half-applied - // model. Warnings are printed and do not stop the run. - if !skipCheck { - violations := executor.ApplyDeprecationPolicy(executor.ValidateProgram(prog, projectPath), depPolicy) - if len(violations) > 0 { - formatter := linter.GetFormatter(linter.OutputFormatText, true) - formatter.Format(violations, os.Stderr) - } - if summary := linter.Summarize(violations); summary.Errors > 0 { - fmt.Fprintf(os.Stderr, - "\nRefusing to execute: %d error(s) above. Nothing was written.\n"+ - " exec applies statements one at a time and cannot roll back, so a script\n"+ - " with a known error would leave the model partly updated.\n"+ - " Fix them, or re-run with --no-check to apply the script anyway.\n", - summary.Errors) - os.Exit(1) - } - } - - // Second preflight pass: resolve every NAME against the connected - // project. The semantic pass above cannot do this — a missing module, - // entity, page or microflow needs a backend, not a path — so `mxcli - // check -p` ran it and `exec` did not (#607). - // - // MEASURED on the expr-checker fixture, running the pre-fix binary - // (`--no-check` reproduces it), because the failure mode is not the one - // the refusal above describes and the difference matters: - // - // create entity "NotAModule"."Thing" -> exit 0, "Created module: - // NotAModule". A misspelled - // module is SILENTLY CREATED. - // microflow retrieving a missing entity -> exit 0, both documents - // written. The dangling name - // reaches the model and is - // not reported until mxbuild - // rejects it (CE1613). - // - // So exec did not half-apply here — it completed, and wrote a model that - // only a 25s build would reject. That makes this a check-to-build parity - // fix (moving a build-tier error to the 2s tier) and a fix for the - // "no silent side effects on typos" rule in CLAUDE.md's checklist, which - // auto-creating a module on a misspelling violates outright. - // - // Safe to refuse on, because the pass skips references to objects the - // script itself creates: an error from it means the name resolves to - // nothing in the project AND is not created here, so exec would have - // failed on it regardless — later, and after writing. - // - // Only possible with -p. A script that connects with its own CONNECT - // statement has no backend until ExecuteProgram runs, which is the same - // condition `check` gates this on. - // - // CheckProjectConflicts is deliberately NOT run here, though `check` - // runs it alongside this pass: a plain CREATE over an existing document - // is worth reporting when validating a script, but it is ordinary for a - // re-run, and refusing it would break scripts that work today. Its one - // exception is: a create over a name ANOTHER kind already has in the - // module (ako/mxcli#793) is never a re-run — `or modify` of a kind that - // lacks the name still adds an element — and Mendix rejects the model - // (CE0122 / CE0065), so it is refused here, after the references. - if !skipCheck && projectPath != "" { - refErrs, refWarnings := exec.ValidateProgramWithWarnings(prog) - for _, w := range refWarnings { - fmt.Fprintf(os.Stderr, "Reference warning: %s\n", w) - } - if len(refErrs) > 0 { - for _, refErr := range refErrs { - fmt.Fprintf(os.Stderr, "Reference error: %v\n", refErr) - } - fmt.Fprintf(os.Stderr, - "\nRefusing to execute: %d unresolved reference(s) above. Nothing was written.\n"+ - " A name that resolves to nothing is written into the model as it stands and\n"+ - " is not reported until mxbuild rejects it (CE1613) — and a misspelled MODULE\n"+ - " is created rather than refused.\n"+ - " Fix them, or re-run with --no-check to apply the script anyway.\n", - len(refErrs)) - os.Exit(1) - } - if clashes := exec.CheckProjectNameClashes(prog); len(clashes) > 0 { - for _, c := range clashes { - fmt.Fprintf(os.Stderr, "Name clash: %v\n", c) - } - fmt.Fprintf(os.Stderr, - "\nRefusing to execute: %d name clash(es) above. Nothing was written.\n"+ - " Mendix rejects the model (CE0122 / CE0065) however the script continues.\n"+ - " Rename, or re-run with --no-check to apply the script anyway (each clashing\n"+ - " create is still refused when it runs).\n", - len(clashes)) - os.Exit(1) - } + if refusal := execPreflight(exec, prog, projectPath, skipCheck, depPolicy, os.Stderr, true); refusal != "" { + fmt.Fprint(os.Stderr, refusal) + os.Exit(1) } if continueOnError { diff --git a/cmd/mxcli/exec_preflight.go b/cmd/mxcli/exec_preflight.go new file mode 100644 index 000000000..61432cdfa --- /dev/null +++ b/cmd/mxcli/exec_preflight.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "io" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// execPreflight runs exec's pre-flight checks on prog, printing what they +// report to w, and returns why exec refuses to run the script — the message it +// prints before exiting — or "" when it runs it. `mxcli diff` runs the same +// checks, so it refuses exactly the scripts exec refuses (ako/mxcli#807). +// +// exec is the executor the script would run on, connected to projectPath ("" when +// the script connects itself). +func execPreflight(exec *executor.Executor, prog *ast.Program, projectPath string, skipCheck bool, depPolicy deprecation.Policy, w io.Writer, color bool) string { + // Pre-flight: refuse a script whose semantic checks report an error, + // rather than writing part of it and leaving the model to mxbuild. + // exec is not transactional, so "run it and see" means a half-applied + // model. Warnings are printed and do not stop the run. + if !skipCheck { + violations := executor.ApplyDeprecationPolicy(executor.ValidateProgram(prog, projectPath), depPolicy) + if len(violations) > 0 { + formatter := linter.GetFormatter(linter.OutputFormatText, color) + formatter.Format(violations, w) + } + if summary := linter.Summarize(violations); summary.Errors > 0 { + return fmt.Sprintf( + "\nRefusing to execute: %d error(s) above. Nothing was written.\n"+ + " exec applies statements one at a time and cannot roll back, so a script\n"+ + " with a known error would leave the model partly updated.\n"+ + " Fix them, or re-run with --no-check to apply the script anyway.\n", + summary.Errors) + } + } + + // Second preflight pass: resolve every NAME against the connected + // project. The semantic pass above cannot do this — a missing module, + // entity, page or microflow needs a backend, not a path — so `mxcli + // check -p` ran it and `exec` did not (#607). + // + // MEASURED on the expr-checker fixture, running the pre-fix binary + // (`--no-check` reproduces it), because the failure mode is not the one + // the refusal above describes and the difference matters: + // + // create entity "NotAModule"."Thing" -> exit 0, "Created module: + // NotAModule". A misspelled + // module is SILENTLY CREATED. + // microflow retrieving a missing entity -> exit 0, both documents + // written. The dangling name + // reaches the model and is + // not reported until mxbuild + // rejects it (CE1613). + // + // So exec did not half-apply here — it completed, and wrote a model that + // only a 25s build would reject. That makes this a check-to-build parity + // fix (moving a build-tier error to the 2s tier) and a fix for the + // "no silent side effects on typos" rule in CLAUDE.md's checklist, which + // auto-creating a module on a misspelling violates outright. + // + // Safe to refuse on, because the pass skips references to objects the + // script itself creates: an error from it means the name resolves to + // nothing in the project AND is not created here, so exec would have + // failed on it regardless — later, and after writing. + // + // Only possible with -p. A script that connects with its own CONNECT + // statement has no backend until ExecuteProgram runs, which is the same + // condition `check` gates this on. + // + // CheckProjectConflicts is deliberately NOT run here, though `check` + // runs it alongside this pass: a plain CREATE over an existing document + // is worth reporting when validating a script, but it is ordinary for a + // re-run, and refusing it would break scripts that work today. Its one + // exception is: a create over a name ANOTHER kind already has in the + // module (ako/mxcli#793) is never a re-run — `or modify` of a kind that + // lacks the name still adds an element — and Mendix rejects the model + // (CE0122 / CE0065), so it is refused here, after the references. + if !skipCheck && projectPath != "" { + refErrs, refWarnings := exec.ValidateProgramWithWarnings(prog) + for _, warning := range refWarnings { + fmt.Fprintf(w, "Reference warning: %s\n", warning) + } + if len(refErrs) > 0 { + for _, refErr := range refErrs { + fmt.Fprintf(w, "Reference error: %v\n", refErr) + } + return fmt.Sprintf( + "\nRefusing to execute: %d unresolved reference(s) above. Nothing was written.\n"+ + " A name that resolves to nothing is written into the model as it stands and\n"+ + " is not reported until mxbuild rejects it (CE1613) — and a misspelled MODULE\n"+ + " is created rather than refused.\n"+ + " Fix them, or re-run with --no-check to apply the script anyway.\n", + len(refErrs)) + } + if clashes := exec.CheckProjectNameClashes(prog); len(clashes) > 0 { + for _, c := range clashes { + fmt.Fprintf(w, "Name clash: %v\n", c) + } + return fmt.Sprintf( + "\nRefusing to execute: %d name clash(es) above. Nothing was written.\n"+ + " Mendix rejects the model (CE0122 / CE0065) however the script continues.\n"+ + " Rename, or re-run with --no-check to apply the script anyway (each clashing\n"+ + " create is still refused when it runs).\n", + len(clashes)) + } + } + return "" +} From 4c1e5c3844c542907879fafa265a14de2c5d845b Mon Sep 17 00:00:00 2001 From: Ako Date: Thu, 1 Oct 2026 20:28:45 +0000 Subject: [PATCH 13/23] fix(diff): diff runs exec on a scratch copy and reports the units it writes (#907, #807) mxcli diff answered "what would exec write?" a second way: it rendered each statement as MDL and compared the text with the stored document's describe output, without running anything. Every normalisation exec applies had to be re-derived by that renderer, so it reported phantom changes (Boolean against Boolean default false, String against String(unlimited), positions, flow layout), did not compare pages, translations or layouts at all, said unchanged for a plain create exec refuses (#807), and could not diff a statement that depends on an earlier one (#856). The new mdl/scriptdiff package copies the project to a scratch folder, runs the script there with exec's own code (the same pre-flight, ExecuteProgram or the continue-on-error run, under the script's header), and compares the copy with the project unit by unit, the .mpr's container rows included so a move is seen. Each changed unit is rendered by DESCRIBE before and after; a domain model per entity and association, module and project security per role and demo user. A unit that is written but describes the same is listed with the properties that change. The scratch run always uses the file engine. The statement-based differ (cmd_diff_mdl.go, cmd_diff_render.go, spliceVerdict) and its tests are removed; diff gains --no-check, --continue-on-error, --deprecations and --exec-output as exec has them. Co-Authored-By: Claude Opus 5.5 --- .claude/commands/mendix/diff-script.md | 32 +- .../skills/mendix/choose-edit-mode/SKILL.md | 4 +- .claude/skills/mendix/mdl-entities/SKILL.md | 4 +- cmd/mxcli/cmd_diff.go | 76 +- cmd/mxcli/deprecations_flag.go | 3 +- cmd/mxcli/main.go | 3 + docs-site/src/tools/diff.md | 131 ++-- docs-site/src/tutorial/validation.md | 2 +- mdl/executor/cmd_associations_storage_test.go | 80 -- mdl/executor/cmd_diff.go | 705 ++---------------- mdl/executor/cmd_diff_if_not_exists_test.go | 62 -- mdl/executor/cmd_diff_mdl.go | 510 ------------- mdl/executor/cmd_diff_module_name_test.go | 170 ----- mdl/executor/cmd_diff_output.go | 18 +- mdl/executor/cmd_diff_render.go | 84 --- mdl/executor/cmd_diff_render_test.go | 87 --- mdl/roundtrip/flow_splice_parity_test.go | 34 +- mdl/roundtrip/flow_verdict_agreement_test.go | 7 +- mdl/scriptdiff/copy.go | 79 ++ mdl/scriptdiff/helpers_test.go | 168 +++++ mdl/scriptdiff/property_test.go | 143 ++++ mdl/scriptdiff/render.go | 477 ++++++++++++ mdl/scriptdiff/scriptdiff.go | 163 ++++ mdl/scriptdiff/scriptdiff_test.go | 193 +++++ mdl/scriptdiff/snapshot.go | 283 +++++++ mdl/scriptdiff/write.go | 70 ++ 26 files changed, 1820 insertions(+), 1768 deletions(-) delete mode 100644 mdl/executor/cmd_diff_if_not_exists_test.go delete mode 100644 mdl/executor/cmd_diff_mdl.go delete mode 100644 mdl/executor/cmd_diff_module_name_test.go delete mode 100644 mdl/executor/cmd_diff_render.go delete mode 100644 mdl/executor/cmd_diff_render_test.go create mode 100644 mdl/scriptdiff/copy.go create mode 100644 mdl/scriptdiff/helpers_test.go create mode 100644 mdl/scriptdiff/property_test.go create mode 100644 mdl/scriptdiff/render.go create mode 100644 mdl/scriptdiff/scriptdiff.go create mode 100644 mdl/scriptdiff/scriptdiff_test.go create mode 100644 mdl/scriptdiff/snapshot.go create mode 100644 mdl/scriptdiff/write.go diff --git a/.claude/commands/mendix/diff-script.md b/.claude/commands/mendix/diff-script.md index 7c9d18316..558bf0e69 100644 --- a/.claude/commands/mendix/diff-script.md +++ b/.claude/commands/mendix/diff-script.md @@ -72,25 +72,31 @@ Entity: MyModule.Order ## What Gets Compared -- **Entities**: Attributes, constraints, indexes, documentation -- **Enumerations**: Values and captions -- **Associations**: Type, owner, delete behavior -- **Microflows**: Parameters, return type, body statements +`mxcli diff` runs the script with exec's own code on a scratch copy of the +project, then compares the copy with the project unit by unit. It reports +exactly the units exec would write — every document kind (domain model per +entity and association, pages, snippets, layouts, flows, security, navigation, +settings, folders) and every statement kind — each shown as its `describe` +before and after. The project itself is not changed. + +A unit exec would rewrite although its description does not change is listed +on one line with the properties that change (`Modified: Page X: changed: +CanvasHeight`), or the folder it moves to. ## Summary Output Every diff ends with a summary: ``` -Summary: 2 new, 3 modified, 5 unchanged +Summary: 2 new, 3 modified, 0 removed — exec would write 4 unit(s) ``` -When a `create or modify` of an existing microflow or nanoflow would be refused -by `exec` (under `mdl 1;`, a change the splice cannot make), diff lists it as -`Refused: …` with the reason and adds `, N refused` to the summary. -One `exec` would write although its MDL renders as stored (a `folder` clause -naming another folder, applied as a move) is counted as modified and listed as -`Modified: … exec would write it: …`. +A script exec has already applied reports `exec would write nothing`. + +A script exec would refuse (its pre-flight checks) or a statement exec would +stop at (a plain `create` of an existing document; a flow change the splice +cannot make under `mdl 1;`) is reported as `Refused: …` with exec's message. +`--no-check` and `--continue-on-error` behave as they do for exec. ## Use Cases @@ -104,4 +110,6 @@ naming another folder, applied as a move) is counted as modified and listed as - Use `--color` for terminal output to easily spot changes - Use `--format struct` for a quick overview of what changes - Use `--format side` when comparing large objects -- Combine with `mxcli check` to validate syntax first +- Run it before `exec` on a Studio Pro-authored project: a write you did not + intend (a rewrite of a document you only meant to read back) shows up as a + modified unit diff --git a/.claude/skills/mendix/choose-edit-mode/SKILL.md b/.claude/skills/mendix/choose-edit-mode/SKILL.md index 7c61bcf41..bd0cc8164 100644 --- a/.claude/skills/mendix/choose-edit-mode/SKILL.md +++ b/.claude/skills/mendix/choose-edit-mode/SKILL.md @@ -26,7 +26,9 @@ MDL has two ways to change a document, and each is safe in a different situation Pro since). On a Studio Pro-authored document this path is lossy even when you change nothing. On real projects it has flipped association storage from table to column, dropped page translations, dropped nanoflow annotation links, changed export - levels, and dropped a snippet's type. `mxcli diff` shows none of these. + levels, and dropped a snippet's type. `mxcli diff` runs the script on a scratch + copy and lists every unit it would write, so run it first and expect "exec would + write nothing" for a document you did not mean to change. 4. **Never `drop` and re-create an existing document to change it.** The new document gets new identities. For an entity, the runtime then drops its table and its rows. diff --git a/.claude/skills/mendix/mdl-entities/SKILL.md b/.claude/skills/mendix/mdl-entities/SKILL.md index 6e1f6c3ce..ad0e570a7 100644 --- a/.claude/skills/mendix/mdl-entities/SKILL.md +++ b/.claude/skills/mendix/mdl-entities/SKILL.md @@ -346,8 +346,8 @@ alter enumeration Shop.OrderStatus add value Cancelled caption 'Cancelled'; ``` Re-running `create or modify` from `describe` on a Studio Pro association has flipped -its storage from table to column, which is a schema change, with `mxcli diff` reporting -no changes. Never `drop` and re-create an entity to change it: the new entity has a new +its storage from table to column, which is a schema change. Run `mxcli diff` first: it +runs the script on a scratch copy and lists every unit exec would write. Never `drop` and re-create an entity to change it: the new entity has a new identity, and the runtime drops the old table and its rows. ## Quick Reference diff --git a/cmd/mxcli/cmd_diff.go b/cmd/mxcli/cmd_diff.go index 6a89c83d7..7cced952a 100644 --- a/cmd/mxcli/cmd_diff.go +++ b/cmd/mxcli/cmd_diff.go @@ -4,20 +4,38 @@ package main import ( "fmt" + "io" "os" + "path/filepath" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/scriptdiff" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/spf13/cobra" ) var diffCmd = &cobra.Command{ Use: "diff ", - Short: "Compare an MDL script against the current project state", - Long: `Compare an MDL script file against the current state of a Mendix project. + Short: "Show what executing an MDL script would change in the project", + Long: `Show what "mxcli exec" would change in a Mendix project, without changing it. -Shows the differences between what the script would create/modify and what -currently exists in the project. +The script is executed by exec itself, against a scratch copy of the project, +and the copy is then compared with the project unit by unit. What diff reports +is therefore exactly what exec would write: the documents it adds, rewrites, +moves or removes (an entity or association counts as its own document), and +the files next to the model it writes. Each change is shown as the DESCRIBE of +the document before and after. A unit exec rewrites whose description does +not change says which properties change instead. + +Like exec, diff runs the pre-flight checks first (skip them with --no-check), +runs the statements in order under the script's language header, and stops at +the first error (--continue-on-error runs every statement). A script exec +would refuse, or a statement it would stop at, is reported as Refused. + +The project itself is only read. The scratch copy leaves out .git, deployment, +releases and node_modules, and is deleted afterwards. Output Formats: unified - Traditional unified diff format (default) @@ -44,6 +62,10 @@ Examples: format, _ := cmd.Flags().GetString("format") useColor, _ := cmd.Flags().GetBool("color") width, _ := cmd.Flags().GetInt("width") + skipCheck, _ := cmd.Flags().GetBool("no-check") + continueOnError, _ := cmd.Flags().GetBool("continue-on-error") + showExecOutput, _ := cmd.Flags().GetBool("exec-output") + depPolicy := deprecationPolicy(cmd) refuseJSONFlag("diff", "--format unified|side|struct") if projectPath == "" { @@ -51,14 +73,12 @@ Examples: os.Exit(1) } - // Read the script file - content, err := os.ReadFile(filePath) + content, err := readMDLSource(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) os.Exit(1) } - // Parse the script prog, errs := visitor.Build(string(content)) if len(errs) > 0 { fmt.Fprintf(os.Stderr, "Syntax errors found:\n") @@ -67,32 +87,36 @@ Examples: } os.Exit(1) } - - // Create executor and connect - exec, logger := newLoggedExecutor("subcommand") - defer logger.Close() - defer exec.Close() - exec.SetDescribeLanguage(mdlFlag(cmd)) - - connectProg, _ := visitor.Build(fmt.Sprintf("CONNECT LOCAL '%s'", visitor.QuoteString(projectPath))) - for _, stmt := range connectProg.Statements { - if err := exec.Execute(stmt); err != nil { - fmt.Fprintf(os.Stderr, "Error connecting: %v\n", err) - os.Exit(1) - } + if line, bad := unparsableInput(string(content), len(prog.Statements)); bad { + fmt.Fprintln(os.Stderr, unparsableInputError(filePath, line)) + os.Exit(1) } - // Run diff - opts := executor.DiffOptions{ - Format: executor.DiffFormat(format), - UseColor: useColor, - Width: width, + opts := scriptdiff.Options{ + // Always the file engine, whatever --mcp says: the script is + // executed for real, and only the scratch copy may receive it. + NewBackend: func() backend.FullBackend { return modelsdkbackend.New() }, + ContinueOnError: continueOnError, + Preflight: func(scratch *executor.Executor, w io.Writer) string { + return execPreflight(scratch, prog, projectPath, skipCheck, depPolicy, w, useColor) + }, + } + if filePath != "-" { + if abs, absErr := filepath.Abs(filePath); absErr == nil { + opts.ScriptDir = filepath.Dir(abs) + } } - if err := exec.DiffProgram(prog, opts); err != nil { + report, err := scriptdiff.Run(projectPath, prog, opts) + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + report.Write(os.Stdout, executor.DiffOptions{ + Format: executor.DiffFormat(format), + UseColor: useColor, + Width: width, + }, showExecOutput) }, } diff --git a/cmd/mxcli/deprecations_flag.go b/cmd/mxcli/deprecations_flag.go index db892c292..07126be89 100644 --- a/cmd/mxcli/deprecations_flag.go +++ b/cmd/mxcli/deprecations_flag.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -// The --deprecations flag decides what `check` and `exec` do with a deprecated +// The --deprecations flag decides what `check`, `exec` and `diff` do with a deprecated // MDL spelling (an MDL-DEPRnnn warning, registry in mdl/deprecation). The // default warns; `error` fails the run, so CI over docs, skills and examples // can hold them to the canonical form. @@ -20,6 +20,7 @@ const deprecationsFlagUsage = "What to do with a deprecated MDL spelling (MDL-DE func init() { checkCmd.Flags().String("deprecations", "warn", deprecationsFlagUsage) execCmd.Flags().String("deprecations", "warn", deprecationsFlagUsage) + diffCmd.Flags().String("deprecations", "warn", deprecationsFlagUsage) } // deprecationPolicy reads --deprecations, exiting with a usage error on a value diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 317be7b5b..d33319f05 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -364,6 +364,9 @@ func init() { diffCmd.Flags().StringP("format", "f", "unified", "Output format: unified, side, struct") diffCmd.Flags().BoolP("color", "", false, "Use colored output") diffCmd.Flags().IntP("width", "w", 120, "Terminal width for side-by-side format") + diffCmd.Flags().Bool("no-check", false, "Skip exec's pre-flight checks, as exec --no-check does") + diffCmd.Flags().Bool("continue-on-error", false, "Run every statement, as exec --continue-on-error does, instead of stopping at the first error") + diffCmd.Flags().Bool("exec-output", false, "Also print what exec reports while it runs the script on the scratch copy") // Diff-local command flags diffLocalCmd.Flags().StringP("ref", "r", "HEAD", "Git ref or range (e.g., HEAD, main, main..feature)") diff --git a/docs-site/src/tools/diff.md b/docs-site/src/tools/diff.md index 1729a56db..013011aa8 100644 --- a/docs-site/src/tools/diff.md +++ b/docs-site/src/tools/diff.md @@ -4,7 +4,8 @@ mxcli provides two diff commands for comparing MDL scripts against project state ## mxcli diff -Compares an MDL script against the current project state, showing what would change if the script were executed. This is a dry-run preview. +Shows what `mxcli exec` would change in the project if it ran the script, without +changing the project. **Usage:** @@ -12,82 +13,82 @@ Compares an MDL script against the current project state, showing what would cha mxcli diff -p app.mpr changes.mdl ``` -This shows: -- Elements that would be created (new entities, microflows, pages) -- Elements that would be modified (changed attributes, altered properties) -- Elements that would be removed (DROP statements) +### How it works: diff runs exec -Use `mxcli diff` to review changes before applying them, especially when working with AI-generated scripts. +`diff` copies the project to a scratch folder, runs the script there with exec's +own code — the same pre-flight checks, the statements in order, under the +script's language header — and then compares the copy with the project unit by +unit. What it reports is therefore exactly what `exec` would write: there is no +second judgement to disagree with exec's. The project itself is only read, and +the scratch copy is deleted afterwards (it leaves out `.git`, `deployment`, +`releases` and `node_modules`). -### What diff does not compare - -`diff` compares entities, view entities, enumerations, associations, microflows -and nanoflows. Every other statement — `grant`, `create constant`, pages, -navigation, settings — is listed under **Not compared** after the summary: +Each unit exec would add, rewrite, move or remove is shown as the `describe` of +the document before and after. A domain model is shown per entity and +association, module security per module role, and project security per user +role and demo user. This covers every document kind — pages, snippets, +layouts, navigation, settings, security, folders — and statements of every +kind, including `grant`, `alter`, `move` and `drop`. ``` -Summary: 0 new, 0 modified, 1 unchanged +--- Page.MyFirstModule.Home_Web (current) ++++ Page.MyFirstModule.Home_Web (script) +@@ -1,6 +1,6 @@ + create or modify page MyFirstModule.Home_Web ( + Title: 'Homepage', +- Layout: Atlas_Core.Atlas_TopBar, ++ Layout: Atlas_Core.Atlas_Default, + PopupResizable: true + ) { + +Summary: 0 new, 1 modified, 0 removed — exec would write 1 unit(s) +``` -Not compared (2 statement(s)) — diff has no comparison for these, -so they are absent from the summary above, not unchanged: - create constant x1 - grant microflow access x1 +A unit exec rewrites although its description does not change — a property +`describe` does not print, a translation, a move to another folder — is listed +on one line with what changes, so a write is never hidden behind identical MDL: + +``` +Modified: Page MyFirstModule.Home_Web: changed: CanvasHeight, CanvasWidth +Modified: Microflow Shop.ACT_Apply: moved to 'Shop/Archive' ``` -Read that list. The counts describe only the statements diff understands, so a -script made entirely of the others summarises as all zeros — which means "not -examined", not "no change". Those statements were previously skipped without a -word, so the summary looked like a clean bill of health for a script that would -add documents (#997). - -### Both sides go through one renderer - -The project side and the script side are rendered by the same describer -`describe microflow` uses, so an unmodified `describe` dump diffs as -**unchanged**. Before this, the script side had a renderer of its own that -covered 18 of 43 activity types and silently emitted nothing for the rest, so a -java-action call, a `download file` or a canvas annotation appeared as a -deletion in a script that changed nothing at all. - -### `create or modify` of a flow: diff reaches exec's verdict - -`exec` applies `create or modify microflow|nanoflow` on a flow that exists as a -patch spliced into the stored flow, and under `mdl 1;` it refuses a change the -splice cannot make (a change inside a loop body, a redrawn connector) rather -than rebuild the flow. `diff` runs the same patch planning, under the script's -own language header, without writing, so the two agree: - -- a statement `exec` would refuse is listed as **Refused**, with the reason, - and counted as refused in the summary; -- a statement whose patch is empty — nothing `exec` would write — is - **unchanged**, whatever the two renderings differ in (surrounding whitespace, - a bracketed `where [ … ]` against the bare form describe prints). An - expression is compared by its tokens: the case of a keyword (`AND`, `and`) - and the whitespace and line breaks between tokens — a member list laid out - over several lines — are not a change, and neither is `Long` for the one - Integer/Long type a flow stores (ako/mxcli#886); what is inside a string - literal is compared as written. A statement - that builds the flow that is stored is unchanged however that flow is - described: a guard clause `if … then return …; end if; return …;` that - describe prints as `if … then … else … end if`, a nested guard it prints with - a `join`/`merge` pair, a long flow it prints as crossed branches - (ako/mxcli#859); -- a statement `exec` would write although its MDL renders as stored — a - `folder` clause naming another folder, which `exec` applies as a move — is - **modified**, with what `exec` would write: - - ``` - Modified: Microflow Shop.ACT_Apply: its MDL renders as stored, but exec would write it: moved to folder 'Archive' - ``` +Files exec writes next to the model (Java sources, theme files) are listed as +`New file:` / `Modified file:`. + +### What diff no longer reports + +Because the verdict is exec's own, a script exec has already applied diffs as +`exec would write nothing`. Before (ako/mxcli#907), diff compared the script's +text with the stored document's description and reported phantom changes +exec never makes — `Boolean` against the stored `Boolean default false`, +`String` against `String(unlimited)`, a position, a re-laid-out flow — and +did not compare pages, translations or layouts at all. + +### Refusals and errors + +- A script exec's pre-flight refuses (a `check` error, an unresolved + reference, a name clash) is reported as **Refused**, with the same report + exec prints, and nothing is written. `--no-check` skips the pre-flight, as + it does for exec. +- A statement exec stops at — a plain `create` of a document that exists + (ako/mxcli#807), a `create or modify` of a flow the splice cannot make + under `mdl 1;` — is reported as **Refused** with exec's error, after the + changes exec makes before it. `--continue-on-error` runs every statement, + as it does for exec. +- Statements that depend on earlier ones (a flow calling a microflow the + script creates first) are diffed like any other, because the earlier ones + run first (ako/mxcli#856). ``` -Refused: Microflow Shop.ACT_Apply: exec would refuse this statement and write nothing: the Loop at (700, 200) changes inside its body; … +Refused: exec would stop at this error, having written nothing: entity already exists: Shop.Order — … -Summary: 0 new, 0 modified, 0 unchanged, 1 refused +Summary: 0 new, 0 modified, 0 removed — exec would write nothing ``` -Before this, `diff` compared renderings only and said "unchanged" for a -statement `exec` then refused (ako/mxcli#839). +`diff` always runs the script with the file engine on the scratch copy, also +under `--mcp`: the script is executed for real, and only the copy may receive +it. `--exec-output` prints what exec reports while it runs. ## mxcli diff-local diff --git a/docs-site/src/tutorial/validation.md b/docs-site/src/tutorial/validation.md index af79093f8..4da454b94 100644 --- a/docs-site/src/tutorial/validation.md +++ b/docs-site/src/tutorial/validation.md @@ -173,7 +173,7 @@ Before executing, you can preview what a script would change: mxcli diff -p app.mpr changes.mdl ``` -This compares the script against the current project state and shows what would be created, modified, or left unchanged. It does not modify the project. +This runs the script with exec's own code on a scratch copy of the project and shows every document exec would create, modify or remove, each as its `describe` before and after. It does not modify the project. ## What mxcli check catches automatically diff --git a/mdl/executor/cmd_associations_storage_test.go b/mdl/executor/cmd_associations_storage_test.go index 0b8824a09..86fb559d0 100644 --- a/mdl/executor/cmd_associations_storage_test.go +++ b/mdl/executor/cmd_associations_storage_test.go @@ -62,83 +62,3 @@ func TestDescribeAssociationStorageSurvivesReplay(t *testing.T) { }) } } - -// `mxcli diff` must show a storage change, and must not show one for a statement -// that leaves storage unstated (exec carries the stored value in that case). -func TestDiffAssociationComparesStorage(t *testing.T) { - base := func(s ast.StorageType) *ast.CreateAssociationStmt { - return &ast.CreateAssociationStmt{ - Name: ast.QualifiedName{Module: "M", Name: "Child_Parent"}, - Parent: ast.QualifiedName{Module: "M", Name: "Child"}, - Child: ast.QualifiedName{Module: "M", Name: "Parent"}, - Type: ast.AssocReference, - DeleteBehavior: ast.DeleteCascade, - Storage: s, - } - } - - ctx, assoc := assocFixture(t) - assoc.StorageFormat = domainmodel.StorageFormatTable - - r, err := diffAssociation(ctx, base(ast.StorageColumn)) - assertNoError(t, err) - if r.Proposed == r.Current { - t.Errorf("Table → Column reported as no change\n--- current ---\n%s\n--- proposed ---\n%s", r.Current, r.Proposed) - } - - for _, s := range []ast.StorageType{ast.StorageDefault, ast.StorageTable} { - r, err = diffAssociation(ctx, base(s)) - assertNoError(t, err) - if r.Proposed != r.Current { - t.Errorf("storage clause %d against stored Table reported as a change\n--- current ---\n%s\n--- proposed ---\n%s", - s, r.Current, r.Proposed) - } - } -} - -// A cross-module association lives in dm.CrossAssociations. The differ looked -// only in dm.Associations, so it reported every existing cross-module -// association as new — a storage flip on one was shown as a creation, never as -// the Table → Column change exec would make. -func TestDiffCrossModuleAssociationComparesStorage(t *testing.T) { - ctx, assoc := assocFixture(t) - dm, err := ctx.Backend.GetDomainModel("") - assertNoError(t, err) - dm.Associations = nil - dm.CrossAssociations = []*domainmodel.CrossModuleAssociation{{ - Name: "Child_Remote", - ParentID: assoc.ParentID, - ChildRef: "Other.Remote", - Type: domainmodel.AssociationTypeReference, - Owner: domainmodel.AssociationOwnerDefault, - StorageFormat: domainmodel.StorageFormatTable, - ChildDeleteBehavior: &domainmodel.DeleteBehavior{Type: domainmodel.DeleteBehaviorTypeDeleteMeAndReferences}, - }} - stmt := func(s ast.StorageType) *ast.CreateAssociationStmt { - return &ast.CreateAssociationStmt{ - Name: ast.QualifiedName{Module: "M", Name: "Child_Remote"}, - Parent: ast.QualifiedName{Module: "M", Name: "Child"}, - Child: ast.QualifiedName{Module: "Other", Name: "Remote"}, - Type: ast.AssocReference, - DeleteBehavior: ast.DeleteCascade, - Storage: s, - } - } - - r, err := diffAssociation(ctx, stmt(ast.StorageColumn)) - assertNoError(t, err) - if r.IsNew { - t.Fatalf("existing cross-module association reported as new\n%s", r.Proposed) - } - if r.Proposed == r.Current { - t.Errorf("cross-module Table → Column reported as no change\n%s", r.Current) - } - for _, s := range []ast.StorageType{ast.StorageDefault, ast.StorageTable} { - r, err = diffAssociation(ctx, stmt(s)) - assertNoError(t, err) - if r.Proposed != r.Current { - t.Errorf("cross-module storage %d against stored Table reported as a change\n--- current ---\n%s\n--- proposed ---\n%s", - s, r.Current, r.Proposed) - } - } -} diff --git a/mdl/executor/cmd_diff.go b/mdl/executor/cmd_diff.go index 8e04a48ba..9a9b22de1 100644 --- a/mdl/executor/cmd_diff.go +++ b/mdl/executor/cmd_diff.go @@ -1,18 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 -// Package executor - Diff command implementation for comparing MDL scripts against project state +// Package executor - diff results and their rendering, shared by `mxcli diff` +// (mdl/scriptdiff, which runs the script against a scratch copy) and +// `mxcli diff-local`. package executor import ( - "context" - "errors" "fmt" - "sort" - "strings" - "unicode" + "io" + + "github.com/pmezard/go-difflib/difflib" "github.com/mendixlabs/mxcli/mdl/ast" - mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" ) // DiffFormat represents the output format for diff results @@ -53,16 +52,13 @@ type DiffResult struct { ObjectType string ObjectName ast.QualifiedName Current string // MDL from MPR (empty if new) - Proposed string // MDL from script + Proposed string // MDL after the change (empty if deleted) IsNew bool IsDeleted bool Changes []StructuralChange - // Refused is why exec would refuse the statement, writing nothing, when - // it would (ako/mxcli#839); "" otherwise. - Refused string - // Writes is what exec would write when the two renderings are the same - // but exec still writes (a patch the rendering does not show, a move to - // another folder); "" otherwise. It makes the statement modified. + // Writes is what is written that the two renderings do not show (a move + // to another folder, a property describe does not print), or why they + // could not be made; "" otherwise. Writes string } @@ -75,667 +71,74 @@ const ( colorYellow = "\033[33m" ) -// DiffProgram compares an MDL program against the current project state -func diffProgram(ctx *ExecContext, prog *ast.Program, opts DiffOptions) error { - if !ctx.Connected() { - return mdlerrors.NewNotConnected() - } - - // Set defaults +// WriteDiffResults prints results in the chosen format. A result whose change +// the renderings do not show is printed as one line saying what is written. +func WriteDiffResults(w io.Writer, results []DiffResult, opts DiffOptions) { if opts.Format == "" { opts.Format = DiffFormatUnified } if opts.Width == 0 { opts.Width = 120 } - - var results []DiffResult - var newCount, modifiedCount, unchangedCount, refusedCount int - - // Track processed objects to avoid duplicates (script may have multiple statements for same object) - processed := make(map[string]bool) - - // Process each statement - // Statements diff cannot compare, and statements whose comparison failed. - // Both are reported rather than dropped — see unsupportedDiffError. - skipped := map[string]int{} - var failures []string - - for _, stmt := range prog.Statements { - result, err := diffStatement(ctx, stmt) - if err != nil { - var unsupported *unsupportedDiffError - if errors.As(err, &unsupported) { - skipped[unsupported.kind]++ - } else { - failures = append(failures, err.Error()) - } - continue - } - if result != nil { - // CREATE … IF NOT EXISTS on an element that is already there is - // skipped by exec and leaves the element untouched (#731), so what - // the script would leave behind is what is stored now. - if g, ok := stmt.(ast.IfNotExistsCreate); ok && g.CreateIfNotExists() && !result.IsNew { - result.Proposed = result.Current - result.Changes = nil - } - // Create unique key for deduplication - key := result.ObjectType + ":" + result.ObjectName.String() - if processed[key] { - // Skip duplicate - already processed this object - continue - } - processed[key] = true - - results = append(results, *result) - if result.Refused != "" { - refusedCount++ - } else if result.IsNew { - newCount++ - } else if result.Current != result.Proposed || result.Writes != "" { - modifiedCount++ - } else { - unchangedCount++ - } - } - } - - // Output results based on format + ctx := &ExecContext{Output: w} for _, result := range results { - if result.Refused != "" { - fmt.Fprintf(ctx.Output, "Refused: %s %s: exec would refuse this statement and write nothing: %s\n", - result.ObjectType, result.ObjectName, result.Refused) + verdict := "Modified" + switch { + case result.IsNew: + verdict = "New" + case result.IsDeleted: + verdict = "Removed" + } + shows := result.Current != result.Proposed + if !shows { + fmt.Fprintf(w, "%s: %s %s: %s\n", verdict, result.ObjectType, result.ObjectName, result.Writes) continue } - if result.Writes != "" && result.Current == result.Proposed { - fmt.Fprintf(ctx.Output, "Modified: %s %s: its MDL renders as stored, but exec would write it: %s\n", - result.ObjectType, result.ObjectName, result.Writes) - continue - } - if result.Current == result.Proposed && !result.IsNew { - // Skip unchanged objects unless showing structural - if opts.Format != DiffFormatStructural { - continue - } - } - switch opts.Format { - case DiffFormatUnified: - outputUnifiedDiff(ctx, result, opts.UseColor) case DiffFormatSideBySide: outputSideBySideDiff(ctx, result, opts.Width, opts.UseColor) case DiffFormatStructural: outputStructuralDiff(ctx, result, opts.UseColor) + default: + outputUnifiedDiff(ctx, result, opts.UseColor) } - } - - // Output summary - summary := fmt.Sprintf("\nSummary: %d new, %d modified, %d unchanged", newCount, modifiedCount, unchangedCount) - if refusedCount > 0 { - summary += fmt.Sprintf(", %d refused", refusedCount) - } - fmt.Fprintln(ctx.Output, summary) - reportUndiffed(ctx, skipped, failures) - - return nil -} - -// reportUndiffed prints what the summary above does NOT account for. -// -// The counts only ever describe statements diff understands, so a script made -// entirely of statements it does not understand summarises as all zeros. That -// reads as "nothing would change" for a script that may add documents, which -// is exactly the wrong answer from a pre-apply safety gate. -func reportUndiffed(ctx *ExecContext, skipped map[string]int, failures []string) { - if len(skipped) > 0 { - kinds := make([]string, 0, len(skipped)) - for k := range skipped { - kinds = append(kinds, k) - } - sort.Strings(kinds) - total := 0 - for _, n := range skipped { - total += n - } - fmt.Fprintf(ctx.Output, "\nNot compared (%d statement(s)) — diff has no comparison for these,\n"+ - "so they are absent from the summary above, not unchanged:\n", total) - for _, k := range kinds { - fmt.Fprintf(ctx.Output, " %s x%d\n", k, skipped[k]) - } - } - for _, f := range failures { - fmt.Fprintf(ctx.Output, "\nCould not diff: %s\n", f) - } -} - -// DiffProgram is a method wrapper for external callers. -// -// The program runs under its own language header, as exec runs it: what a -// statement means, and whether exec would refuse it, depends on it. -func (e *Executor) DiffProgram(prog *ast.Program, opts DiffOptions) error { - defer e.enterLanguage(prog.LanguageVersion)() - return diffProgram(e.newExecContext(context.Background()), prog, opts) -} - -// diffStatement generates a diff result for a single statement -func diffStatement(ctx *ExecContext, stmt ast.Statement) (*DiffResult, error) { - switch s := stmt.(type) { - case *ast.CreateEntityStmt: - return diffEntity(ctx, s) - case *ast.CreateViewEntityStmt: - return diffViewEntity(ctx, s) - case *ast.CreateEnumerationStmt: - return diffEnumeration(ctx, s) - case *ast.CreateAssociationStmt: - return diffAssociation(ctx, s) - case *ast.CreateMicroflowStmt: - return diffMicroflow(ctx, s) - case *ast.CreateNanoflowStmt: - return diffNanoflow(ctx, s) - default: - return nil, &unsupportedDiffError{kind: statementKindName(stmt)} - } -} - -// unsupportedDiffError marks a statement diff has no comparison for. It is an -// error rather than a nil result so that diffProgram can SAY so: skipping -// silently made `diff` report "0 new, 0 modified, 0 unchanged" for a script -// that would genuinely add documents, which is worse than a wrong count -// because there is nothing on screen to disbelieve (#997). -type unsupportedDiffError struct{ kind string } - -func (e *unsupportedDiffError) Error() string { - return "diff does not compare " + e.kind + " statements" -} - -// statementKindName turns an AST statement type into something an MDL author -// recognises: *ast.GrantMicroflowAccessStmt → "grant microflow access". -func statementKindName(stmt ast.Statement) string { - name := strings.TrimPrefix(fmt.Sprintf("%T", stmt), "*ast.") - name = strings.TrimSuffix(name, "Stmt") - var out []rune - for i, r := range name { - if i > 0 && r >= 'A' && r <= 'Z' { - out = append(out, ' ') - } - out = append(out, unicode.ToLower(r)) - } - return string(out) -} - -// diffEntity compares a CREATE ENTITY statement against the project -func diffEntity(ctx *ExecContext, s *ast.CreateEntityStmt) (*DiffResult, error) { - result := &DiffResult{ - ObjectType: "Entity", - ObjectName: s.Name, - Proposed: entityStmtToMDL(ctx, s), - } - - // Try to find existing entity - module, err := findModule(ctx, s.Name.Module) - if err != nil { - result.IsNew = true - return result, nil - } - - dm, err := ctx.Backend.GetDomainModel(module.ID) - if err != nil { - result.IsNew = true - return result, nil - } - - for _, entity := range dm.Entities { - if entity.Name == s.Name.Name { - // Found existing entity - get its MDL representation - result.Current = entityToMDL(ctx, module.Name, entity, dm) - result.Changes = compareEntities(ctx, result.Current, result.Proposed) - return result, nil - } - } - - result.IsNew = true - return result, nil -} - -// diffViewEntity compares a CREATE VIEW ENTITY statement against the project -func diffViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) (*DiffResult, error) { - result := &DiffResult{ - ObjectType: "View Entity", - ObjectName: s.Name, - Proposed: viewEntityStmtToMDL(ctx, s), - } - - module, err := findModule(ctx, s.Name.Module) - if err != nil { - result.IsNew = true - return result, nil - } - - dm, err := ctx.Backend.GetDomainModel(module.ID) - if err != nil { - result.IsNew = true - return result, nil - } - - for _, entity := range dm.Entities { - if entity.Name == s.Name.Name { - result.Current = viewEntityFromProjectToMDL(ctx, module.Name, entity, dm) - return result, nil - } - } - - result.IsNew = true - return result, nil -} - -// diffEnumeration compares a CREATE ENUMERATION statement against the project -func diffEnumeration(ctx *ExecContext, s *ast.CreateEnumerationStmt) (*DiffResult, error) { - result := &DiffResult{ - ObjectType: "Enumeration", - ObjectName: s.Name, - Proposed: enumerationStmtToMDL(ctx, s), - } - - // Try to find existing enumeration - existingEnum := findEnumeration(ctx, s.Name.Module, s.Name.Name) - if existingEnum == nil { - result.IsNew = true - return result, nil - } - - // ContainerID is a folder when the enumeration is filed in one, so walk up - // to the module before asking for its name; asking directly rendered the - // stored side as `create enumeration .Name` and made an untouched - // enumeration diff as modified (ako/mxcli#794). findEnumeration matched the - // statement's module through the same walk, so that is the module name. - h, _ := getHierarchy(ctx) - modName := h.GetModuleName(h.FindModuleID(existingEnum.ContainerID)) - result.Current = enumerationToMDL(ctx, modName, existingEnum) - result.Changes = compareEnumerations(ctx, result.Current, result.Proposed) - - return result, nil -} - -// diffAssociation compares a CREATE ASSOCIATION statement against the project -func diffAssociation(ctx *ExecContext, s *ast.CreateAssociationStmt) (*DiffResult, error) { - result := &DiffResult{ - ObjectType: "Association", - ObjectName: s.Name, - } - - module, err := findModule(ctx, s.Name.Module) - if err != nil { - result.IsNew = true - result.Proposed = associationStmtToMDL(ctx, s, "") - return result, nil - } - - dm, err := ctx.Backend.GetDomainModel(module.ID) - if err != nil { - result.IsNew = true - result.Proposed = associationStmtToMDL(ctx, s, "") - return result, nil - } - - for _, assoc := range dm.Associations { - if assoc.Name == s.Name.Name { - result.Current = associationToMDL(ctx, module.Name, assoc, dm) - result.Proposed = associationStmtToMDL(ctx, s, assoc.StorageFormat) - return result, nil - } - } - // A cross-module association is stored apart, in CrossAssociations; without - // this lookup every existing one diffed as new. - for _, ca := range dm.CrossAssociations { - if ca.Name == s.Name.Name { - result.Current = crossAssociationToMDL(module.Name, ca, dm) - result.Proposed = associationStmtToMDL(ctx, s, ca.StorageFormat) - return result, nil - } - } - - result.IsNew = true - result.Proposed = associationStmtToMDL(ctx, s, "") - return result, nil -} - -// diffMicroflow compares a CREATE MICROFLOW statement against the project -func diffMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) (*DiffResult, error) { - result := &DiffResult{ObjectType: "Microflow", ObjectName: s.Name} - - // Build the flow the script describes, without writing anything, then - // render it through the SAME describer the stored side goes through. The - // second AST-to-MDL renderer this replaces dropped every activity type it - // did not know, which the diff then showed as a deletion (#997). - built, err := buildMicroflowFromStmt(ctx, s, buildFlowOpts{}) - if err != nil { - return nil, err - } - proposed, err := renderFlowFromModel(ctx, "microflow", built.Microflow, s.Name) - if err != nil { - return nil, err - } - result.Proposed = proposed - - if built.ExistingID == "" { - result.IsNew = true - return result, nil - } - - stored, err := ctx.Backend.GetMicroflow(built.ExistingID) - if err != nil || stored == nil { - result.IsNew = true - return result, nil - } - current, err := renderFlowFromModel(ctx, "microflow", stored, s.Name) - if err != nil { - return nil, err - } - result.Current = current - result.Changes = compareMicroflows(ctx, result.Current, result.Proposed) - if s.CreateOrModify { - spliceVerdict(ctx, microflowDecl(s), result) - } - return result, nil -} - -// diffNanoflow compares a CREATE NANOFLOW statement against the project -func diffNanoflow(ctx *ExecContext, s *ast.CreateNanoflowStmt) (*DiffResult, error) { - result := &DiffResult{ObjectType: "Nanoflow", ObjectName: s.Name} - - built, err := buildNanoflowFromStmt(ctx, s, buildFlowOpts{}) - if err != nil { - return nil, err - } - proposed, err := renderFlowFromModel(ctx, "nanoflow", nanoflowAsMicroflow(built.Nanoflow), s.Name) - if err != nil { - return nil, err - } - result.Proposed = proposed - - if built.ExistingID == "" { - result.IsNew = true - return result, nil - } - - stored, err := ctx.Backend.GetNanoflow(built.ExistingID) - if err != nil || stored == nil { - result.IsNew = true - return result, nil - } - current, err := renderFlowFromModel(ctx, "nanoflow", nanoflowAsMicroflow(stored), s.Name) - if err != nil { - return nil, err - } - result.Current = current - result.Changes = compareMicroflows(ctx, result.Current, result.Proposed) - if s.CreateOrModify { - spliceVerdict(ctx, nanoflowDecl(s), result) - } - return result, nil -} - -// spliceVerdict brings the diff of a `create or modify` of a stored flow to the -// verdict exec reaches, which is not the rendered comparison above: exec -// patches the stored flow (diff-then-patch, planFlowModify), and it is the -// patch that decides whether anything is written (ako/mxcli#839, where diff -// said unchanged for a statement exec refused). -// -// - A change the splice cannot make is refused under mdl 1, and diff says -// so; under mdl 0 exec rebuilds the flow, which the rendered comparison -// already shows. -// - An empty patch in the same folder writes nothing, whatever the two -// renderings say, so it is unchanged. -// - A patch to make, or a move to another folder, is a write whatever the -// two renderings say: when they are the same (the rendering leaves the -// folder out), what exec would write is stated instead. -// -// An error exec would report itself leaves the rendered comparison as it is. -func spliceVerdict(ctx *ExecContext, d *flowDecl, result *DiffResult) { - v := decideFlowModify(ctx, d) - p := v.plan - switch { - case v.why != nil: - if v.refused { - result.Refused = v.why.reason - } - case v.err != nil || p == nil: - case p.mut == nil && !movesFolder(d.folder, p.storedFolder): - result.Proposed = result.Current - result.Changes = nil - case result.Current == result.Proposed: - var what []string - if s := patchSummary(p.ops, p.moves, p.set); s != "" { - what = append(what, s) - } - if movesFolder(d.folder, p.storedFolder) { - what = append(what, fmt.Sprintf("moved to folder '%s'", d.folder)) - } - if len(what) == 0 { - what = append(what, "patched") - } - result.Writes = strings.Join(what, "; ") - } -} - -// ============================================================================ -// Structural Comparison Functions -// ============================================================================ - -// compareEntities extracts structural changes between two entity MDL representations -func compareEntities(ctx *ExecContext, current, proposed string) []StructuralChange { - var changes []StructuralChange - - // Simple line-based comparison for now - currentLines := strings.Split(current, "\n") - proposedLines := strings.Split(proposed, "\n") - - // Extract attributes from both - currentAttrs := extractAttributes(ctx, currentLines) - proposedAttrs := extractAttributes(ctx, proposedLines) - - // Find added attributes - for name, proposed := range proposedAttrs { - if _, exists := currentAttrs[name]; !exists { - changes = append(changes, StructuralChange{ - ChangeType: ChangeAdded, - ElementType: "Attribute", - ElementName: name, - Details: proposed, - }) - } - } - - // Find removed attributes - for name := range currentAttrs { - if _, exists := proposedAttrs[name]; !exists { - changes = append(changes, StructuralChange{ - ChangeType: ChangeRemoved, - ElementType: "Attribute", - ElementName: name, - }) - } - } - - // Find modified attributes - for name, proposed := range proposedAttrs { - if current, exists := currentAttrs[name]; exists && current != proposed { - changes = append(changes, StructuralChange{ - ChangeType: ChangeModified, - ElementType: "Attribute", - ElementName: name, - Details: "changed", - }) - } - } - - return changes -} - -// compareEnumerations extracts structural changes between two enumeration MDL representations -func compareEnumerations(ctx *ExecContext, current, proposed string) []StructuralChange { - var changes []StructuralChange - - currentValues := extractEnumValues(ctx, strings.Split(current, "\n")) - proposedValues := extractEnumValues(ctx, strings.Split(proposed, "\n")) - - for name := range proposedValues { - if _, exists := currentValues[name]; !exists { - changes = append(changes, StructuralChange{ - ChangeType: ChangeAdded, - ElementType: "Value", - ElementName: name, - }) - } - } - - for name := range currentValues { - if _, exists := proposedValues[name]; !exists { - changes = append(changes, StructuralChange{ - ChangeType: ChangeRemoved, - ElementType: "Value", - ElementName: name, - }) + if result.Writes != "" { + fmt.Fprintf(w, "%s: %s %s: also %s\n\n", verdict, result.ObjectType, result.ObjectName, result.Writes) } } - - return changes } -// compareMicroflows extracts structural changes between two microflow MDL representations -func compareMicroflows(ctx *ExecContext, current, proposed string) []StructuralChange { - var changes []StructuralChange - - currentParams := extractParameters(ctx, strings.Split(current, "\n")) - proposedParams := extractParameters(ctx, strings.Split(proposed, "\n")) - - for name := range proposedParams { - if _, exists := currentParams[name]; !exists { - changes = append(changes, StructuralChange{ - ChangeType: ChangeAdded, - ElementType: "Parameter", - ElementName: name, - }) - } - } - - for name := range currentParams { - if _, exists := proposedParams[name]; !exists { - changes = append(changes, StructuralChange{ - ChangeType: ChangeRemoved, - ElementType: "Parameter", - ElementName: name, - }) - } +// LineChanges summarises the difference between two renderings as lines added +// and removed, for the structural format. +func LineChanges(current, proposed string) []StructuralChange { + if current == proposed { + return nil } - - // Count body statements - currentStmts := countBodyStatements(ctx, current) - proposedStmts := countBodyStatements(ctx, proposed) - if currentStmts != proposedStmts { - diff := proposedStmts - currentStmts - if diff > 0 { - changes = append(changes, StructuralChange{ - ChangeType: ChangeAdded, - ElementType: "Body", - ElementName: "statements", - Details: fmt.Sprintf("%d statements added", diff), - }) - } else { - changes = append(changes, StructuralChange{ - ChangeType: ChangeRemoved, - ElementType: "Body", - ElementName: "statements", - Details: fmt.Sprintf("%d statements removed", -diff), - }) - } - } - - return changes -} - -// extractAttributes extracts attribute definitions from MDL lines -func extractAttributes(_ *ExecContext, lines []string) map[string]string { - attrs := make(map[string]string) - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.Contains(line, ":") && !strings.HasPrefix(line, "create") && !strings.HasPrefix(line, "/**") && !strings.HasPrefix(line, "*") { - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - name := strings.TrimSpace(parts[0]) - if !strings.HasPrefix(name, "$") && !strings.HasPrefix(name, "@") { - attrs[name] = strings.TrimSuffix(strings.TrimSpace(parts[1]), ",") - } - } + lines := func(s string) []string { + if s == "" { + return nil } + return difflib.SplitLines(s) } - return attrs -} - -// extractEnumValues extracts enumeration values from MDL lines -func extractEnumValues(_ *ExecContext, lines []string) map[string]bool { - values := make(map[string]bool) - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.Contains(line, "'") && !strings.HasPrefix(line, "create") { - parts := strings.Fields(line) - if len(parts) >= 1 { - name := strings.TrimSuffix(parts[0], ",") - if name != "" && !strings.HasPrefix(name, "/") && !strings.HasPrefix(name, "*") { - values[name] = true - } - } + m := difflib.NewMatcher(lines(current), lines(proposed)) + added, removed := 0, 0 + for _, op := range m.GetOpCodes() { + switch op.Tag { + case 'r': + removed += op.I2 - op.I1 + added += op.J2 - op.J1 + case 'd': + removed += op.I2 - op.I1 + case 'i': + added += op.J2 - op.J1 } } - return values -} - -// extractParameters extracts parameter names from MDL lines -func extractParameters(_ *ExecContext, lines []string) map[string]bool { - params := make(map[string]bool) - inParams := false - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "create microflow") || strings.HasPrefix(line, "create nanoflow") || - strings.HasPrefix(line, "create or modify microflow") || strings.HasPrefix(line, "create or modify nanoflow") { - inParams = true - continue - } - if inParams { - if strings.HasPrefix(line, ")") { - inParams = false - continue - } - if strings.HasPrefix(line, "$") { - parts := strings.SplitN(line, ":", 2) - if len(parts) >= 1 { - name := strings.TrimPrefix(parts[0], "$") - name = strings.TrimSuffix(name, ",") - params[strings.TrimSpace(name)] = true - } - } - } + var out []StructuralChange + if added > 0 { + out = append(out, StructuralChange{ChangeType: ChangeAdded, ElementType: "Lines", Details: fmt.Sprintf("%d line(s) added", added)}) } - return params -} - -// countBodyStatements counts statements in a microflow body -func countBodyStatements(_ *ExecContext, mdl string) int { - count := 0 - inBody := false - for line := range strings.SplitSeq(mdl, "\n") { - line = strings.TrimSpace(line) - if line == "begin" { - inBody = true - continue - } - if line == "end;" { - break - } - if inBody && line != "" && !strings.HasPrefix(line, "--") { - count++ - } + if removed > 0 { + out = append(out, StructuralChange{ChangeType: ChangeRemoved, ElementType: "Lines", Details: fmt.Sprintf("%d line(s) removed", removed)}) } - return count + return out } diff --git a/mdl/executor/cmd_diff_if_not_exists_test.go b/mdl/executor/cmd_diff_if_not_exists_test.go deleted file mode 100644 index af81992aa..000000000 --- a/mdl/executor/cmd_diff_if_not_exists_test.go +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "strings" - "testing" - - "github.com/mendixlabs/mxcli/mdl/backend/mock" - "github.com/mendixlabs/mxcli/mdl/visitor" - "github.com/mendixlabs/mxcli/model" -) - -// diffEnumScript diffs src against a project holding enumeration M.E (Red). -func diffEnumScript(t *testing.T, src string) string { - t.Helper() - mod := mkModule("M") - enum := mkEnumeration(mod.ID, "E", "Red") - mb := &mock.MockBackend{ - IsConnectedFunc: func() bool { return true }, - ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, - ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return []*model.Enumeration{enum}, nil }, - } - ctx, out := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) - prog, errs := visitor.Build(src) - if len(errs) > 0 { - t.Fatalf("parse %q: %v", src, errs) - } - if err := diffProgram(ctx, prog, DiffOptions{}); err != nil { - t.Fatalf("diff: %v", err) - } - return out.String() -} - -// diff is the pre-apply view of exec, so a guarded create of an element that -// already exists — which exec skips and leaves untouched (ako/mxcli#731) — is -// unchanged, not modified. -func TestDiff_CreateIfNotExistsOnExistingElementIsUnchanged(t *testing.T) { - out := diffEnumScript(t, "create enumeration if not exists M.E (Blue 'Blue');") - if !strings.Contains(out, "Summary: 0 new, 0 modified, 1 unchanged") { - t.Errorf("a guarded create of an existing element must diff as unchanged; got:\n%s", out) - } - if strings.Contains(out, "Blue") { - t.Errorf("diff shows the script's definition, which exec will not apply:\n%s", out) - } -} - -// CONTROL: without the guard the same statement is a modification. -func TestDiff_UnguardedCreateOnExistingElementIsModified(t *testing.T) { - out := diffEnumScript(t, "create or modify enumeration M.E (Blue 'Blue');") - if !strings.Contains(out, "Summary: 0 new, 1 modified, 0 unchanged") { - t.Errorf("control: an unguarded create of a changed element must diff as modified; got:\n%s", out) - } -} - -// CONTROL: a guarded create of an absent element is new. -func TestDiff_CreateIfNotExistsOnAbsentElementIsNew(t *testing.T) { - out := diffEnumScript(t, "create enumeration if not exists M.F (Blue 'Blue');") - if !strings.Contains(out, "Summary: 1 new, 0 modified, 0 unchanged") { - t.Errorf("a guarded create of an absent element must diff as new; got:\n%s", out) - } -} diff --git a/mdl/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go deleted file mode 100644 index cd6ba7f1a..000000000 --- a/mdl/executor/cmd_diff_mdl.go +++ /dev/null @@ -1,510 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package executor - MDL generation functions for diff (statement→text and project→text converters) -package executor - -import ( - "fmt" - "strings" - - "github.com/mendixlabs/mxcli/mdl/ast" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" -) - -// ============================================================================ -// Statement to MDL Converters -// ============================================================================ - -// entityStmtToMDL converts a CreateEntityStmt to MDL text -func entityStmtToMDL(ctx *ExecContext, s *ast.CreateEntityStmt) string { - var lines []string - - // Documentation - if s.Documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+s.Documentation) - lines = append(lines, " */") - } - - // Position annotation - if s.Position != nil { - lines = append(lines, fmt.Sprintf("@Position(%d, %d)", s.Position.X, s.Position.Y)) - } - - // Entity type. EntityKind.String() is upper case for error messages; the - // project side of the diff writes the keyword in lower case, and an - // unmodified describe dump was reported as modified purely on that casing - // (#997, the same two-renderer split as the flow body). - entityType := strings.ToLower(s.Kind.String()) - lines = append(lines, fmt.Sprintf("create %s entity %s (", entityType, s.Name)) - - // Attributes - for i, attr := range s.Attributes { - // Attribute documentation - if attr.Documentation != "" { - lines = append(lines, fmt.Sprintf(" /** %s */", attr.Documentation)) - } - - typeStr := dataTypeToString(ctx, attr.Type) - constraints := "" - - if attr.NotNull { - constraints += " not null" - if attr.NotNullError != "" { - constraints += fmt.Sprintf(" error message '%s'", attr.NotNullError) - } - } - if attr.Unique { - constraints += " unique" - if attr.UniqueError != "" { - constraints += fmt.Sprintf(" error message '%s'", attr.UniqueError) - } - } - if attr.HasDefault { - defaultVal := fmt.Sprintf("%v", attr.DefaultValue) - if attr.Type.Kind == ast.TypeString { - defaultVal = fmt.Sprintf("'%s'", attr.DefaultValue) - } - constraints += fmt.Sprintf(" default %s", defaultVal) - } - - comma := "," - if i == len(s.Attributes)-1 { - comma = "" - } - lines = append(lines, fmt.Sprintf(" %s: %s%s%s", attr.Name, typeStr, constraints, comma)) - } - - lines = append(lines, ")") - - // Indexes - for _, idx := range s.Indexes { - var cols []string - for _, col := range idx.Columns { - colStr := col.Name - if col.Descending { - colStr += " desc" - } - cols = append(cols, colStr) - } - lines = append(lines, fmt.Sprintf("index (%s)", strings.Join(cols, ", "))) - } - - lines = append(lines, ";") - - return strings.Join(lines, "\n") -} - -// viewEntityStmtToMDL converts a CreateViewEntityStmt to MDL text -func viewEntityStmtToMDL(ctx *ExecContext, s *ast.CreateViewEntityStmt) string { - var lines []string - - if s.Documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+s.Documentation) - lines = append(lines, " */") - } - - if s.Position != nil { - lines = append(lines, fmt.Sprintf("@Position(%d, %d)", s.Position.X, s.Position.Y)) - } - - lines = append(lines, fmt.Sprintf("create view entity %s (", s.Name)) - - for i, attr := range s.Attributes { - typeStr := dataTypeToString(ctx, attr.Type) - comma := "," - if i == len(s.Attributes)-1 { - comma = "" - } - lines = append(lines, fmt.Sprintf(" %s: %s%s", attr.Name, typeStr, comma)) - } - - lines = append(lines, ") as (") - // Indent OQL query - for line := range strings.SplitSeq(s.Query.RawQuery, "\n") { - lines = append(lines, " "+line) - } - lines = append(lines, ");") - - return strings.Join(lines, "\n") -} - -// enumerationStmtToMDL converts a CreateEnumerationStmt to MDL text -func enumerationStmtToMDL(ctx *ExecContext, s *ast.CreateEnumerationStmt) string { - var lines []string - - if s.Documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+s.Documentation) - lines = append(lines, " */") - } - - lines = append(lines, fmt.Sprintf("create enumeration %s (", s.Name)) - - for i, v := range s.Values { - comma := "," - if i == len(s.Values)-1 { - comma = "" - } - lines = append(lines, fmt.Sprintf(" %s '%s'%s", v.Name, v.Caption, comma)) - } - - lines = append(lines, ");") - - return strings.Join(lines, "\n") -} - -// associationStmtToMDL converts a CreateAssociationStmt to MDL text -// -// stored is the existing association's storage format ("" when there is none), -// so an unstated storage renders as what exec would leave in place rather than -// as a change. -func associationStmtToMDL(ctx *ExecContext, s *ast.CreateAssociationStmt, stored domainmodel.AssociationStorageFormat) string { - var lines []string - - if s.Documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+s.Documentation) - lines = append(lines, " */") - } - - lines = append(lines, fmt.Sprintf("create association %s", s.Name)) - lines = append(lines, fmt.Sprintf("from %s to %s", s.Parent, s.Child)) - - assocType := "Reference" - if s.Type == ast.AssocReferenceSet { - assocType = "ReferenceSet" - } - lines = append(lines, fmt.Sprintf("type %s", assocType)) - - owner := "Default" - if s.Owner == ast.OwnerBoth { - owner = "Both" - } - lines = append(lines, fmt.Sprintf("owner %s", owner)) - - storage, stated := statedStorageFormat(s.Storage) - if !stated { - storage = stored - if storage == "" { - storage = domainmodel.StorageFormatColumn - } - } - if line := storageClause(storage); line != "" { - lines = append(lines, line) - } - - deleteBehavior := "set null" - switch s.DeleteBehavior { - case ast.DeleteCascade: - deleteBehavior = "cascade" - case ast.DeleteIfNoReferences: - deleteBehavior = "restrict" - } - lines = append(lines, fmt.Sprintf("on delete %s;", deleteBehavior)) - - return strings.Join(lines, "\n") -} - -// ============================================================================ -// Project to MDL Converters -// ============================================================================ - -// entityToMDL converts a project entity to MDL text -func entityToMDL(ctx *ExecContext, moduleName string, entity *domainmodel.Entity, dm *domainmodel.DomainModel) string { - var lines []string - lang := describeDefaultLanguage(ctx) - - // Documentation - if entity.Documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+entity.Documentation) - lines = append(lines, " */") - } - - // Position - lines = append(lines, fmt.Sprintf("@Position(%d, %d)", entity.Location.X, entity.Location.Y)) - - // Entity type - entityType := "persistent" - if strings.Contains(entity.Source, "OqlView") { - entityType = "view" - } else if !entity.Persistable { - entityType = "non-persistent" - } - - lines = append(lines, fmt.Sprintf("create %s entity %s.%s (", entityType, moduleName, entity.Name)) - - // Build validation rules map - validationsByAttr := make(map[model.ID][]*domainmodel.ValidationRule) - validationsByName := make(map[string][]*domainmodel.ValidationRule) - for _, vr := range entity.ValidationRules { - validationsByAttr[vr.AttributeID] = append(validationsByAttr[vr.AttributeID], vr) - attrName := extractAttrNameFromQualified(string(vr.AttributeID)) - if attrName != "" { - validationsByName[attrName] = append(validationsByName[attrName], vr) - } - } - - // Attributes - for i, attr := range entity.Attributes { - // Documentation - if attr.Documentation != "" { - lines = append(lines, fmt.Sprintf(" /** %s */", attr.Documentation)) - } - - typeStr := formatAttributeType(attr.Type) - var constraints strings.Builder - - // Check for validation rules - attrValidations := validationsByAttr[attr.ID] - if len(attrValidations) == 0 { - attrValidations = validationsByName[attr.Name] - } - for _, vr := range attrValidations { - if vr.Type == "Required" { - constraints.WriteString(" not null") - if errMsg := pickTextTranslation(vr.ErrorMessage, lang); errMsg != "" { - constraints.WriteString(fmt.Sprintf(" error message '%s'", errMsg)) - } - } - if vr.Type == "Unique" { - constraints.WriteString(" unique") - if errMsg := pickTextTranslation(vr.ErrorMessage, lang); errMsg != "" { - constraints.WriteString(fmt.Sprintf(" error message '%s'", errMsg)) - } - } - } - - // Default value - if attr.Value != nil && attr.Value.DefaultValue != "" { - defaultVal := attr.Value.DefaultValue - if _, ok := attr.Type.(*domainmodel.StringAttributeType); ok { - defaultVal = fmt.Sprintf("'%s'", defaultVal) - } - constraints.WriteString(fmt.Sprintf(" default %s", defaultVal)) - } - - comma := "," - if i == len(entity.Attributes)-1 { - comma = "" - } - lines = append(lines, fmt.Sprintf(" %s: %s%s%s", attr.Name, typeStr, constraints.String(), comma)) - } - - lines = append(lines, ")") - - // Build attr name map for indexes - attrNames := make(map[model.ID]string) - for _, attr := range entity.Attributes { - attrNames[attr.ID] = attr.Name - } - - // Indexes - for _, idx := range entity.Indexes { - var cols []string - for _, ia := range idx.Attributes { - colName := attrNames[ia.AttributeID] - if !ia.Ascending { - colName += " desc" - } - cols = append(cols, colName) - } - if len(cols) > 0 { - lines = append(lines, fmt.Sprintf("index (%s)", strings.Join(cols, ", "))) - } - } - - lines = append(lines, ";") - - return strings.Join(lines, "\n") -} - -// viewEntityFromProjectToMDL converts a view entity from project to MDL -func viewEntityFromProjectToMDL(ctx *ExecContext, moduleName string, entity *domainmodel.Entity, dm *domainmodel.DomainModel) string { - var lines []string - - if entity.Documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+entity.Documentation) - lines = append(lines, " */") - } - - lines = append(lines, fmt.Sprintf("@Position(%d, %d)", entity.Location.X, entity.Location.Y)) - lines = append(lines, fmt.Sprintf("create view entity %s.%s (", moduleName, entity.Name)) - - for i, attr := range entity.Attributes { - typeStr := formatAttributeType(attr.Type) - comma := "," - if i == len(entity.Attributes)-1 { - comma = "" - } - lines = append(lines, fmt.Sprintf(" %s: %s%s", attr.Name, typeStr, comma)) - } - - lines = append(lines, ") as (") - if entity.OqlQuery != "" { - for line := range strings.SplitSeq(entity.OqlQuery, "\n") { - lines = append(lines, " "+line) - } - } - lines = append(lines, ");") - - return strings.Join(lines, "\n") -} - -// enumerationToMDL converts a project enumeration to MDL text -func enumerationToMDL(ctx *ExecContext, moduleName string, enum *model.Enumeration) string { - var lines []string - - if enum.Documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+enum.Documentation) - lines = append(lines, " */") - } - - lines = append(lines, fmt.Sprintf("create enumeration %s.%s (", moduleName, enum.Name)) - - // Same read as DESCRIBE: a hardcoded "en_US" renders every caption of a - // non-en_US project as '' and makes the diff claim the script changes them - // (mendixlabs/mxcli#1113). - lang := describeDefaultLanguage(ctx) - - for i, v := range enum.Values { - comma := "," - if i == len(enum.Values)-1 { - comma = "" - } - caption := pickTextTranslation(v.Caption, lang) - lines = append(lines, fmt.Sprintf(" %s '%s'%s", v.Name, caption, comma)) - } - - lines = append(lines, ");") - - return strings.Join(lines, "\n") -} - -// associationToMDL converts a project association to MDL text -func associationToMDL(ctx *ExecContext, moduleName string, assoc *domainmodel.Association, dm *domainmodel.DomainModel) string { - // Build entity name map - entityNames := make(map[model.ID]string) - for _, entity := range dm.Entities { - entityNames[entity.ID] = entity.Name - } - return storedAssociationToMDL(moduleName, assoc.Name, assoc.Documentation, - moduleName+"."+entityNames[assoc.ParentID], moduleName+"."+entityNames[assoc.ChildID], - assoc.Type, assoc.Owner, assoc.StorageFormat, assoc.ChildDeleteBehavior) -} - -// crossAssociationToMDL converts a project cross-module association (FROM an -// entity of this module, TO one named in another) to MDL text. -func crossAssociationToMDL(moduleName string, ca *domainmodel.CrossModuleAssociation, dm *domainmodel.DomainModel) string { - fromEntity := "" - for _, entity := range dm.Entities { - if entity.ID == ca.ParentID { - fromEntity = entity.Name - break - } - } - return storedAssociationToMDL(moduleName, ca.Name, ca.Documentation, - moduleName+"."+fromEntity, ca.ChildRef, - ca.Type, ca.Owner, ca.StorageFormat, ca.ChildDeleteBehavior) -} - -// storedAssociationToMDL renders the parts both association kinds share, in the -// shape associationStmtToMDL renders the script side. -func storedAssociationToMDL(moduleName, name, documentation, from, to string, - typ domainmodel.AssociationType, own domainmodel.AssociationOwner, - storage domainmodel.AssociationStorageFormat, childDelete *domainmodel.DeleteBehavior) string { - var lines []string - - if documentation != "" { - lines = append(lines, "/**") - lines = append(lines, " * "+documentation) - lines = append(lines, " */") - } - - lines = append(lines, fmt.Sprintf("create association %s.%s", moduleName, name)) - lines = append(lines, fmt.Sprintf("from %s to %s", from, to)) - - assocType := "Reference" - if typ == domainmodel.AssociationTypeReferenceSet { - assocType = "ReferenceSet" - } - lines = append(lines, fmt.Sprintf("type %s", assocType)) - - owner := "Default" - if own == domainmodel.AssociationOwnerBoth { - owner = "Both" - } - lines = append(lines, fmt.Sprintf("owner %s", owner)) - - if line := storageClause(storage); line != "" { - lines = append(lines, line) - } - - deleteBehavior := "set null" - if childDelete != nil { - switch childDelete.Type { - case domainmodel.DeleteBehaviorTypeDeleteMeAndReferences: - deleteBehavior = "cascade" - case domainmodel.DeleteBehaviorTypeDeleteMeIfNoReferences: - deleteBehavior = "restrict" - } - } - lines = append(lines, fmt.Sprintf("on delete %s;", deleteBehavior)) - - return strings.Join(lines, "\n") -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -// dataTypeToString converts a DataType to its string representation -func dataTypeToString(_ *ExecContext, dt ast.DataType) string { - switch dt.Kind { - case ast.TypeString: - if dt.Length > 0 { - return fmt.Sprintf("String(%d)", dt.Length) - } - return "String" - case ast.TypeInteger: - return "Integer" - case ast.TypeLong: - return "Long" - case ast.TypeDecimal: - return "Decimal" - case ast.TypeBoolean: - return "Boolean" - case ast.TypeDateTime: - return "DateTime" - case ast.TypeDate: - return "Date" - case ast.TypeAutoNumber: - return "AutoNumber" - case ast.TypeBinary: - return "Binary" - case ast.TypeEnumeration: - if dt.EnumRef != nil { - return fmt.Sprintf("Enumeration(%s)", dt.EnumRef.String()) - } - return "Enumeration" - case ast.TypeEntity: - if dt.EntityRef != nil { - return dt.EntityRef.String() - } - return "Object" - case ast.TypeListOf: - if dt.EntityRef != nil { - return fmt.Sprintf("List of %s", dt.EntityRef.String()) - } - return "List" - case ast.TypeVoid: - return "Void" - default: - return "Unknown" - } -} diff --git a/mdl/executor/cmd_diff_module_name_test.go b/mdl/executor/cmd_diff_module_name_test.go deleted file mode 100644 index f18ea7f00..000000000 --- a/mdl/executor/cmd_diff_module_name_test.go +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "regexp" - "strings" - "testing" - - "github.com/mendixlabs/mxcli/mdl/backend/mock" - "github.com/mendixlabs/mxcli/mdl/visitor" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/domainmodel" - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -// ako/mxcli#794: `mxcli diff` rendered an existing enumeration as -// -// create enumeration .Enum_DistanceUnit ( -// -// — an empty module name. The enumeration sat in a FOLDER, so its ContainerID -// is the folder's ID, and diffEnumeration asked the hierarchy for the module -// NAME of that ID directly instead of walking up to the module first. The same -// defect as DROP ENUMERATION's #976, in a second resolver. The consequence is -// not cosmetic: the stored side's header never matches the script's, so an -// untouched enumeration diffs as "modified". -// -// The issue asked for every diff kind to be checked for the same empty -// qualifier, so this is one table over all of them: each document exists in the -// project, in a folder wherever the kind can live in one, and the stored side of -// the diff must name its module. - -// emptyQualifier matches a qualified name whose module part is missing: -// ".Name" after a space, a paren or the start of a line. -var emptyQualifier = regexp.MustCompile(`(^|[\s(])\.[A-Za-z_]`) - -// diffModuleNameFixture is module M with a folder F holding an enumeration, a -// microflow and a nanoflow; the domain model holds a persistent entity, a view -// entity and an association between two entities. `Root` is the control: the -// same enumeration at the module root, which rendered correctly before the fix. -func diffModuleNameFixture(t *testing.T) *ExecContext { - t.Helper() - mod := mkModule("M") - folderID := model.ID("folder-f") - - h := mkHierarchy(mod) - withContainer(h, folderID, mod.ID) - h.folderNames[folderID] = "F" - - filed := mkEnumeration(folderID, "Filed", "Red") - root := mkEnumeration(mod.ID, "Root", "Red") - - dm := mkDomainModel(mod.ID) - withContainer(h, dm.ID, mod.ID) - parent := mkEntity(dm.ID, "Parent") - child := mkEntity(dm.ID, "Child") - view := mkEntity(dm.ID, "View") - view.Persistable = false - view.Source = "DomainModels$OqlViewEntitySource" - view.OqlQuery = "select 1" - dm.Entities = []*domainmodel.Entity{parent, child, view} - dm.Associations = []*domainmodel.Association{mkAssociation(dm.ID, "Child_Parent", child.ID, parent.ID)} - - mf := mkMicroflow(folderID, "MF") - nf := mkNanoflow(folderID, "NF") - - mb := &mock.MockBackend{ - IsConnectedFunc: func() bool { return true }, - ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, - ListEnumerationsFunc: func() ([]*model.Enumeration, error) { - return []*model.Enumeration{filed, root}, nil - }, - ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { - return []*domainmodel.DomainModel{dm}, nil - }, - GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, - ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return []*microflows.Microflow{mf}, nil }, - ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return []*microflows.Nanoflow{nf}, nil }, - GetMicroflowFunc: func(model.ID) (*microflows.Microflow, error) { return mf, nil }, - GetNanoflowFunc: func(model.ID) (*microflows.Nanoflow, error) { return nf, nil }, - } - ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) - return ctx -} - -func TestDiff_StoredSideNamesItsModule(t *testing.T) { - cases := []struct { - kind, src, want string - }{ - {"enumeration in a folder (#794)", "create or modify enumeration M.Filed (Red 'Red');", "create enumeration M.Filed ("}, - {"enumeration at the module root (control)", "create or modify enumeration M.Root (Red 'Red');", "create enumeration M.Root ("}, - {"entity", "create or modify persistent entity M.Parent ();", "entity M.Parent ("}, - {"view entity", "create or modify view entity M.View () as (select 1);", "create view entity M.View ("}, - {"association", "create or modify association M.Child_Parent from M.Child to M.Parent;", "create association M.Child_Parent"}, - {"microflow in a folder", "create or modify microflow M.MF () begin end;", "M.MF"}, - {"nanoflow in a folder", "create or modify nanoflow M.NF () begin end;", "M.NF"}, - } - for _, tc := range cases { - t.Run(tc.kind, func(t *testing.T) { - ctx := diffModuleNameFixture(t) - prog, errs := visitor.Build(tc.src) - if len(errs) > 0 { - t.Fatalf("parse %q: %v", tc.src, errs) - } - res, err := diffStatement(ctx, prog.Statements[0]) - if err != nil { - t.Fatalf("diff: %v", err) - } - if res.IsNew { - t.Fatalf("the document exists in the project; diff reported it as new") - } - if !strings.Contains(res.Current, tc.want) { - t.Errorf("stored side does not name its module (want %q):\n%s", tc.want, res.Current) - } - t.Logf("stored side:\n%s", res.Current) - for line := range strings.SplitSeq(res.Current, "\n") { - if emptyQualifier.MatchString(line) { - t.Errorf("stored side has an empty module qualifier: %q", line) - } - } - }) - } -} - -// A flow's stored side is headed by the statement's own name, so the table above -// cannot catch an empty qualifier inside its body. Those references — called -// microflows, retrieved entities — are printed from flowNameMaps, and the flows -// here are filed in a folder: every name must still carry its module. -func TestDiff_FlowReferenceNamesCarryTheirModule(t *testing.T) { - ctx := diffModuleNameFixture(t) - entityNames, microflowNames, err := flowNameMaps(ctx) - if err != nil { - t.Fatalf("flowNameMaps: %v", err) - } - want := map[string]bool{"M.Parent": true, "M.Child": true, "M.View": true, "M.MF": true, "M.NF": true} - got := map[string]bool{} - for _, m := range []map[model.ID]string{entityNames, microflowNames} { - for _, name := range m { - got[name] = true - } - } - for name := range want { - if !got[name] { - t.Errorf("reference name %q missing; flowNameMaps produced %v", name, got) - } - } - for name := range got { - if strings.HasPrefix(name, ".") { - t.Errorf("reference name %q has an empty module qualifier", name) - } - } -} - -// The consequence the user sees: an enumeration in a folder, diffed against a -// script that defines it exactly as stored, must be unchanged. Before the fix the -// two headers differed (".Filed" vs "M.Filed") and it diffed as modified. -func TestDiff_UnchangedEnumerationInAFolderIsUnchanged(t *testing.T) { - ctx := diffModuleNameFixture(t) - prog, errs := visitor.Build("create or modify enumeration M.Filed (Red '');") - if len(errs) > 0 { - t.Fatalf("parse: %v", errs) - } - res, err := diffStatement(ctx, prog.Statements[0]) - if err != nil { - t.Fatalf("diff: %v", err) - } - if res.Current != res.Proposed { - t.Errorf("an enumeration identical to the stored one must diff as unchanged\ncurrent:\n%s\nproposed:\n%s", res.Current, res.Proposed) - } -} diff --git a/mdl/executor/cmd_diff_output.go b/mdl/executor/cmd_diff_output.go index f5974b7a0..82302ed31 100644 --- a/mdl/executor/cmd_diff_output.go +++ b/mdl/executor/cmd_diff_output.go @@ -41,11 +41,16 @@ func outputUnifiedDiff(ctx *ExecContext, result DiffResult, useColor bool) { } // Use difflib for unified diff + from := fmt.Sprintf("%s.%s (current)", result.ObjectType, result.ObjectName) + to := fmt.Sprintf("%s.%s (script)", result.ObjectType, result.ObjectName) + if result.IsDeleted { + to = "/dev/null" + } diff := difflib.UnifiedDiff{ A: difflib.SplitLines(result.Current), B: difflib.SplitLines(result.Proposed), - FromFile: fmt.Sprintf("%s.%s (current)", result.ObjectType, result.ObjectName), - ToFile: fmt.Sprintf("%s.%s (script)", result.ObjectType, result.ObjectName), + FromFile: from, + ToFile: to, Context: 3, } @@ -87,6 +92,9 @@ func outputSideBySideDiff(ctx *ExecContext, result DiffResult, width int, useCol if result.IsNew { leftHeader = "(new)" } + if result.IsDeleted { + rightHeader = "(removed)" + } fmt.Fprintf(ctx.Output, "%-*s │ %s\n", colWidth, leftHeader, rightHeader) fmt.Fprintln(ctx.Output, strings.Repeat("─", width)) @@ -148,6 +156,12 @@ func outputStructuralDiff(ctx *ExecContext, result DiffResult, useColor bool) { } else { fmt.Fprintln(ctx.Output, " + New") } + } else if result.IsDeleted { + if useColor { + fmt.Fprintf(ctx.Output, " %s- Removed%s\n", colorRed, colorReset) + } else { + fmt.Fprintln(ctx.Output, " - Removed") + } } else if result.Current == result.Proposed { fmt.Fprintln(ctx.Output, " (no changes)") } else if len(result.Changes) == 0 { diff --git a/mdl/executor/cmd_diff_render.go b/mdl/executor/cmd_diff_render.go deleted file mode 100644 index 94a21e4ab..000000000 --- a/mdl/executor/cmd_diff_render.go +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// Package executor - rendering both sides of a diff through one describer. -package executor - -import ( - "github.com/mendixlabs/mxcli/mdl/ast" - mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" - "github.com/mendixlabs/mxcli/model" - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -// flowNameMaps builds the ID → qualified-name maps renderMicroflowMDL needs to -// print entity and flow references. Shared so that the two sides of a diff -// resolve names identically; a map built for one side only would show a -// reference as a name on one side and a stub on the other. -func flowNameMaps(ctx *ExecContext) (entityNames, microflowNames map[model.ID]string, err error) { - h, err := getHierarchy(ctx) - if err != nil { - return nil, nil, mdlerrors.NewBackend("build hierarchy", err) - } - - entityNames = make(map[model.ID]string) - domainModels, _ := ctx.Backend.ListDomainModels() - for _, dm := range domainModels { - modName := h.GetModuleName(dm.ContainerID) - for _, entity := range dm.Entities { - entityNames[entity.ID] = modName + "." + entity.Name - } - } - - microflowNames = make(map[model.ID]string) - allMicroflows, err := ctx.Backend.ListMicroflows() - if err != nil { - return nil, nil, mdlerrors.NewBackend("list microflows", err) - } - for _, mf := range allMicroflows { - microflowNames[mf.ID] = h.GetQualifiedName(mf.ContainerID, mf.Name) - } - allNanoflows, _ := ctx.Backend.ListNanoflows() - for _, nf := range allNanoflows { - microflowNames[nf.ID] = h.GetQualifiedName(nf.ContainerID, nf.Name) - } - return entityNames, microflowNames, nil -} - -// renderFlowFromModel renders an in-memory flow as MDL through the same -// describer DESCRIBE and diff-local use. -// -// This is the whole point of the #997 fix. diff used to render its script side -// with a second AST-to-MDL renderer, which covered 18 of 43 activity types and -// silently emitted nothing for the rest — so a java-action call, a `download -// file` or a canvas annotation appeared in the diff as a deletion, and mxcli -// confidently reported that a script would gut a microflow that exec proved -// was a no-op. One renderer for both sides makes that class of false report -// unrepresentable rather than fixed case by case. -func renderFlowFromModel(ctx *ExecContext, flowType string, mf *microflows.Microflow, name ast.QualifiedName) (string, error) { - entityNames, microflowNames, err := flowNameMaps(ctx) - if err != nil { - return "", err - } - return renderMicroflowMDL(ctx, flowType, mf, name, entityNames, microflowNames, nil), nil -} - -// nanoflowAsMicroflow wraps a Nanoflow so renderMicroflowMDL can print it. -// ContainerID is carried so the `folder` line matches the microflow path; -// without it one side of a nanoflow diff would print a folder and the other -// would not. -func nanoflowAsMicroflow(nf *microflows.Nanoflow) *microflows.Microflow { - if nf == nil { - return nil - } - return µflows.Microflow{ - BaseElement: nf.BaseElement, - ContainerID: nf.ContainerID, - Name: nf.Name, - Documentation: nf.Documentation, - Excluded: nf.Excluded, - Parameters: nf.Parameters, - ReturnType: nf.ReturnType, - ObjectCollection: nf.ObjectCollection, - AllowedModuleRoles: nf.AllowedModuleRoles, - } -} diff --git a/mdl/executor/cmd_diff_render_test.go b/mdl/executor/cmd_diff_render_test.go deleted file mode 100644 index 1264101ff..000000000 --- a/mdl/executor/cmd_diff_render_test.go +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package executor - -import ( - "errors" - "os" - "strings" - "testing" - - "github.com/mendixlabs/mxcli/mdl/ast" - "github.com/mendixlabs/mxcli/sdk/microflows" -) - -// #997: diff rendered its script side with a second AST-to-MDL renderer whose -// statement switch had no default case, so an activity it did not know emitted -// zero lines and showed up in the diff as a deletion. mxcli reported that a -// script would gut a microflow that exec proved was a no-op. -// -// The fix is structural — one renderer for both sides — so the regression test -// that matters is that the dead renderer stays dead. A reviewer adding a case -// to a revived script-side renderer would re-create the drift; this fails -// first and says why. -func TestDiffHasNoSecondFlowRenderer(t *testing.T) { - for _, name := range []string{ - "microflowStmtToMDL", - "nanoflowStmtToMDL", - "microflowStatementToMDL", - "diffExpressionToString", - } { - if diffMDLSource(t, name) { - t.Errorf("%s is back in cmd_diff_mdl.go. diff must render its script side "+ - "through renderMicroflowMDL — the describer both `describe` and `diff-local` "+ - "use — so that an activity it cannot print is impossible rather than shown "+ - "as a deletion (#997).", name) - } - } -} - -// diffMDLSource reports whether cmd_diff_mdl.go still defines fn. -func diffMDLSource(t *testing.T, fn string) bool { - t.Helper() - b, err := os.ReadFile("cmd_diff_mdl.go") - if err != nil { - t.Fatalf("read cmd_diff_mdl.go: %v", err) - } - return strings.Contains(string(b), "func "+fn+"(") -} - -// nanoflowAsMicroflow must carry ContainerID: renderMicroflowMDL prints the -// `folder` line from it, so dropping it would make one side of a nanoflow diff -// print a folder and the other not — a false modification of exactly the kind -// this issue was about. -func TestNanoflowWrapperCarriesContainer(t *testing.T) { - nf := µflows.Nanoflow{Name: "NF", ContainerID: "folder-id"} - got := nanoflowAsMicroflow(nf) - if got.ContainerID != "folder-id" { - t.Errorf("ContainerID = %q, want folder-id — the folder line would differ between sides", got.ContainerID) - } - if nanoflowAsMicroflow(nil) != nil { - t.Error("nil nanoflow should wrap to nil") - } -} - -// A statement diff cannot compare must be reported, not dropped. Silently -// skipping made `diff` print "0 new, 0 modified, 0 unchanged" for a script that -// would genuinely add documents — worse than a wrong count, because there is -// nothing on screen to disbelieve. -func TestUnsupportedStatementIsAnError(t *testing.T) { - _, err := diffStatement(nil, &ast.CreateConstantStmt{}) - if err == nil { - t.Fatal("an unsupported statement returned no error — it would vanish from the summary") - } - var unsupported *unsupportedDiffError - if !errors.As(err, &unsupported) { - t.Fatalf("error = %T, want *unsupportedDiffError", err) - } - if !strings.Contains(unsupported.kind, "constant") { - t.Errorf("kind = %q, want it to name the statement", unsupported.kind) - } -} - -func TestStatementKindName(t *testing.T) { - if got := statementKindName(&ast.CreateConstantStmt{}); got != "create constant" { - t.Errorf("got %q, want \"create constant\"", got) - } -} diff --git a/mdl/roundtrip/flow_splice_parity_test.go b/mdl/roundtrip/flow_splice_parity_test.go index dbdfd9f0f..19f5a5ed5 100644 --- a/mdl/roundtrip/flow_splice_parity_test.go +++ b/mdl/roundtrip/flow_splice_parity_test.go @@ -5,11 +5,15 @@ package roundtrip import ( + "bytes" "strings" "testing" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" "github.com/mendixlabs/mxcli/mdl/executor" "github.com/mendixlabs/mxcli/mdl/langver" + "github.com/mendixlabs/mxcli/mdl/scriptdiff" "github.com/mendixlabs/mxcli/mdl/visitor" ) @@ -103,8 +107,8 @@ func TestPedAppSpliceParity_AuthoredSpellings(t *testing.T) { t.Run(name, func(t *testing.T) { script := header + spliceParityFlow // diff first: it must reach exec's verdict, and it writes nothing. - if out := h.diff(script); !strings.Contains(out, "0 new, 0 modified, 1 unchanged") || - strings.Contains(out, "refused") { + if out := h.diff(script); !strings.Contains(out, "exec would write nothing") || + strings.Contains(out, "Refused") { t.Errorf("diff of the unchanged source under %s:\n%s", name, out) } if err := h.exec(script); err != nil { @@ -127,7 +131,7 @@ func TestPedAppSpliceParity_AuthoredSpellings(t *testing.T) { // spliced in — the comparison is not blind to the where clause. t.Run("an edited constraint writes", func(t *testing.T) { edited := strings.Replace(spliceParityFlow, "MonthKey >= $Context/FromKey", "MonthKey > $Context/FromKey", 1) - if out := h.diff("mdl 1;\n" + edited); !strings.Contains(out, "0 new, 1 modified, 0 unchanged") { + if out := h.diff("mdl 1;\n" + edited); !strings.Contains(out, "0 new, 1 modified, 0 removed") { t.Errorf("diff of an edited constraint:\n%s", out) } if err := h.exec("mdl 1;\n" + edited); err != nil { @@ -149,7 +153,7 @@ func TestPedAppSpliceParity_AuthoredSpellings(t *testing.T) { inLoop := strings.Replace(spliceParityFlow, "$Total + $R/MonthKey", "$Total + $R/MonthKey + 1", 1) before := h.snapshot() out := h.diff("mdl 1;\n" + inLoop) - if !strings.Contains(out, "Refused: Microflow MyFirstModule.SpliceParity") || !strings.Contains(out, "1 refused") { + if !strings.Contains(out, "Refused: exec would stop at this error, having written nothing") || !strings.Contains(out, "cannot be spliced") { t.Errorf("diff of a change exec refuses:\n%s", out) } err := h.exec("mdl 1;\n" + inLoop) @@ -160,7 +164,7 @@ func TestPedAppSpliceParity_AuthoredSpellings(t *testing.T) { t.Errorf("a refused statement wrote %d unit(s)", len(changed)) } // Under mdl 0 the same change is rebuilt, and diff shows the change. - if out := h.diff(inLoop); !strings.Contains(out, "0 new, 1 modified, 0 unchanged") { + if out := h.diff(inLoop); !strings.Contains(out, "0 new, 1 modified, 0 removed") { t.Errorf("diff of the same change under mdl 0:\n%s", out) } }) @@ -184,11 +188,12 @@ func TestPedAppSpliceParity_DiffReportsTheWriteExecMakes(t *testing.T) { for _, header := range []string{"", "mdl 1;\n"} { name := map[string]string{"": "mdl 0", "mdl 1;\n": "mdl 1"}[header] // Control: the same statement in the stored folder is unchanged. - if out := h.diff(header + spliceParityFlow); !strings.Contains(out, "0 new, 0 modified, 1 unchanged") { + if out := h.diff(header + spliceParityFlow); !strings.Contains(out, "exec would write nothing") { t.Errorf("diff of the unchanged source under %s:\n%s", name, out) } - if out := h.diff(header + moved); !strings.Contains(out, "0 new, 1 modified, 0 unchanged") || - !strings.Contains(out, "moved to folder 'Moved'") { + // The folder is new, and the flow is moved into it. + if out := h.diff(header + moved); !strings.Contains(out, "1 new, 1 modified, 0 removed") || + !strings.Contains(out, "moved to 'MyFirstModule/Moved'") { t.Errorf("diff of a statement exec moves, under %s:\n%s", name, out) } } @@ -276,16 +281,21 @@ func runFlowSpliceParity(t *testing.T, fx fixture) { t.Logf("%d flows checked under mdl 0 and mdl 1", flows) } -// diff runs `mxcli diff` on a script and returns what it printed. +// diff runs `mxcli diff` on a script and returns what it printed: the script +// executed on a scratch copy of the working copy, compared unit by unit. func (h *harness) diff(script string) string { h.t.Helper() - h.out.Reset() prog, errs := visitor.Build(script) if len(errs) > 0 { h.t.Fatalf("parse: %v", errs[0]) } - if err := h.exe.DiffProgram(prog, executor.DiffOptions{}); err != nil { + rep, err := scriptdiff.Run(h.mpr, prog, scriptdiff.Options{ + NewBackend: func() backend.FullBackend { return modelsdkbackend.New() }, + }) + if err != nil { h.t.Fatalf("diff: %v", err) } - return h.out.String() + var out bytes.Buffer + rep.Write(&out, executor.DiffOptions{}, false) + return out.String() } diff --git a/mdl/roundtrip/flow_verdict_agreement_test.go b/mdl/roundtrip/flow_verdict_agreement_test.go index 1d276fe36..8e0a03839 100644 --- a/mdl/roundtrip/flow_verdict_agreement_test.go +++ b/mdl/roundtrip/flow_verdict_agreement_test.go @@ -23,7 +23,7 @@ import ( type verdicts struct { checkError bool // check -p: an MDL-V1-REBUILD error checkWarning bool // check -p: the MDL-V1-REBUILD warning - diffRefused bool // diff: "Refused:" + diffRefused bool // diff: "Refused:" with the splice's refusal execRefused bool // exec: the splice refusal, nothing written execRebuilt bool // exec: the MDL-V1-REBUILD warning, the flow rebuilt checkMessage string @@ -51,7 +51,10 @@ func (h *harness) verdictsOf(script string) verdicts { v.checkWarning = true } } - v.diffRefused = strings.Contains(h.diff(script), "Refused:") + // diff runs exec on a scratch copy, so it reports whatever exec stops + // at; the refusal compared here is the splice's. + out := h.diff(script) + v.diffRefused = strings.Contains(out, "Refused:") && strings.Contains(out, "cannot be spliced into the stored flow") before := h.snapshot() err := h.exec(script) changed := before.diff(h.snapshot()) diff --git a/mdl/scriptdiff/copy.go b/mdl/scriptdiff/copy.go new file mode 100644 index 000000000..b9200fc44 --- /dev/null +++ b/mdl/scriptdiff/copy.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package scriptdiff + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// skippedDirs are directories of a project folder the scratch copy leaves out: +// version-control metadata, build output and package caches. Exec reads none of +// them and writes none of them, and together they are most of a project folder's +// size once it has been built or deployed. +var skippedDirs = map[string]bool{ + ".git": true, + ".svn": true, + "deployment": true, + "releases": true, + "node_modules": true, + ".mendix-cache": true, + ".mendix-cache2": true, +} + +// copyProject copies the project folder src into dst, which must not exist. +// +// Everything is a real copy rather than a link: exec writes the .mpr, the unit +// files and, for some statements, files next to the project (java and +// javascript sources, theme files), and a hard link or a symlinked directory +// would carry such a write back into the project being diffed. A symlink in the +// project is copied as a symlink, which is the one exception, and the same one +// `cp -a` makes. +func copyProject(src, dst string) error { + return filepath.Walk(src, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + switch mode := info.Mode(); { + case mode&os.ModeSymlink != 0: + link, err := os.Readlink(p) + if err != nil { + return err + } + return os.Symlink(link, target) + case info.IsDir(): + if rel != "." && skippedDirs[info.Name()] { + return filepath.SkipDir + } + return os.MkdirAll(target, 0o755) + case mode.IsRegular(): + return copyFile(p, target, mode.Perm()) + default: + return nil // sockets, devices: nothing exec reads + } + }) +} + +func copyFile(src, dst string, perm os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm|0o200) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return fmt.Errorf("copy %s: %w", src, err) + } + return out.Close() +} diff --git a/mdl/scriptdiff/helpers_test.go b/mdl/scriptdiff/helpers_test.go new file mode 100644 index 000000000..9459d6e07 --- /dev/null +++ b/mdl/scriptdiff/helpers_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 + +package scriptdiff + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +func fileEngine() backend.FullBackend { return modelsdkbackend.New() } + +// pedAppCopy copies the committed PedApp fixture (Studio Pro-authored) into a +// temporary folder and returns its .mpr. +func pedAppCopy(t testing.TB) string { + t.Helper() + src := filepath.Join("..", "..", "testdata", "pedapp") + if _, err := os.Stat(filepath.Join(src, "PedApp.mpr")); err != nil { + t.Skipf("PedApp fixture not found: %v", err) + } + dst := filepath.Join(t.TempDir(), "PedApp") + if err := copyProject(src, dst); err != nil { + t.Fatal(err) + } + return filepath.Join(dst, "PedApp.mpr") +} + +func parse(t testing.TB, script string) *ast.Program { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("parse: %v\n%s", errs[0], script) + } + return prog +} + +// execScript runs a script the way `mxcli exec` does after its pre-flight: a +// fresh executor, connected to the project, running the program. +func execScript(t testing.TB, mpr, scriptDir string, prog *ast.Program, continueOnError bool) (string, error) { + t.Helper() + var out bytes.Buffer + x := executor.New(&out) + x.SetBackendFactory(fileEngine) + if scriptDir != "" { + x.SetScriptDir(scriptDir) + } + defer x.Close() + if err := x.Execute(&ast.ConnectStmt{Path: mpr}); err != nil { + t.Fatalf("connect: %v", err) + } + var err error + if continueOnError { + var fails bytes.Buffer + _, err = x.ExecuteProgramContinueOnError(prog, &fails) + out.WriteString(fails.String()) + } else { + err = x.ExecuteProgram(prog) + } + if errors.Is(err, executor.ErrExit) { + err = nil + } + _ = x.Execute(&ast.DisconnectStmt{}) + return out.String(), err +} + +func snap(t testing.TB, mpr string) *Snapshot { + t.Helper() + s, err := TakeSnapshot(mpr) + if err != nil { + t.Fatal(err) + } + return s +} + +// writeSet is a run's writes as comparable lines: " ", +// and "file ". +func writeSet(units []UnitChange, files []FileChange) []string { + var out []string + for _, u := range units { + k := string(u.Kind) + if u.Kind == Modified && !u.Rewritten { + k = "moved" + } + out = append(out, fmt.Sprintf("%s %s %s %s", k, u.ID, u.Type, u.Name)) + } + for _, f := range files { + out = append(out, fmt.Sprintf("file %s %s", f.Kind, f.Path)) + } + sort.Strings(out) + return out +} + +// symmetricDiff lists the lines only in a (prefixed "diff only: ") and only in +// b ("exec only: "). +func symmetricDiff(a, b []string) []string { + in := func(s []string) map[string]bool { + m := map[string]bool{} + for _, x := range s { + m[x] = true + } + return m + } + ma, mb := in(a), in(b) + var out []string + for _, x := range a { + if !mb[x] { + out = append(out, "diff only: "+x) + } + } + for _, x := range b { + if !ma[x] { + out = append(out, "exec only: "+x) + } + } + return out +} + +// diffThenExec runs diff on the project, checks it wrote nothing, then runs +// exec on it, and returns what each said is written. New units get fresh IDs +// in each run, so an added unit is compared by type and name only. +func diffThenExec(t testing.TB, mpr, scriptDir string, prog *ast.Program, continueOnError bool) (rep *Report, diffSet, execSet []string, execOut string, execErr error) { + t.Helper() + before := snap(t, mpr) + rep, err := Run(mpr, prog, Options{NewBackend: fileEngine, ScriptDir: scriptDir, ContinueOnError: continueOnError}) + if err != nil { + t.Fatalf("diff: %v", err) + } + if u, f := before.Compare(snap(t, mpr)); len(u)+len(f) > 0 { + t.Fatalf("diff wrote to the project it diffs:\n %s", strings.Join(writeSet(u, f), "\n ")) + } + execOut, execErr = execScript(t, mpr, scriptDir, prog, continueOnError) + u, f := before.Compare(snap(t, mpr)) + return rep, comparable(writeSet(rep.Units, rep.Files)), comparable(writeSet(u, f)), execOut, execErr +} + +// comparable drops the unit ID of an added unit, which each run mints afresh. +func comparable(lines []string) []string { + out := make([]string, len(lines)) + for i, l := range lines { + if strings.HasPrefix(l, "added ") { + parts := strings.SplitN(l, " ", 3) + l = "added " + parts[2] + } + out[i] = l + } + sort.Strings(out) + return out +} + +// parseOK parses a script, returning its first error instead of failing. +func parseOK(script string) (*ast.Program, error) { + prog, errs := visitor.Build(script) + if len(errs) > 0 { + return nil, errs[0] + } + return prog, nil +} diff --git a/mdl/scriptdiff/property_test.go b/mdl/scriptdiff/property_test.go new file mode 100644 index 000000000..30d9132fc --- /dev/null +++ b/mdl/scriptdiff/property_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package scriptdiff + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The property `mxcli diff` exists for (ako/mxcli#907): the documents diff +// reports changed are exactly the units exec writes. For every script, diff +// runs first and must not touch the project; exec then runs on the same +// project; the two sets of writes must be equal. Each script then runs a +// second time, which is the twice-exec rule seen from diff: whatever exec 2 +// writes (usually nothing) is what diff 2 reports. +// +// Every statement runs, as `exec --continue-on-error` runs them, so a script +// with one failing statement still compares everything else it does. +// +// By default a handful of mdl-examples doctype scripts covering the document +// kinds diff used not to compare run on the PedApp fixture, each on its own +// copy. MXCLI_DIFF_ALL=1 runs every doctype script. MXCLI_DIFF_LEGS names a +// file of legs, one per line — "\t\t