From 62b8c1bba02232680d8f89a35eea308e16b11055 Mon Sep 17 00:00:00 2001 From: stagknee <241821830+stagknee@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:52:52 -0500 Subject: [PATCH 1/5] fix: give each project's docker stack its own compose project name (closes #1364) mxcli runs `docker compose` in /.docker and Compose names a stack after that folder, so every project was the Compose project "docker": containers docker-db-1 / docker-mendix-1 and volume docker_postgres-data were shared, and a second app's `docker up` replaced the first app's stack. New compose files (template v3) carry a top-level `name: "mxcli-"` written by `docker init`. The name is a key to the stack's data, so mxcli never renames a stack on its own: nothing is rewritten without --force, and --force keeps the name the stack already had - an existing `name:`, a COMPOSE_PROJECT_NAME from the old .env (which --force rewrites and which beats `name:` in Compose), or, for a file written by an older mxcli with no name, `docker` (the folder-derived name Compose was using). Existing users opt in by editing `name:`; the init output, --help, the docker-workflow skill and the CHANGELOG explain that this starts an empty database and leaves the old volume in place. No code path hardcodes container or volume names; every compose call addresses services. Co-Authored-By: Claude Sonnet 5.5 --- ...er-compose-project-name-shared-docker.json | 1 + .../skills/mendix/docker-workflow/SKILL.md | 13 +- CHANGELOG.md | 1 + cmd/mxcli/docker.go | 8 + cmd/mxcli/docker/composename.go | 183 ++++++++++ cmd/mxcli/docker/composename_test.go | 312 ++++++++++++++++++ cmd/mxcli/docker/init.go | 46 ++- cmd/mxcli/docker/templates/docker-compose.yml | 13 +- 8 files changed, 570 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-docker-compose-project-name-shared-docker.json create mode 100644 cmd/mxcli/docker/composename.go create mode 100644 cmd/mxcli/docker/composename_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-docker-compose-project-name-shared-docker.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-docker-compose-project-name-shared-docker.json new file mode 100644 index 0000000000..aac9d47c3c --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-09-docker-compose-project-name-shared-docker.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-09", "symptom": "Every mxcli project's docker stack was the Compose project `docker`: containers docker-db-1 / docker-mendix-1 and volume docker_postgres-data were shared, so a second app's `docker up` replaced the first app's containers and two apps used one Postgres volume", "cause": "docker init wrote a compose file with no top-level `name:`, and mxcli runs compose with cmd.Dir = /.docker, so Compose derived the project name from the folder, which is `.docker` for every app (normalised to `docker`).", "file": "cmd/mxcli/docker/composename.go + init.go + templates/docker-compose.yml", "fix": "Template v3 has a top-level `name:` filled in by docker init (mxcli-). A stack's name is the key to its data, so it is chosen only when the compose file is created from nothing: --force on an existing file keeps its name: line, and on an old file with none pins the name Compose was using (COMPOSE_PROJECT_NAME from the old .env if set, else the normalised folder name, `docker`) and prints how to opt in.", "insight": "Anything that changes a Compose project name strands the old named volumes silently (new empty volumes appear, nothing errors), so a fix to a naming default must be new-files-only and the regeneration path must read the old file before overwriting it. Also check what the regeneration overwrites besides the file: --force rewrites .env, which is where the pre-fix workaround (COMPOSE_PROJECT_NAME) lived. The name goes in the stamped compose file, not .env, so a .env that is recreated by itself can never rename a stack. Verified read-only with `docker compose config` in temp dirs; no code path hardcodes container or volume names (all compose calls address services, not containers).", "refs": ["mendixlabs/mxcli#1364"]} diff --git a/.claude/skills/mendix/docker-workflow/SKILL.md b/.claude/skills/mendix/docker-workflow/SKILL.md index 2611d4e36c..82b1b0e839 100644 --- a/.claude/skills/mendix/docker-workflow/SKILL.md +++ b/.claude/skills/mendix/docker-workflow/SKILL.md @@ -15,11 +15,11 @@ description: "Build, run and validate a Mendix app in Docker from inside a devco | 53-68 | Architecture | | 69-85 | Quick Start: One Command | | 86-113 | Creating an Empty Mendix App | -| 114-272 | Step-by-Step Workflow | -| 273-303 | Common Workflow: Edit, Rebuild, Test | -| 304-415 | Hot Reload | -| 416-434 | Environment Variables | -| 435-454 | Troubleshooting | +| 114-274 | Step-by-Step Workflow | +| 275-305 | Common Workflow: Edit, Rebuild, Test | +| 306-417 | Hot Reload | +| 418-436 | Environment Variables | +| 437-457 | Troubleshooting | # Docker Build & Run Skill @@ -167,6 +167,8 @@ mxcli docker init -p app.mpr This creates a `.docker/` directory with Docker Compose configuration for the Mendix app + PostgreSQL. +**Stack name:** `docker init` writes a Compose project name into `.docker/docker-compose.yml` (`name: "mxcli-"`), so containers and volumes of different apps are separate (`mxcli-myapp-db-1`, `mxcli-myapp_postgres-data`). Never change `name:` on a stack that holds data you need: Compose then starts new containers with an empty database and leaves the old volume behind. Projects initialised by older mxcli have no `name:`, so Compose calls them `docker` (`docker-db-1`, `docker_postgres-data`), shared with every other such project; `docker init --force` keeps that name rather than stranding the data. + **Port conflicts:** If default ports (8080/8090/5432) are already in use, check with `ss -tlnp | grep -E '808|809|543'` and use `--port-offset N` to shift all ports: ```bash @@ -450,5 +452,6 @@ All defaults can be overridden in `.docker/.env`. | `password should not be empty (debugger)` | Add `RUNTIME_DEBUGGER_PASSWORD` — re-run `mxcli docker init --force` | | `security level should be set to CHECKEVERYTHING` | App in Production mode without security — set to Development mode or configure security | | Port 8080 not accessible | Check `forwardPorts` in devcontainer.json includes 8080 | +| Two projects overwrite each other's containers or share one database (both show as project `docker`) | Old compose file without `name:`. Back up the data, set a unique `name:` at the top of `.docker/docker-compose.yml` (e.g. `mxcli-myapp`), then `docker compose up`; the new stack starts with an empty database | | Database errors on startup | Try `mxcli docker up -p app.mpr --fresh` to reset volumes | | OQL: "Action not found: preview_execute_oql" | Runtime needs `-Dmendix.live-preview=enabled` JVM flag — re-run `mxcli docker init --force` to get the updated docker-compose.yml | diff --git a/CHANGELOG.md b/CHANGELOG.md index 941ae1731b..ae13d611de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Headline: **Documents mxcli writes now survive Studio Pro's merge and diff: ever ### Fixed - **`check -p` on `create or modify` of stored microflows is back to v0.24.0's speed** (mendixlabs/mxcli#1272) — the passes that predict exec's verdict and resolve references looked each flow up by name several times per statement, and every lookup re-read and fully decoded every microflow file in the project, so the cost per statement grew with the project. Each check pass now reads a unit file once, and a lookup by name or ID decodes only the flows it returns. On a blank 11.14 app with 200 microflows, re-declaring 25 / 100 / 200 of them: 3.5 / 11.7 / 23.7 s → 0.8 / 1.8 / 3.0 s (v0.24.0: 0.9 / 2.6 / 3.7 s). `exec` gains the same through its preflight and its own planning of each statement; an unchanged re-run of the 200 went from 40 s to 5 s. Nothing reported changes. +- **Every project's docker stack is its own Compose project instead of all sharing the name `docker`** (mendixlabs/mxcli#1364) — mxcli runs `docker compose` in the project's `.docker/` folder and Compose names a stack after that folder, so every app was the project `docker`: containers `docker-db-1` / `docker-mendix-1` and volume `docker_postgres-data` were shared, a second app's `docker up` replaced the first app's containers, and two apps pointed at one Postgres volume. `mxcli docker init` now writes a top-level `name: "mxcli-"` into `docker-compose.yml` (template v3; `Customer Portal/app.mpr` becomes `mxcli-customer-portal`; `docker compose -p` and `COMPOSE_PROJECT_NAME` still override it), so the containers are `mxcli-customer-portal-db-1` and the volume `mxcli-customer-portal_postgres-data`. **Existing projects keep their stack and their data:** a compose file written by an older mxcli has no `name:`, nothing is rewritten without `--force`, and `docker init --force` on such a project writes `name: "docker"` (or the `COMPOSE_PROJECT_NAME` from its old `.env`, which `--force` rewrites and which overrides `name:` in Compose, carried into `name:`) — the name Compose was already using — and says so; a `name:` you set yourself is kept as well. Mxcli never renames a stack on its own, because a new name makes Compose start new containers with an **empty** database and leave the old volume behind. **To opt in** on a project that has `name: "docker"`, stop the stack (`docker compose down` in `.docker/`, without `-v`), change `name:` to a unique value such as `mxcli-myapp`, then `mxcli docker up`; the database starts empty, and the old data stays in `docker_postgres-data` (to carry it over, dump it with `pg_dump` before you change the name and restore it into the new stack). To check which name a stack uses: `docker compose config` in `.docker/` prints `name:`. Two app folders with the same name still collide — edit `name:` in one of them. - **A navigation sub-menu no longer keeps the action of the page item it replaces** (mendixlabs/mxcli#1341) — turning menu item `'Work'` into sub-menu `'Work'` with `create or replace navigation` (or `create or modify menu`) paired the two by caption and carried the page item's `Forms$FormAction` onto the sub-menu, reporting it as an action "MDL cannot express"; `mx check` then failed with CE0548 "Items with subitems cannot have an action themselves." A sub-menu is now always written with no action (measured on 11.14.0). - **`mxcli test --attach` and the dev loop's "already serving" detection see a live app on Windows** (mendixlabs/mxcli#1284) — both read a pid from a handshake file (`.mxcli/test-endpoint.json`, `.mxcli/run-local.json`) and tested it with `Signal(0)`, which Go supports only as Kill on Windows, so every pid read as dead: `test --attach` always refused with "the app that published … is no longer running" while the app was up, and a command that looks for a dev loop already serving the project (the recompile warning, for one) never found it. Liveness is now `internal/procalive.Alive`, which uses OpenProcess and WaitForSingleObject on Windows (the check `run --local` already used for mxbuild) and `Signal(0)` elsewhere. No change on Linux or macOS. - **`run stop` on Windows no longer reports a run that has shut down as still alive** — it printed "failed: 1 process(es) of pid N's run are still alive" and exited 1 for a run that had stopped, because its liveness check counted any pid Windows could still open as alive, and an exited process stays openable while its parent holds a handle to it. It now uses the same check as `test --attach` (above), and reports "stopped: pid N". diff --git a/cmd/mxcli/docker.go b/cmd/mxcli/docker.go index 62c859052a..549ffb17ad 100644 --- a/cmd/mxcli/docker.go +++ b/cmd/mxcli/docker.go @@ -239,6 +239,14 @@ Creates a .docker/ directory next to the MPR file containing: The compose file references ./build as the build context, which is where 'mxcli docker build' outputs the PAD package. +Each project gets its own Compose project name ("name:" at the top of +docker-compose.yml, "mxcli-"), so the containers and database volume +of different apps no longer collide. The name identifies the stack's data: +changing it makes Compose start new containers with an empty database. --force +therefore keeps the name an existing docker-compose.yml already has; a file +written by an older mxcli (no name, so Compose used "docker") stays "docker" +until you edit 'name:' yourself. + Examples: mxcli docker init -p app.mpr mxcli docker init -p app.mpr --force diff --git a/cmd/mxcli/docker/composename.go b/cmd/mxcli/docker/composename.go new file mode 100644 index 0000000000..bfe5e803b2 --- /dev/null +++ b/cmd/mxcli/docker/composename.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Compose project names (mendixlabs/mxcli#1364). +// +// mxcli runs `docker compose` with the project's .docker/ folder as the working +// directory, and Compose names a stack after that folder. Every project's folder +// is called ".docker", so every project was the Compose project "docker" and +// shared container names (docker-db-1) and volumes (docker_postgres-data): two +// apps' stacks replaced each other and their databases collided. +// +// New compose files therefore carry a top-level `name:` derived from the app. +// The name lives only in docker-compose.yml (not in .env) because that file is +// the one stamped with the template version and the one `docker init --force` +// regenerates deliberately; a .env that gets recreated by itself must never be +// able to rename a stack. Precedence in Compose is `-p` > COMPOSE_PROJECT_NAME +// (shell or .env) > `name:` > folder name, so users can still override it. +// +// The name is part of a stack's identity: a different name makes Compose create +// new, EMPTY volumes and leaves the old database volume behind. So a name is only +// ever chosen when a compose file is created from nothing. Regenerating an +// existing file (--force) carries over whatever name the stack already had. + +const ( + // composeNamePlaceholder is the token in templates/docker-compose.yml that + // is replaced by the project name when the file is written. + composeNamePlaceholder = "__COMPOSE_PROJECT_NAME__" + + // composeNamePrefix marks stacks created by mxcli so they are recognisable + // in `docker ps` / `docker volume ls`. + composeNamePrefix = "mxcli-" + + // legacyComposeProjectName is what Compose called every stack before the + // template had a name: the normalised ".docker" folder name. + legacyComposeProjectName = "docker" + + // maxComposeNameBase bounds the app-derived part so container, network and + // volume names stay well inside the 63-character DNS label limit. + maxComposeNameBase = 48 +) + +var ( + invalidComposeNameChars = regexp.MustCompile(`[^a-z0-9_-]+`) + repeatedDashes = regexp.MustCompile(`-{2,}`) + // topLevelNameRegex matches an unindented `name:` line of a compose file and + // captures its value up to whitespace or a trailing comment. + topLevelNameRegex = regexp.MustCompile(`(?m)^name:[ \t]*([^\s#]+)`) + envProjectNameRe = regexp.MustCompile(`(?m)^COMPOSE_PROJECT_NAME=[ \t]*([^\r\n#]*)`) +) + +// sanitizeComposeName reduces s to what a Compose project name may contain +// (lowercase letters, digits, '-' and '_', starting with a letter or digit). +// Anything else becomes a dash; runs of dashes collapse; at most +// maxComposeNameBase characters are kept. Returns "" when nothing usable is left. +func sanitizeComposeName(s string) string { + s = invalidComposeNameChars.ReplaceAllString(strings.ToLower(s), "-") + s = repeatedDashes.ReplaceAllString(s, "-") + s = strings.Trim(s, "-_") + if len(s) > maxComposeNameBase { + s = strings.Trim(s[:maxComposeNameBase], "-_") + } + return s +} + +// ComposeProjectNameFor returns the Compose project name for a new stack of the +// app whose .mpr is projectPath: "mxcli-" plus the app folder's name, so +// ~/work/Customer Portal/app.mpr becomes "mxcli-customer-portal". The folder is +// preferred over the .mpr name because projects commonly call the file "app.mpr". +// If the folder gives nothing usable the .mpr base name is used, then "app". +func ComposeProjectNameFor(projectPath string) string { + abs, err := filepath.Abs(projectPath) + if err != nil { + abs = projectPath + } + base := sanitizeComposeName(filepath.Base(filepath.Dir(abs))) + if base == "" { + file := filepath.Base(abs) + base = sanitizeComposeName(strings.TrimSuffix(file, filepath.Ext(file))) + } + if base == "" { + base = "app" + } + return composeNamePrefix + base +} + +// normalizeLikeCompose applies Docker Compose's own rule for deriving a project +// name from a directory name: lowercase, drop every character outside +// [a-z0-9_-], then trim leading '_' and '-'. ".docker" -> "docker". +func normalizeLikeCompose(s string) string { + s = invalidComposeNameChars.ReplaceAllString(strings.ToLower(s), "") + return strings.TrimLeft(s, "_-") +} + +// existingComposeName returns the raw value of the top-level `name:` of a +// compose file (quotes and interpolation untouched) and whether there was one. +func existingComposeName(data []byte) (string, bool) { + m := topLevelNameRegex.FindSubmatch(data) + if m == nil { + return "", false + } + return string(m[1]), true +} + +// envComposeProjectName returns COMPOSE_PROJECT_NAME from dockerDir/.env, as +// Compose normalises it, if the user set it (the workaround for #1364). +func envComposeProjectName(dockerDir string) (string, bool) { + env, err := os.ReadFile(filepath.Join(dockerDir, ".env")) + if err != nil { + return "", false + } + m := envProjectNameRe.FindSubmatch(env) + if m == nil { + return "", false + } + v := normalizeLikeCompose(strings.Trim(strings.TrimSpace(string(m[1])), `"'`)) + return v, v != "" +} + +// legacyComposeName returns the project name that a compose file with no +// `name:` in dockerDir has been getting: the folder name as Compose normalises +// it (".docker" -> "docker"). +func legacyComposeName(dockerDir string) string { + abs, err := filepath.Abs(dockerDir) + if err != nil { + abs = dockerDir + } + if n := normalizeLikeCompose(filepath.Base(abs)); n != "" { + return n + } + return legacyComposeProjectName +} + +// composeNameDecision is the name written into a (re)generated compose file and +// where it came from. +type composeNameDecision struct { + // Value is the YAML scalar to write after `name:` (already quoted when generated). + Value string + // Name is the bare project name, for messages. + Name string + // Source is "env" (COMPOSE_PROJECT_NAME in the old .env), "kept" (an existing + // name: line), "legacy" (an existing file with no name, pinned to the name + // Compose was already using) or "new". + Source string +} + +// decideComposeName chooses the project name for the compose file about to be +// written at composePath. See the package comment above for why an existing +// file's name always wins over a freshly derived one. +func decideComposeName(projectPath, dockerDir, composePath string) composeNameDecision { + // COMPOSE_PROJECT_NAME in .env beats `name:` in Compose's precedence, so it + // is the stack's real name wherever it is set. `--force` rewrites .env + // without it, so it must be carried into the compose file now or the stack + // is renamed (and its volumes stranded) by the rewrite. + if v, ok := envComposeProjectName(dockerDir); ok { + return composeNameDecision{Value: fmt.Sprintf("%q", v), Name: v, Source: "env"} + } + if data, err := os.ReadFile(composePath); err == nil { + if raw, ok := existingComposeName(data); ok { + return composeNameDecision{Value: raw, Name: strings.Trim(raw, `"'`), Source: "kept"} + } + legacy := legacyComposeName(dockerDir) + return composeNameDecision{Value: fmt.Sprintf("%q", legacy), Name: legacy, Source: "legacy"} + } + name := ComposeProjectNameFor(projectPath) + return composeNameDecision{Value: fmt.Sprintf("%q", name), Name: name, Source: "new"} +} + +// renderComposeTemplate fills the project name into the compose template. +func renderComposeTemplate(data []byte, d composeNameDecision) ([]byte, error) { + if !strings.Contains(string(data), composeNamePlaceholder) { + return nil, fmt.Errorf("compose template has no %s placeholder", composeNamePlaceholder) + } + return []byte(strings.Replace(string(data), composeNamePlaceholder, d.Value, 1)), nil +} diff --git a/cmd/mxcli/docker/composename_test.go b/cmd/mxcli/docker/composename_test.go new file mode 100644 index 0000000000..36fb094fc3 --- /dev/null +++ b/cmd/mxcli/docker/composename_test.go @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSanitizeComposeName(t *testing.T) { + cases := []struct { + in, want string + }{ + {"MyApp", "myapp"}, + {"my app", "my-app"}, + {"My Cool__App!!", "my-cool__app"}, + {" --Leading and trailing-- ", "leading-and-trailing"}, + {"_underscore-first", "underscore-first"}, + {"app.v2", "app-v2"}, + {"Ünïcode-Ñame", "n-code-ame"}, + {"123", "123"}, + {"", ""}, + {"!!!", ""}, + {strings.Repeat("a", 80), strings.Repeat("a", maxComposeNameBase)}, + {strings.Repeat("a", maxComposeNameBase-1) + "-bbb", strings.Repeat("a", maxComposeNameBase-1)}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := sanitizeComposeName(c.in); got != c.want { + t.Errorf("sanitizeComposeName(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestComposeProjectNameFor(t *testing.T) { + root := t.TempDir() + cases := []struct { + name string + mpr string + want string + }{ + {"folder wins over mpr base", filepath.Join(root, "Customer Portal", "app.mpr"), "mxcli-customer-portal"}, + {"folder with punctuation", filepath.Join(root, "MyApp_v2.0", "MyApp.mpr"), "mxcli-myapp_v2-0"}, + {"mpr base when folder is unusable", filepath.Join(root, "!!!", "Billing.mpr"), "mxcli-billing"}, + {"fallback when nothing usable", filepath.Join(root, "!!!", "???.mpr"), "mxcli-app"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ComposeProjectNameFor(c.mpr); got != c.want { + t.Errorf("ComposeProjectNameFor(%q) = %q, want %q", c.mpr, got, c.want) + } + }) + } +} + +func TestNormalizeLikeCompose(t *testing.T) { + // Mirrors docker compose's own derivation of a project name from the + // working directory: lowercase, drop invalid characters, trim leading _/-. + cases := []struct{ in, want string }{ + {".docker", "docker"}, + {"docker", "docker"}, + {"My Docker.Dir", "mydockerdir"}, + {"_x-y_", "x-y_"}, + {"...", ""}, + } + for _, c := range cases { + if got := normalizeLikeCompose(c.in); got != c.want { + t.Errorf("normalizeLikeCompose(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestExistingComposeName(t *testing.T) { + cases := []struct { + name string + in string + want string + wantOK bool + }{ + {"none", "services:\n mendix:\n name: not-top-level\n", "", false}, + {"plain", "name: foo\nservices:\n", "foo", true}, + {"double quoted", "# c\nname: \"mxcli-x\"\nservices:\n", "\"mxcli-x\"", true}, + {"single quoted with comment", "name: 'a-b' # why\nservices:\n", "'a-b'", true}, + {"interpolated", "name: ${PROJ:-mine}\n", "${PROJ:-mine}", true}, + {"commented out", "# name: nope\nservices:\n", "", false}, + {"indented is not top level", " name: nope\n", "", false}, + {"crlf", "name: foo\r\nservices:\r\n", "foo", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := existingComposeName([]byte(c.in)) + if got != c.want || ok != c.wantOK { + t.Errorf("existingComposeName() = (%q, %v), want (%q, %v)", got, ok, c.want, c.wantOK) + } + }) + } +} + +// initInto runs `docker init` for a fresh app folder named folder under root +// and returns the generated compose file's content. +func initInto(t *testing.T, root, folder string) (compose string, out string) { + t.Helper() + appDir := filepath.Join(root, folder) + if err := os.MkdirAll(appDir, 0755); err != nil { + t.Fatal(err) + } + mpr := filepath.Join(appDir, "app.mpr") + if err := os.WriteFile(mpr, nil, 0644); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Stdout: &buf}); err != nil { + t.Fatalf("Init: %v", err) + } + data, err := os.ReadFile(filepath.Join(appDir, ".docker", "docker-compose.yml")) + if err != nil { + t.Fatal(err) + } + return string(data), buf.String() +} + +func TestInit_EachProjectGetsItsOwnComposeName(t *testing.T) { + // Issue #1364: compose derived the project name from the ".docker" folder, + // so every mxcli project was the compose project "docker" and their + // containers and volumes collided. + root := t.TempDir() + a, _ := initInto(t, root, "AppOne") + b, _ := initInto(t, root, "AppTwo") + + nameA, okA := existingComposeName([]byte(a)) + nameB, okB := existingComposeName([]byte(b)) + if !okA || !okB { + t.Fatalf("generated compose files must carry a top-level name: (%q, %v) (%q, %v)", nameA, okA, nameB, okB) + } + if nameA == nameB { + t.Errorf("two projects share compose project name %s", nameA) + } + if nameA != `"mxcli-appone"` || nameB != `"mxcli-apptwo"` { + t.Errorf("names = %s, %s; want \"mxcli-appone\", \"mxcli-apptwo\"", nameA, nameB) + } + for _, c := range []string{a, b} { + if strings.Contains(c, composeNamePlaceholder) { + t.Error("template placeholder left in generated compose file") + } + if got := composeVersionOf(c); got < 3 { + t.Errorf("generated template version = %d, want >= 3 (the name: line is part of v3)", got) + } + } +} + +func composeVersionOf(content string) int { return parseTemplateVersion([]byte(content)) } + +func TestInit_ReportsComposeProjectName(t *testing.T) { + _, out := initInto(t, t.TempDir(), "Shop") + if !strings.Contains(out, "mxcli-shop") { + t.Errorf("init output should name the compose project; got:\n%s", out) + } +} + +// legacyProject lays out a project initialised by an older mxcli: a compose +// file with no name, so Compose named the stack after the ".docker" folder. +func legacyProject(t *testing.T, composeBody string) (mpr, dockerDir string) { + t.Helper() + appDir := filepath.Join(t.TempDir(), "LegacyApp") + dockerDir = filepath.Join(appDir, ".docker") + if err := os.MkdirAll(dockerDir, 0755); err != nil { + t.Fatal(err) + } + mpr = filepath.Join(appDir, "app.mpr") + if err := os.WriteFile(mpr, nil, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dockerDir, "docker-compose.yml"), []byte(composeBody), 0644); err != nil { + t.Fatal(err) + } + return mpr, dockerDir +} + +const legacyCompose = "# mxcli-template-version: 2\nservices:\n mendix:\n image: x\n" + +func readName(t *testing.T, dockerDir string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dockerDir, "docker-compose.yml")) + if err != nil { + t.Fatal(err) + } + n, ok := existingComposeName(data) + if !ok { + t.Fatalf("regenerated compose file has no name:\n%s", data) + } + return n +} + +func TestInit_ForceOnLegacyProjectKeepsOldStackName(t *testing.T) { + // Renaming the stack would make Compose create new, empty volumes and + // orphan the old Postgres data. A regeneration of a file that had no name + // must therefore pin the name Compose was using: the folder name, "docker". + mpr, dockerDir := legacyProject(t, legacyCompose) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"docker"` { + t.Errorf("name after --force on a legacy project = %s, want \"docker\"", got) + } + out := buf.String() + for _, want := range []string{"docker", "mxcli-legacyapp", "empty"} { + if !strings.Contains(strings.ToLower(out), want) { + t.Errorf("output should explain the kept name and how to opt in (missing %q):\n%s", want, out) + } + } +} + +func TestInit_ForceOnLegacyCustomOutputDirKeepsFolderDerivedName(t *testing.T) { + appDir := filepath.Join(t.TempDir(), "App") + custom := filepath.Join(appDir, "My Stack.Dir") + if err := os.MkdirAll(custom, 0755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(custom, "docker-compose.yml"), []byte(legacyCompose), 0644) + mpr := filepath.Join(appDir, "app.mpr") + os.WriteFile(mpr, nil, 0644) + + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, OutputDir: custom, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, custom); got != `"mystackdir"` { + t.Errorf("name = %s, want \"mystackdir\" (what Compose derived from the folder)", got) + } +} + +func TestInit_ForceOnLegacyHonoursComposeProjectNameInEnv(t *testing.T) { + // The workaround for #1364 was COMPOSE_PROJECT_NAME in .env; --force + // rewrites .env, so the value has to move into the compose file or the + // stack is renamed behind the user's back. + mpr, dockerDir := legacyProject(t, legacyCompose) + os.WriteFile(filepath.Join(dockerDir, ".env"), []byte("APP_PORT=9000\nCOMPOSE_PROJECT_NAME=\"shop\"\n"), 0644) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"shop"` { + t.Errorf("name = %s, want \"shop\" (from the old .env)", got) + } +} + +func TestInit_ForceCarriesEnvProjectNameOverExplicitName(t *testing.T) { + // Compose prefers COMPOSE_PROJECT_NAME over name:, so a stack with both is + // really named by .env. --force rewrites .env; the real name must survive. + mpr, dockerDir := legacyProject(t, "# mxcli-template-version: 3\nname: \"mxcli-a\"\nservices:\n") + os.WriteFile(filepath.Join(dockerDir, ".env"), []byte("COMPOSE_PROJECT_NAME=shop\n"), 0644) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"shop"` { + t.Errorf("name = %s, want \"shop\" (the name Compose was really using)", got) + } + env, _ := os.ReadFile(filepath.Join(dockerDir, ".env")) + if strings.Contains(string(env), "COMPOSE_PROJECT_NAME") { + t.Error(".env should be the regenerated template") + } +} + +func TestInit_NewComposeFileHonoursEnvProjectName(t *testing.T) { + // compose file deleted, .env (with the old workaround) still there + mpr, dockerDir := legacyProject(t, legacyCompose) + os.Remove(filepath.Join(dockerDir, "docker-compose.yml")) + os.WriteFile(filepath.Join(dockerDir, ".env"), []byte("COMPOSE_PROJECT_NAME=shop\n"), 0644) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != `"shop"` { + t.Errorf("name = %s, want \"shop\"", got) + } +} + +func TestInit_ForceKeepsExplicitName(t *testing.T) { + // A user who opted in (or chose their own name) keeps it across --force. + mpr, dockerDir := legacyProject(t, "# mxcli-template-version: 3\nname: my-own\nservices:\n") + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Force: true, Stdout: &buf}); err != nil { + t.Fatal(err) + } + if got := readName(t, dockerDir); got != "my-own" { + t.Errorf("name = %s, want my-own", got) + } +} + +func TestInit_NoForceNeverRenames(t *testing.T) { + // Without --force the compose file is left alone, and a deleted .env being + // recreated must not introduce a project name either. + mpr, dockerDir := legacyProject(t, legacyCompose) + var buf bytes.Buffer + if err := Init(InitOptions{ProjectPath: mpr, Stdout: &buf}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(filepath.Join(dockerDir, "docker-compose.yml")) + if string(data) != legacyCompose { + t.Errorf("compose file must be untouched without --force:\n%s", data) + } + env, _ := os.ReadFile(filepath.Join(dockerDir, ".env")) + if strings.Contains(string(env), "COMPOSE_PROJECT_NAME") { + t.Error(".env must not set COMPOSE_PROJECT_NAME (the compose file's name: is the single source)") + } +} diff --git a/cmd/mxcli/docker/init.go b/cmd/mxcli/docker/init.go index 4f30d69363..8377c73dbc 100644 --- a/cmd/mxcli/docker/init.go +++ b/cmd/mxcli/docker/init.go @@ -49,7 +49,7 @@ func Init(opts InitOptions) error { // Write docker-compose.yml composePath := filepath.Join(dockerDir, "docker-compose.yml") - if err := writeTemplate(composePath, "templates/docker-compose.yml", opts.Force, w); err != nil { + if err := writeComposeFile(composePath, dockerDir, opts, w); err != nil { return err } @@ -101,6 +101,50 @@ func Init(opts InitOptions) error { return nil } +// writeComposeFile writes docker-compose.yml with its Compose project name +// filled in. A new file gets a name of its own; regenerating an existing one +// keeps the name the stack already had (see composename.go), because a stack's +// name is the key to its containers and its database volume. +func writeComposeFile(composePath, dockerDir string, opts InitOptions, w io.Writer) error { + if !opts.Force && fileExists(composePath) { + fmt.Fprintf(w, " Skipped %s (already exists)\n", composePath) + return nil + } + + data, err := templatesFS.ReadFile("templates/docker-compose.yml") + if err != nil { + return fmt.Errorf("reading compose template: %w", err) + } + // Decide before the file is overwritten: the old file is the evidence. + name := decideComposeName(opts.ProjectPath, dockerDir, composePath) + rendered, err := renderComposeTemplate(data, name) + if err != nil { + return err + } + if err := os.WriteFile(composePath, rendered, 0644); err != nil { + return fmt.Errorf("writing %s: %w", composePath, err) + } + + fmt.Fprintf(w, " Created %s\n", composePath) + switch name.Source { + case "new": + fmt.Fprintf(w, " Compose project name: %s (containers %s-db-1, %s-mendix-1; volume %s_postgres-data)\n", + name.Name, name.Name, name.Name, name.Name) + case "env": + fmt.Fprintf(w, " Kept Compose project name %q from COMPOSE_PROJECT_NAME in the previous .env (which is rewritten); it is now set by 'name:' in the compose file\n", name.Name) + case "kept": + fmt.Fprintf(w, " Kept Compose project name %q from the previous docker-compose.yml\n", name.Name) + case "legacy": + suggested := ComposeProjectNameFor(opts.ProjectPath) + fmt.Fprintf(w, " Kept Compose project name %q: the previous docker-compose.yml had no 'name:', so Compose used the folder name\n", name.Name) + fmt.Fprintf(w, " and your containers and database volume (%s_postgres-data) are still found under it.\n", name.Name) + fmt.Fprintf(w, " Other mxcli projects using %q share that name, and with it their containers and volumes.\n", name.Name) + fmt.Fprintf(w, " To isolate this project, change 'name:' in %s to e.g. %q. Compose then creates\n", composePath, suggested) + fmt.Fprintf(w, " NEW containers and an EMPTY database; the old volume stays untouched (back it up first if you need its data).\n") + } + return nil +} + // writeTemplate writes an embedded template file to disk. func writeTemplate(destPath, templateName string, force bool, w io.Writer) error { if !force && fileExists(destPath) { diff --git a/cmd/mxcli/docker/templates/docker-compose.yml b/cmd/mxcli/docker/templates/docker-compose.yml index 7ed4acad53..52da7b5163 100644 --- a/cmd/mxcli/docker/templates/docker-compose.yml +++ b/cmd/mxcli/docker/templates/docker-compose.yml @@ -1,7 +1,18 @@ -# mxcli-template-version: 2 +# mxcli-template-version: 3 # Regenerate with `mxcli docker init --force` when mxcli reports this file is # out of date — template fixes (e.g. the OQL dev-servlet flags) only reach an # existing .docker/ when the compose file is rewritten. +# +# Compose project name: prefixes every container, network and volume of this +# stack (-db-1, _postgres-data), so each project needs its own — +# before v3 it was the ".docker" folder name, i.e. "docker" for every project, +# and the stacks and databases of different apps collided (mxcli#1364). +# The name identifies the stack's data: change it and Compose starts NEW +# containers with an EMPTY database volume, leaving the old one behind. Keep it +# stable; `mxcli docker init --force` keeps whatever is here. `docker compose -p` +# and COMPOSE_PROJECT_NAME override it. +name: __COMPOSE_PROJECT_NAME__ + services: mendix: image: eclipse-temurin:21-jre From 5f3f1e65a2a6eb51bd927f4a794f7606ef1c705d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:36:47 +0000 Subject: [PATCH 2/5] test: pin multi-hop qualified datagrid column path (mendixlabs/mxcli#1379) `column (Attribute: "M"."AssocA_2"/"M"."AssocB_2"/"NId")` failed check with MDL-WIDGET34 + MDL-WIDGET42 on mxcli 0.25.0. The cause was already fixed by 05a05aad (qualified association hops in attributePathV3); this adds the reported shape at each layer: - check: the issue's script verbatim through parse + widget validation produces neither rule; against 05a05aad^ it reproduces both messages, including the `&{...}` text the reporter quoted. - builder: two qualified hops store Attribute M.AssocB.NId with two IndirectEntityRef steps, identical to the bare spelling (existing tests covered only one hop here). - bug-test MDL; exec + mx check on 11.12.4: 0 errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PJziWrQXsg6PNdBe14sQ4v --- ...i-hop-qualified-column-attribute-path.json | 1 + ...tagrid-column-multi-hop-qualified-path.mdl | 49 +++++++ .../cmd_pages_builder_multihop_column_test.go | 133 ++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-grammar/2026-10-12-multi-hop-qualified-column-attribute-path.json create mode 100644 mdl-examples/bug-tests/1379-datagrid-column-multi-hop-qualified-path.mdl create mode 100644 mdl/executor/cmd_pages_builder_multihop_column_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-12-multi-hop-qualified-column-attribute-path.json b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-12-multi-hop-qualified-column-attribute-path.json new file mode 100644 index 0000000000..be03567bbc --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-grammar/2026-10-12-multi-hop-qualified-column-attribute-path.json @@ -0,0 +1 @@ +{"date": "2026-10-12", "area": "mdl/grammar", "symptom": "`column (Attribute: \"MyModule\".\"AssocA_2\"/\"MyModule\".\"AssocB_2\"/\"NId\")` in a Data grid 2 over a junction entity failed `mxcli check` with MDL-WIDGET34 \"Attribute: &{…} is not an attribute binding MDL can store\" plus MDL-WIDGET42 \"cannot hold the expression\" (mxcli 0.25.0, Mendix 11.12.4).", "cause": "Same root as the 2026-10-11 qualified-association-hop finding: attributePathV3 took only bare identifiers between slashes, so module-qualified hops fell through to `keyword COLON expression` and parsed as a division. Already fixed by 05a05aad, which shipped after the 0.25.0 the reporter ran; its tests pinned two qualified hops at the parser but only one hop at the builder.", "fix": "No code change. Added the reported shape as tests at the check layer (the issue's script verbatim through visitor.Build + validateWidgetTree) and at the builder (two qualified hops -> Attribute MyModule.AssocB.NId with two IndirectEntityRef steps, equal to the bare spelling), plus a bug-test MDL. mx check on 11.12.4: 0 errors with and without widget normalisation.", "insight": "Before fixing a report, check whether HEAD already passes the reporter's script verbatim and build the commit before the suspected fix as a control: here the pre-fix binary reproduced the exact `&{…}` WIDGET34 text, which proves the match rather than assuming it from the error code. A 'two hops' parser case does not cover the builder walking two hops — pin each layer with the reported depth. Wrong turn to skip: the reporter's entities are external (OData); the path resolution is the same for persistent entities, so the repro does not need an OData service. Side observation: DESCRIBE of a snippet data view sourced from a snippet parameter prints no DataSource although the BSON stores SnippetParameter.", "issue": "mendixlabs/mxcli#1379", "file": "mdl/grammar/domains/MDLPage.g4 (attributePathV3), mdl/executor/cmd_pages_builder_input.go (resolveAssociationPathIn)", "test": "mdl/executor/cmd_pages_builder_multihop_column_test.go, mdl-examples/bug-tests/1379-datagrid-column-multi-hop-qualified-path.mdl"} diff --git a/mdl-examples/bug-tests/1379-datagrid-column-multi-hop-qualified-path.mdl b/mdl-examples/bug-tests/1379-datagrid-column-multi-hop-qualified-path.mdl new file mode 100644 index 0000000000..00ea01f9e2 --- /dev/null +++ b/mdl-examples/bug-tests/1379-datagrid-column-multi-hop-qualified-path.mdl @@ -0,0 +1,49 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1379 — Data grid 2 column bound two associations away +-- ============================================================================ +-- +-- Reported (mxcli 0.25.0, Mendix 11.12.4): +-- MDL-WIDGET34: Attribute: &{"MyModule"."AssocA_2"/"MyModule"."AssocB_2"/"Attr"} +-- is not an attribute binding MDL can store +-- MDL-WIDGET42: widget column property Attribute is an attribute property and +-- cannot hold the expression +-- +-- The module-qualified hops fell out of attributePathV3 and parsed as a division. +-- Fixed by the qualified-hop grammar change (05a05aad); the column stores +-- Attribute MyModule.AssocB.NId with an IndirectEntityRef of two steps +-- (AssocA_2 -> AssocA, AssocB_2 -> AssocB), and mx check reports 0 errors. +-- ============================================================================ + +create module Bug1379; + +create persistent entity Bug1379.Parent ( Name: String(100) ); +create persistent entity Bug1379.ParentChildAssociation ( Note: String(100) ); +create persistent entity Bug1379.AssocA ( Code: String(100) ); +create persistent entity Bug1379.AssocB ( NId: String(100) ); +create association Bug1379.Parent_2 from Bug1379.ParentChildAssociation to Bug1379.Parent; +create association Bug1379.AssocA_2 from Bug1379.ParentChildAssociation to Bug1379.AssocA; +create association Bug1379.AssocB_2 from Bug1379.AssocA to Bug1379.AssocB; + +create or modify snippet "Bug1379"."SN_Parent_Children" +folder 'Parent/Details' +( Params: ( $Parent: "Bug1379"."Parent" ) ) +{ + dataview "dvParent" ( DataSource: $Parent ) { + datagrid "dgChildren" ( + DataSource: DATABASE "Bug1379"."ParentChildAssociation" + WHERE ["Bug1379"."Parent_2" = $currentObject], + Selection: Single + ) { + -- the reported spelling: every hop module-qualified, every part quoted + column ( + Attribute: "Bug1379"."AssocA_2"/"Bug1379"."AssocB_2"/"NId", + Caption: 'ID' + ) { + textfilter textFilter_1 + } + -- the bare spelling binds the same attribute + column (Attribute: AssocA_2/AssocB_2/NId, Caption: 'ID (bare)') + } + } +}; diff --git a/mdl/executor/cmd_pages_builder_multihop_column_test.go b/mdl/executor/cmd_pages_builder_multihop_column_test.go new file mode 100644 index 0000000000..f86bed80d4 --- /dev/null +++ b/mdl/executor/cmd_pages_builder_multihop_column_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1379 — a Data grid 2 column over a junction entity, bound +// two associations away: +// +// column (Attribute: "MyModule"."AssocA_2"/"MyModule"."AssocB_2"/"NId") +// +// reported "MDL-WIDGET34 … is not an attribute binding MDL can store" plus +// MDL-WIDGET42 "cannot hold the expression". The parse half is the qualified-hop +// grammar fix; this pins the builder half for the reported shape: two hops, +// each qualified, must store the final attribute on the far entity with one +// IndirectEntityRef step per hop — the same as the bare spelling. +func multiHopPB(t *testing.T) *pageBuilder { + t.Helper() + const modID = model.ID("mod") + const junctionID = model.ID("e-junction") + const aID = model.ID("e-a") + const bID = model.ID("e-b") + return &pageBuilder{ + entityContext: "MyModule.ParentChildAssociation", + widgetScope: map[string]model.ID{}, + execCache: &executorCache{ + hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{modID: "MyModule"}}, + domainModels: []*domainmodel.DomainModel{{ + ContainerID: modID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: junctionID}, Name: "ParentChildAssociation"}, + {BaseElement: model.BaseElement{ID: aID}, Name: "AssocA"}, + {BaseElement: model.BaseElement{ID: bID}, Name: "AssocB", + Attributes: []*domainmodel.Attribute{{Name: "NId"}}}, + }, + Associations: []*domainmodel.Association{ + {Name: "AssocA_2", ParentID: junctionID, ChildID: aID, Type: domainmodel.AssociationTypeReference}, + {Name: "AssocB_2", ParentID: aID, ChildID: bID, Type: domainmodel.AssociationTypeReference}, + }, + }}, + }, + } +} + +func TestMultiHopQualifiedColumnBuildsIndirectRef(t *testing.T) { + build := func(attr string) (string, []pages.AttributeRefStep) { + spec, err := multiHopPB(t).buildColumnSpecFromAST(&ast.WidgetV3{ + Type: "column", + Properties: map[string]any{"Attribute": attr, "Caption": "ID"}, + }) + if err != nil { + t.Fatalf("build column %q: %v", attr, err) + } + return spec.Attribute, spec.AttributeRefSteps + } + want := []pages.AttributeRefStep{ + {Association: "MyModule.AssocA_2", DestinationEntity: "MyModule.AssocA"}, + {Association: "MyModule.AssocB_2", DestinationEntity: "MyModule.AssocB"}, + } + for _, path := range []string{ + "AssocA_2/AssocB_2/NId", // bare: control + "MyModule.AssocA_2/MyModule.AssocB_2/NId", // the issue's spelling, unquoted by the visitor + } { + attr, steps := build(path) + if attr != "MyModule.AssocB.NId" { + t.Errorf("%s: attribute = %q, want MyModule.AssocB.NId", path, attr) + } + if !reflect.DeepEqual(steps, want) { + t.Errorf("%s: steps = %+v, want %+v", path, steps, want) + } + } +} + +// The reported script, verbatim, through parse + widget validation: no +// MDL-WIDGET34 / MDL-WIDGET42, and the column's Attribute reaches the checker +// as the path string rather than a WidgetExpressionV3. +func TestIssue1379MultiHopColumnPassesCheck(t *testing.T) { + const src = `mdl 1; +create or modify snippet "MyModule"."SN_Parent_Children" +folder 'Parent/Details' +( Params: ( $Parent: "MyModule"."Parent" ) ) +{ +dataview "dvParent" ( DataSource: $Parent ) { +datagrid "dgChildren" ( +DataSource: DATABASE "MyModule"."ParentChildAssociation" +WHERE ["MyModule"."Parent_2" = $currentObject], +Selection: Single +) { +column ( +Attribute: "MyModule"."AssocA_2"/"MyModule"."AssocB_2"/"NId", +Caption: 'ID' +) { +textfilter textFilter_1 +} +} +} +};` + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + var snip *ast.CreateSnippetStmtV3 + for _, st := range prog.Statements { + if s, ok := st.(*ast.CreateSnippetStmtV3); ok { + snip = s + } + } + if snip == nil { + t.Fatalf("no snippet statement in %#v", prog.Statements) + } + col := snip.Widgets[0].Children[0].Children[0] + if got := col.Properties["Attribute"]; got != "MyModule.AssocA_2/MyModule.AssocB_2/NId" { + t.Errorf("column Attribute = %#v (%T), want the path string", got, got) + } + registry := LoadWidgetRegistry("") + if registry == nil { + t.Fatal("LoadWidgetRegistry returned nil") + } + for _, v := range validateWidgetTree(snip.Widgets, registry, "snippet MyModule.SN_Parent_Children") { + if v.RuleID == "MDL-WIDGET34" || v.RuleID == "MDL-WIDGET42" { + t.Errorf("%s: %s", v.RuleID, v.Message) + } + } +} From b95bf01be128c2f1d8f32de84629b65884047ca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:38:58 +0000 Subject: [PATCH 3/5] fix: store show message type in ShowMessageType's case (mendixlabs/mxcli#1280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `show message … type error` was stored as Type "error". The runtime does not recognise it and shows an Information dialog, while `mx check` reports 0 errors. The visitor copied the type word verbatim. - sdk/microflows: CanonicalMessageType folds case to Information/Warning/Error - visitor and writer both store the canonical spelling (quotes stripped) - new check rule MDL-SHOWMSG01 refuses a type Mendix does not have (the reporter's `type ok`), in microflows and nanoflows Verified on 11.12.4: the pre-fix binary writes Type "error", the fixed one "Error"; `mxcli docker check` passes both, so only the stored bytes catch it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JaVHH6vsSH4yccVCBpPMTB --- ...ssage-type-lower-case-stored-verbatim.json | 1 + .../bug-tests/1280-show-message-type-case.mdl | 24 ++++++ .../1280-show-message-unknown-type.fail.mdl | 12 +++ mdl/executor/cmd_microflows_builder_calls.go | 3 +- mdl/executor/validate_microflow.go | 1 + .../validate_microflow_show_message.go | 41 ++++++++++ .../validate_microflow_show_message_test.go | 79 +++++++++++++++++++ mdl/executor/validate_nanoflow.go | 2 + mdl/visitor/visitor_microflow_actions.go | 7 +- mdl/visitor/visitor_show_message_type_test.go | 44 +++++++++++ sdk/microflows/microflows_actions.go | 18 +++++ 11 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-visitor/2026-10-12-show-message-type-lower-case-stored-verbatim.json create mode 100644 mdl-examples/bug-tests/1280-show-message-type-case.mdl create mode 100644 mdl-examples/bug-tests/1280-show-message-unknown-type.fail.mdl create mode 100644 mdl/executor/validate_microflow_show_message.go create mode 100644 mdl/executor/validate_microflow_show_message_test.go create mode 100644 mdl/visitor/visitor_show_message_type_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-12-show-message-type-lower-case-stored-verbatim.json b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-12-show-message-type-lower-case-stored-verbatim.json new file mode 100644 index 0000000000..cd9be6b016 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-visitor/2026-10-12-show-message-type-lower-case-stored-verbatim.json @@ -0,0 +1 @@ +{"area":"mdl/visitor","date":"2026-10-12","symptom":"`show message … type error` stored Microflows$ShowMessageAction Type \"error\"; the runtime showed an Information dialog for an error message while `mx check` reported 0 errors (mendixlabs/mxcli#1280). `type ok` was stored as \"ok\" the same way","cause":"buildShowMessageStatement copied the `identifierOrKeyword` text verbatim; the stored enumeration value is case-sensitive and nothing between the visitor and the writer validated it","file":"`mdl/visitor/visitor_microflow_actions.go` (buildShowMessageStatement), `mdl/executor/cmd_microflows_builder_calls.go` (addShowMessageAction), `sdk/microflows/microflows_actions.go` (CanonicalMessageType), `mdl/executor/validate_microflow_show_message.go` (MDL-SHOWMSG01)","insight":"**An enum value taken from a free-form `identifierOrKeyword` needs case-folding to the gen enum's spelling AND a check for non-members** — skills write keywords in lower case, so the lower-case spelling is the common one, and `mx check` accepts any string for an enumeration property, so neither mx check nor a build catches it; only the stored bytes or the running app do. Normalise in the visitor (so declared-vs-described AST comparison agrees) and again in the writer. Verified on 11.12.4: the pre-fix binary stores `Type = error`, the fixed one `Error`, and `docker check` passes both. Grep other `identifierOrKeyword` enum slots for the same shape","refs":["mendixlabs/mxcli#1280"],"ce":[]} diff --git a/mdl-examples/bug-tests/1280-show-message-type-case.mdl b/mdl-examples/bug-tests/1280-show-message-type-case.mdl new file mode 100644 index 0000000000..e4eb78846b --- /dev/null +++ b/mdl-examples/bug-tests/1280-show-message-type-case.mdl @@ -0,0 +1,24 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1280 — show message type is stored in the enumeration's case +-- ============================================================================ +-- +-- Reported: `show message … type error` was stored as Type "error". The runtime +-- does not recognise it and shows an Information dialog; `mx check` reports 0 +-- errors. Microflows$ShowMessageType is "Information" | "Warning" | "Error". +-- +-- Every spelling below must store the capitalised value. `describe microflow +-- Bug1280.ACT_Messages` prints `type Error` / `type Warning` / `type Information`. +-- The negative case (`type ok`, no such value) is 1280-show-message-unknown-type.fail.mdl. +-- ============================================================================ + +create module Bug1280; + +create or modify microflow Bug1280.ACT_Messages () +begin + show message 'Rejected' type error blocking; + show message 'Careful' type warning; + show message 'FYI' type information; + show message 'Loud' type ERROR; + show message 'Already canonical' type Error blocking; +end; diff --git a/mdl-examples/bug-tests/1280-show-message-unknown-type.fail.mdl b/mdl-examples/bug-tests/1280-show-message-unknown-type.fail.mdl new file mode 100644 index 0000000000..2362944019 --- /dev/null +++ b/mdl-examples/bug-tests/1280-show-message-unknown-type.fail.mdl @@ -0,0 +1,12 @@ +mdl 1; +-- mendixlabs/mxcli#1280: `ok` is not a Microflows$ShowMessageType value +-- (Information | Warning | Error). Written as-is it passes `mx check` and the +-- runtime shows an Information dialog, so `mxcli check` must refuse it +-- (MDL-SHOWMSG01). + +create module Bug1280Fail; + +create or modify microflow Bug1280Fail.ACT_Ok () +begin + show message 'Done' type ok; +end; diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index 7764e84b4e..1b73a620bd 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -1240,7 +1240,8 @@ func (fb *flowBuilder) addShowMessageAction(s *ast.ShowMessageStmt) model.ID { Translations: map[string]string{fb.textLangOrDefault(): templateText}, } - msgType := microflows.MessageType(s.Type) + // The stored value is case-sensitive (mendixlabs/mxcli#1280). + msgType, _ := microflows.CanonicalMessageType(s.Type) if msgType == "" { msgType = microflows.MessageTypeInformation } diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index dbe2cda162..3591d78e81 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -120,6 +120,7 @@ func (v *microflowValidator) addViolation(ruleID string, severity linter.Severit func (v *microflowValidator) validate(body []ast.MicroflowStatement) { v.checkListOperationIterator(body) v.checkRetrieveLimitOneAsList(body) + v.checkShowMessageType(body) v.checkRetrieveOverSystemAssociation(body) v.checkSetOnObjectVariable(v.params, body) v.checkListOperationSource(body) diff --git a/mdl/executor/validate_microflow_show_message.go b/mdl/executor/validate_microflow_show_message.go new file mode 100644 index 0000000000..3d9db55282 --- /dev/null +++ b/mdl/executor/validate_microflow_show_message.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// showMessageTypeRule is the rule ID for "show message with a type Mendix does not have". +const showMessageTypeRule = "MDL-SHOWMSG01" + +// checkShowMessageType refuses a `show message … type X` whose X is not a +// ShowMessageType value. Such a value is written as-is, `mx check` reports +// nothing, and the runtime shows an Information dialog in its place — the +// silent half of mendixlabs/mxcli#1280, whose reporter wrote `type ok`. The +// case of a valid value is not this rule's business: the visitor stores +// `type error` as "Error". +func (v *microflowValidator) checkShowMessageType(body []ast.MicroflowStatement) { + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + sm, ok := s.(*ast.ShowMessageStmt) + if !ok || sm.Type == "" { + return + } + if _, ok := microflows.CanonicalMessageType(sm.Type); ok { + return + } + valid := make([]string, len(microflows.MessageTypes)) + for i, t := range microflows.MessageTypes { + valid[i] = string(t) + } + v.addViolation(showMessageTypeRule, linter.SeverityError, + fmt.Sprintf("show message type '%s' is not a Mendix message type; the runtime would show it "+ + "as an Information dialog. Valid types: %s.", sm.Type, strings.Join(valid, ", ")), + "Use one of "+strings.Join(valid, ", ")+".") + }) +} diff --git a/mdl/executor/validate_microflow_show_message_test.go b/mdl/executor/validate_microflow_show_message_test.go new file mode 100644 index 0000000000..7834820ff2 --- /dev/null +++ b/mdl/executor/validate_microflow_show_message_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// mendixlabs/mxcli#1280: the writer must store the enumeration's spelling +// whatever case the AST carries — "error" is not a ShowMessageType value and +// the runtime renders it as an Information dialog. +func TestShowMessageActionStoresCanonicalType(t *testing.T) { + for word, want := range map[string]microflows.MessageType{ + "error": microflows.MessageTypeError, + "warning": microflows.MessageTypeWarning, + "": microflows.MessageTypeInformation, + "Error": microflows.MessageTypeError, + } { + fb := &flowBuilder{measurer: &layoutMeasurer{}} + fb.addShowMessageAction(&ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}, + Type: word, + }) + activity := fb.objects[len(fb.objects)-1].(*microflows.ActionActivity) + if got := activity.Action.(*microflows.ShowMessageAction).Type; got != want { + t.Errorf("type %q written as %q, want %q", word, got, want) + } + } +} + +// A type Mendix does not have (the reporter's `ok`) cannot be stored at all — +// the runtime would show it as Information — so check refuses it rather than +// letting exec write a model mx check passes. +func TestShowMessageUnknownTypeIsRefused(t *testing.T) { + violations := func(typ string) []string { + var out []string + for _, v := range ValidateMicroflow(mfWith(&ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}, + Type: typ, + })) { + if v.RuleID == showMessageTypeRule { + out = append(out, v.Message) + } + } + return out + } + got := violations("ok") + if len(got) != 1 || !strings.Contains(got[0], "ok") || !strings.Contains(got[0], "Information") { + t.Fatalf("type ok: got %q, want one violation naming it and the valid values", got) + } + for _, typ := range []string{"Error", "Warning", "Information", "error", ""} { + if got := violations(typ); len(got) != 0 { + t.Errorf("type %q: unexpected violation %q", typ, got) + } + } +} + +// A nanoflow's show message stores the same enumeration. +func TestShowMessageUnknownTypeIsRefusedInNanoflow(t *testing.T) { + var got []string + for _, v := range ValidateNanoflow(&ast.CreateNanoflowStmt{ + Name: ast.QualifiedName{Module: "Probe", Name: "N"}, + Body: []ast.MicroflowStatement{&ast.ShowMessageStmt{ + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}, + Type: "ok", + }}, + }) { + if v.RuleID == showMessageTypeRule { + got = append(got, v.Message) + } + } + if len(got) != 1 { + t.Fatalf("got %q, want one %s violation", got, showMessageTypeRule) + } +} diff --git a/mdl/executor/validate_nanoflow.go b/mdl/executor/validate_nanoflow.go index 5729126a95..aa632dc3da 100644 --- a/mdl/executor/validate_nanoflow.go +++ b/mdl/executor/validate_nanoflow.go @@ -48,6 +48,8 @@ func validateNanoflowWith(stmt *ast.CreateNanoflowStmt, voids *voidCodeActions) // contains/find rewrite come from the parameters and the declares. v.seedPrimitiveKinds(stmt.Parameters, stmt.Body) v.checkDuplicateVariableNames(v.params, stmt.Body) + // A nanoflow's show message has the same ShowMessageType (mendixlabs/mxcli#1280). + v.checkShowMessageType(stmt.Body) v.checkVoidCallOutputUse(v.params, stmt.Body) // MDL-SET01: a nanoflow's Change variable is as primitive-only (CE7247). v.checkSetOnObjectVariable(v.params, stmt.Body) diff --git a/mdl/visitor/visitor_microflow_actions.go b/mdl/visitor/visitor_microflow_actions.go index f03ea7afdc..a166a77cb1 100644 --- a/mdl/visitor/visitor_microflow_actions.go +++ b/mdl/visitor/visitor_microflow_actions.go @@ -9,6 +9,7 @@ import ( "github.com/antlr4-go/antlr/v4" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" + "github.com/mendixlabs/mxcli/sdk/microflows" ) // buildLogStatement converts LOG statement context to LogStmt. @@ -1309,7 +1310,11 @@ func buildShowMessageStatement(ctx parser.IShowMessageStatementContext) *ast.Sho } if id := smCtx.IdentifierOrKeyword(); id != nil { - stmt.Type = id.GetText() + // Stored in the enumeration's spelling: `type error` stored as "error" + // showed an Information dialog (mendixlabs/mxcli#1280). A word that is + // no ShowMessageType is kept as written for check to name. + t, _ := microflows.CanonicalMessageType(unquoteIdentifier(id.GetText())) + stmt.Type = string(t) } // Build template arguments (optional): `with ({1} = e)`, or the deprecated diff --git a/mdl/visitor/visitor_show_message_type_test.go b/mdl/visitor/visitor_show_message_type_test.go new file mode 100644 index 0000000000..342a1ba0c0 --- /dev/null +++ b/mdl/visitor/visitor_show_message_type_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1280: `show message … type error` was stored as "error". +// Microflows$ShowMessageAction.Type is an enumeration whose values are +// "Information", "Warning", "Error"; the runtime does not recognise "error" and +// falls back to an Information dialog, so an error message showed as +// information while `mx check` reported 0 errors. MDL keywords are written in +// lower case, so the type word must be matched case-insensitively and stored +// in the enumeration's own spelling. +func TestShowMessageTypeIsCanonicalised(t *testing.T) { + cases := map[string]string{ + "error": "Error", + "ERROR": "Error", + "Error": "Error", + "warning": "Warning", + "information": "Information", + "Information": "Information", + `"Error"`: "Error", + } + for word, want := range cases { + t.Run(word, func(t *testing.T) { + prog := mustBuild(t, "create microflow M.F () begin show message 'x' type "+word+"; end;") + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + got := mf.Body[0].(*ast.ShowMessageStmt).Type + if got != want { + t.Errorf("type %s stored as %q, want %q", word, got, want) + } + }) + } + + // Not an enumeration value: kept as written, so `check` can name it. + prog := mustBuild(t, "create microflow M.F () begin show message 'x' type ok; end;") + if got := prog.Statements[0].(*ast.CreateMicroflowStmt).Body[0].(*ast.ShowMessageStmt).Type; got != "ok" { + t.Errorf("unknown type stored as %q, want it kept as %q", got, "ok") + } +} diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index 6bd6bf3236..d79719ec43 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -4,6 +4,8 @@ package microflows import ( + "strings" + "github.com/mendixlabs/mxcli/model" ) @@ -567,6 +569,22 @@ const ( MessageTypeError MessageType = "Error" ) +// MessageTypes lists the values of Microflows$ShowMessageType, in Studio Pro's order. +var MessageTypes = []MessageType{MessageTypeInformation, MessageTypeWarning, MessageTypeError} + +// CanonicalMessageType matches s case-insensitively against the +// ShowMessageType values and returns the enumeration's own spelling. +// The stored value is case-sensitive: the runtime does not recognise "error" +// and shows an Information dialog instead (mendixlabs/mxcli#1280). +func CanonicalMessageType(s string) (MessageType, bool) { + for _, t := range MessageTypes { + if strings.EqualFold(s, string(t)) { + return t, true + } + } + return MessageType(s), false +} + // ValidationFeedbackAction shows validation feedback. type ValidationFeedbackAction struct { model.BaseElement From c933acf6d86685f08eb0f52f186c7b43687cf23c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:52:10 +0000 Subject: [PATCH 4/5] fix(microflow): on error on list activities, cast, rollback and show home page (mendixlabs/mxcli#1252) An Aggregate list activity with a custom error handler, authored in Studio Pro 11.12.3, described as a bare `$count = count($TEYAs);`, and `on error` was a parse error on list operations, aggregates, create list, add/remove, cast, rollback and show home page. Every one of these is a Microflows$MicroflowAction, which declares errorHandlingType in the Mendix Model SDK (mendixmodelsdk 4.116.0, unbounded StructureVersionInfo). MDL077 refused the clause on list operations and aggregates on the premise that the property was absent; it is removed (the navigation-icon rule keeps the ID). - grammar: onErrorClause on the eight statement rules - visitor/AST: the clause reaches each statement node - builder: ehType(clause) + finishCustomErrorHandler; rollback and show home page use explicitErrorHandling so no-clause BSON is unchanged; add/remove on a reference set pass it to the Change object activity - reader/writer: ErrorHandlingType on RollbackAction and ShowHomePageAction, which DESCRIBE needs to walk the handler branch - MDL076 and the nanoflow without-rollback-only rule extended from a matrix measured on mxbuild 11.12.3 (one document per statement x clause, microflow and nanoflow) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JNPzGjwf9HGqanBBxMrvXq --- ...st-activities-cast-rollback-home-page.json | 1 + .../skills/mendix/write-microflows/SKILL.md | 4 +- .../skills/mendix/write-nanoflows/SKILL.md | 19 +- cmd/mxcli/syntax/features_microflow.go | 10 +- docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- .../1252-on-error-list-activities.mdl | 91 ++++++++ mdl/ast/ast_microflow.go | 35 +-- .../microflow_error_handling_1252_test.go | 81 +++++++ .../modelsdk/microflow_read_actions.go | 9 +- mdl/backend/modelsdk/microflow_write.go | 12 +- .../cmd_microflows_builder_actions.go | 71 ++++--- mdl/executor/cmd_microflows_builder_calls.go | 4 + .../microflow_error_handler_1252_test.go | 200 ++++++++++++++++++ .../microflow_error_handler_authoring_test.go | 26 --- mdl/executor/nanoflow_validation.go | 51 ++++- mdl/executor/validate_microflow.go | 1 - .../validate_microflow_error_handling.go | 76 +++---- mdl/exprcheck/adapters/adapter_scope.go | 3 +- mdl/grammar/domains/MDLMicroflow.g4 | 26 +-- mdl/visitor/visitor_microflow_actions.go | 10 + mdl/visitor/visitor_microflow_statements.go | 34 ++- sdk/microflows/microflows_actions.go | 46 ++-- 22 files changed, 627 insertions(+), 185 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-12-on-error-list-activities-cast-rollback-home-page.json create mode 100644 mdl-examples/bug-tests/1252-on-error-list-activities.mdl create mode 100644 mdl/backend/modelsdk/microflow_error_handling_1252_test.go create mode 100644 mdl/executor/microflow_error_handler_1252_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-on-error-list-activities-cast-rollback-home-page.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-on-error-list-activities-cast-rollback-home-page.json new file mode 100644 index 0000000000..41b67178bf --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-on-error-list-activities-cast-rollback-home-page.json @@ -0,0 +1 @@ +{"date": "2026-10-12", "area": "mdl/executor", "symptom": "mendixlabs/mxcli#1252: a Studio Pro 11.12.3 Aggregate list activity with a custom error handler (with or without rollback) described as a bare `$count = count($TEYAs);`, and `on error` was a parse error (mismatched input 'on' expecting ';') on list operations, aggregates, create list, add/remove, cast, rollback and show home page. MDL077 additionally refused the clause on list operations and aggregates.", "cause": "THREE LAYERS, one false premise. (1) Grammar: eight statement rules had no onErrorClause. (2) MDL077 asserted Microflows$ListOperationsAction and AggregateAction have no ErrorHandlingType 'in the metamodel' — the Model SDK declares it on the base Microflows$MicroflowAction, which every one of them extends, with an unbounded StructureVersionInfo; the vendored gen types and the #591 writers carried it too. (3) Reader: actionFromGen never read ErrorHandlingType off RollbackAction or ShowHomePageAction (and their sdk structs had no field), so DESCRIBE's gate saw \"\" and dropped the whole handler branch. The list activities DID read it, so on HEAD their describe emitted a live clause the grammar then refused — the #1078 trap again.", "file": "mdl/grammar/domains/MDLMicroflow.g4; mdl/visitor/visitor_microflow_statements.go; mdl/visitor/visitor_microflow_actions.go; mdl/executor/cmd_microflows_builder_actions.go; mdl/executor/validate_microflow_error_handling.go; mdl/executor/nanoflow_validation.go; mdl/backend/modelsdk/microflow_read_actions.go; mdl/backend/modelsdk/microflow_write.go; sdk/microflows/microflows_actions.go", "fix": "onErrorClause on the eight rules; AST fields; builders use ehType(clause) + finishCustomErrorHandler (rollback/home page use explicitErrorHandling so no-clause BSON is unchanged); add/remove to a reference set pass the clause to the Change object activity; MDL077 (error-handling) removed; MDL076 and the nanoflow without-rollback-only rule extended from a measured matrix; reader/writer carry the type on rollback and show home page.", "test": "TestAuthorOnError_1252_*, TestMDL076_1252_ContinueRefusedWhereMxbuildRejectsIt, TestNanoflow_1252_ListActivitiesTakeOnlyWithoutRollback, TestDescribe_1252_ErrorHandlerSurvives, TestActionFromGen_1252_ReadsErrorHandlingType, TestMicroflowRoundTrip_1252_*; mdl-examples/bug-tests/1252-on-error-list-activities.mdl", "insight": "**Settle 'not in the metamodel' against the Model SDK before writing a rule on it.** MDL077's premise took one grep to refute: `npm pack mendixmodelsdk`, then errorHandlingType sits on the base MicroflowAction class every action extends — a property on a base class is invisible to a per-type search. A refusal rule built on an unverified absence turns a capability gap into an enforced one. **Measure, do not extrapolate, the accepted forms**: one 11.12.3 project with one microflow AND one nanoflow per (statement x clause) cell — 85 documents, a single `mx check -j out.json` (the JSON names the document; the text output does not) — gave the whole matrix in one build. It is not guessable: in a microflow all eight take custom handlers and reject continue EXCEPT rollback, which takes continue; in a nanoflow the list activities and cast share create/commit's 'without rollback only' row while rollback takes everything; show home page is microflow-only. **Default-preservation is per writer**: rollback's writer never stored a type and show home page's always stored a literal Rollback, so both use explicitErrorHandling; the list activities already wrote ehType(nil) and keep ehType. Controls: stub the visitor assignment -> builder test reports 0 Custom activities; drop the reader line -> ErrorHandlingType \"\"; drop the MDL076 rows -> continue accepted. describe -> exec of the matrix reports every document already in sync."} diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index b1807a22b2..3279491a77 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -712,8 +712,8 @@ close page on error begin return; end error; of them; `continue` is fine on `declare`, `set`, `retrieve`, `delete` and `call microflow`. Measured on 11.14.0 — note that create-*variable* and change-*variable* accept `continue` while change-*object* does not. -- **List operations and aggregates** (`$x = head $l;`, `$n = count $l;`) - have no error handling in Mendix at all — **MDL077**. +- **List/aggregate activities, `create list`, `add`/`remove`, `cast`, `show home + page`**: no `continue` (MDL076, 11.12.3, #1252); `rollback` takes every form. **In a nanoflow, almost none of them take a clause at all.** `change`, `log`, `show page`, `close page`, `show message` and `validation feedback` are CE6035 diff --git a/.claude/skills/mendix/write-nanoflows/SKILL.md b/.claude/skills/mendix/write-nanoflows/SKILL.md index d6996c37a6..3aadc22379 100644 --- a/.claude/skills/mendix/write-nanoflows/SKILL.md +++ b/.claude/skills/mendix/write-nanoflows/SKILL.md @@ -21,11 +21,11 @@ description: "Nanoflow syntax in MDL — shared with microflows, but client-side | 482-552 | NanoflowCommons JavaScript Actions Reference | | 553-591 | Disallowed Activities | | 592-595 | Return Type Restrictions | -| 596-639 | Error Handling in Nanoflows | -| 640-647 | Security (GRANT/REVOKE) | -| 648-660 | Management Commands | -| 661-671 | Common Mistakes | -| 672-686 | Validation Checklist | +| 596-640 | Error Handling in Nanoflows | +| 641-648 | Security (GRANT/REVOKE) | +| 649-661 | Management Commands | +| 662-672 | Common Mistakes | +| 673-687 | Validation Checklist | # Mendix Nanoflow Skill @@ -620,13 +620,14 @@ END ERROR; ### Which activities take which clause in a nanoflow -Measured on Mendix 11.14.0; every other form is **CE6035** "Error handling type -is not supported", and mxcli refuses it (MDL091): +Measured on Mendix 11.14.0 (the list activities, cast and rollback on 11.12.3, +mendixlabs/mxcli#1252); every other form is **CE6035** "Error handling type is +not supported", and mxcli refuses it (MDL091): | Activity in a nanoflow | Accepted clauses | |---|---| -| `DECLARE`, `SET`, `RETRIEVE`, `DELETE` | every form | -| `CREATE`, `COMMIT`, `CALL NANOFLOW`, `CALL MICROFLOW` | only `ON ERROR WITHOUT ROLLBACK BEGIN … END ERROR` | +| `DECLARE`, `SET`, `RETRIEVE`, `DELETE`, `ROLLBACK` | every form | +| `CREATE`, `COMMIT`, `CALL NANOFLOW`, `CALL MICROFLOW`, list operations (`head`, `filter`, …), aggregates (`count`, `sum`, …), `CREATE LIST OF`, `ADD … TO $List`, `REMOVE … FROM $List`, `CAST` | only `ON ERROR WITHOUT ROLLBACK BEGIN … END ERROR` | | `CHANGE`, `LOG`, `SHOW PAGE`, `CLOSE PAGE`, `SHOW MESSAGE`, `VALIDATION FEEDBACK` | none | mxcli refuses the clause rather than writing a nanoflow mxbuild rejects. The diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index e103d8fa76..f82210201d 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -257,13 +257,15 @@ func init() { "-- all of them, and CONTINUE is fine on DECLARE, SET, RETRIEVE, DELETE\n" + "-- and CALL MICROFLOW.\n" + "--\n" + - "-- List operations and aggregates ($x = head $l, $n = count $l) have\n" + - "-- no error handling in Mendix at all -> MDL077.\n" + + "-- List operations, aggregates, CREATE LIST OF, ADD, REMOVE, CAST and\n" + + "-- SHOW HOME PAGE take a custom handler but not CONTINUE -> MDL076.\n" + + "-- ROLLBACK takes every form.\n" + "--\n" + "-- IN A NANOFLOW a statement with no clause aborts the flow on error, and\n" + "-- there is no transaction to roll back. DECLARE, SET, RETRIEVE and DELETE\n" + - "-- take every clause. CREATE, COMMIT, CALL NANOFLOW and CALL MICROFLOW take\n" + - "-- only ON ERROR WITHOUT ROLLBACK BEGIN ... END ERROR. CHANGE, LOG, SHOW\n" + + "-- take every clause, and so does ROLLBACK. CREATE, COMMIT, CALL NANOFLOW,\n" + + "-- CALL MICROFLOW, list operations, aggregates, CREATE LIST OF, ADD, REMOVE\n" + + "-- and CAST take only ON ERROR WITHOUT ROLLBACK BEGIN ... END ERROR. CHANGE, LOG, SHOW\n" + "-- PAGE, CLOSE PAGE, SHOW MESSAGE and VALIDATION FEEDBACK take none. Every\n" + "-- other form is CE6035 and is refused (MDL091).\n" + "--\n" + diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index bacd54c402..4744e9ea5c 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -624,7 +624,7 @@ it is for pages. | Execute DB query | `$Result = execute database query Module.Conn.Query;` | 3-part name; supports DYNAMIC, params, CONNECTION override | | Import mapping | `[$Var =] import from mapping Module.IMM($SourceVar) [all\|first\|limit [offset ]];` | Apply import mapping to string variable. Trailing clause is Studio Pro's Range; omitted = infer from the mapping's root. `first` binds one OBJECT (`limit 1` is a one-element LIST). Mendix rejects `offset` on a non-list mapping (CE6100), and `first` on an object-rooted one throws at runtime — both MDL-MAP04 | | Export mapping | `$Var = export to mapping Module.EMM($EntityVar);` | Apply export mapping to entity, returns string | -| Error handling | `... on error continue\|rollback\|[without rollback] begin handler end error;` | Goes on the activity that may fail — including `declare`, `set`, `change`, `log`, `show page`, `close page`, `show message` and `validation feedback`, which gained it in mendixlabs/mxcli#1078 so a Studio Pro handler survives DESCRIBE. `on error continue` is refused (MDL076) where Mendix raises CE6035: create, change, commit, log, show page, close page, show message, validation feedback — a custom `begin handler end error` is accepted on all of them. The list-operation and aggregate forms of `set` have no error handling at all (MDL077). Not supported on EXECUTE DATABASE QUERY. **In a nanoflow** only `declare` and `set` take a clause at all — `change`, `log`, `show page`, `close page`, `show message` and `validation feedback` are CE6035 there in every form, and are refused. A handler that does not end in `return`/`raise error` merges back into the main flow, so a later variable is out of scope on the error path (CE0108) | +| Error handling | `... on error continue\|rollback\|[without rollback] begin handler end error;` | Goes on the activity that may fail — including `declare`, `set`, `change`, `log`, `show page`, `close page`, `show message` and `validation feedback`, which gained it in mendixlabs/mxcli#1078 so a Studio Pro handler survives DESCRIBE. `on error continue` is refused (MDL076) where Mendix raises CE6035: create, change, commit, log, show page, close page, show message, validation feedback — a custom `begin handler end error` is accepted on all of them. List operations, aggregates, `create list of`, `add`/`remove`, `cast` and `show home page` take a custom handler and `on error rollback` but not `continue` (MDL076); `rollback` takes every form (mendixlabs/mxcli#1252, measured on 11.12.3). Not supported on EXECUTE DATABASE QUERY. **In a nanoflow** `change`, `log`, `show page`, `close page`, `show message` and `validation feedback` are CE6035 in every form, and are refused; create, commit, the calls, the list activities and `cast` take only `on error without rollback begin … end error`. A handler that does not end in `return`/`raise error` merges back into the main flow, so a later variable is out of scope on the error path (CE0108) | | Re-raise the error | `raise error;` | **Inside an `on error begin … end error` handler only.** The error event re-raises the error being handled, so Mendix needs one in scope; Studio Pro will not draw the shape and mxbuild rejects it with **CE0710** "The main flow cannot join an error flow or end in an error event". On the main flow — at any nesting depth, and in a rule too — it is **MDL084**. Mendix has no main-flow "throw": call a Java action that throws | | Named join point | `merge