From bae86f44c941ad6fc25f25d253bab1e643d04324 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 13:47:24 +0000 Subject: [PATCH 01/26] deps(vscode-mdl): fix the msal-node and qs advisories - qs 6.16.0 -> 6.16.1 (GHSA-6pq8-jhm2-2vcw, moderate), within range. - @azure/msal-node 6.0.1 -> 7.2.1 (GHSA-xcx6-jx8p-x3h5, high) by overriding @azure/identity to ^4.14.1. That is inside the ^4.13.2 range @vscode/vsce declares; identity 4.14 is what moves to msal-node ^7.1. minimatch (GHSA-g3hv-75wg-m249, moderate) stays: its fix is 11.0.1 and both @vscode/vsce and vscode-languageclient, at their latest versions, declare ^10. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- vscode-mdl/bun.lock | 21 +++++++++++++++------ vscode-mdl/package.json | 3 +++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/vscode-mdl/bun.lock b/vscode-mdl/bun.lock index 4d06648f4..a8907e7b6 100644 --- a/vscode-mdl/bun.lock +++ b/vscode-mdl/bun.lock @@ -17,6 +17,9 @@ }, }, }, + "overrides": { + "@azure/identity": "^4.14.1", + }, "packages": { "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], @@ -32,7 +35,7 @@ "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], - "@azure/identity": ["@azure/identity@4.13.3", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^6.0.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A=="], + "@azure/identity": ["@azure/identity@4.14.1", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^7.1.0", "open": "^11.0.0", "tslib": "^2.2.0" } }, "sha512-HHuuFrmN1uuF9D5A6aAo5eHzNFNX4Q+P4SksZqDKse5YaJFFy/zDLG3wXHEV2FVGgetZ3QM8jGURNBtnQYf1NQ=="], "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], @@ -40,7 +43,7 @@ "@azure/msal-common": ["@azure/msal-common@16.14.1", "", {}, "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ=="], - "@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="], + "@azure/msal-node": ["@azure/msal-node@7.2.1", "", { "dependencies": { "@azure/msal-common": "16.16.1", "jsonwebtoken": "^9.0.0" } }, "sha512-6mntsfCNq7H1eAPSZik74jT3C5at4nRqiVk7OC+D5Hy3Pe3s76uwE4+J0xvDvlVPmoX2kasf3mSwUmzeq9rW/A=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], @@ -228,7 +231,7 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -274,6 +277,8 @@ "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], @@ -318,7 +323,7 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], @@ -326,9 +331,11 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], - "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + "qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="], "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], @@ -384,7 +391,7 @@ "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -396,6 +403,8 @@ "yazl": ["yazl@2.5.1", "", { "dependencies": { "buffer-crc32": "~0.2.3" } }, "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw=="], + "@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.16.1", "", {}, "sha512-dSKdL9h/iercb4GumsKFh+wVvLe1ohuDPiviklgnQVHnY2FYNUlbQJ3E1Qd37vkof9CfraK9BQpCW1hoOg0znQ=="], + "jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "vscode-languageclient/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], diff --git a/vscode-mdl/package.json b/vscode-mdl/package.json index 941d12528..6e0053740 100644 --- a/vscode-mdl/package.json +++ b/vscode-mdl/package.json @@ -238,5 +238,8 @@ "@vscode/vsce": "^4.0.0", "esbuild": "^0.28.0", "typescript": "^7.0.2" + }, + "overrides": { + "@azure/identity": "^4.14.1" } } From d83409bce31fa97b9aaba030cae838deccaaa181 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 13:47:24 +0000 Subject: [PATCH 02/26] deps(vega-charts widget): fix the zod advisory zod 4.4.3 -> 4.6.7 (GHSA-mrhx-c7xc-m3w5, high), via npm audit fix --package-lock-only, with in-range bumps of the lint tooling alongside. The remaining 18 moderate and 2 low findings are in eslint/jest dev tooling and need breaking major upgrades (--force). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiMsnXa9crge2qnSk9S7Zn --- .../widget/package-lock.json | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json b/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json index f7b80df69..81006e791 100644 --- a/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json +++ b/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json @@ -7008,17 +7008,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.2.tgz", + "integrity": "sha512-222PKFXKihmFpi/pS5njoBgdAqsPhj2/GBmFbrNw8Tq8gEG9Sv+kSlKFy4u2q+mJv9ZzJgM2Gkv/bUzRZwZw5g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.72.2", + "@typescript-eslint/type-utils": "8.72.2", + "@typescript-eslint/utils": "8.72.2", + "@typescript-eslint/visitor-keys": "8.72.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -7031,22 +7031,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.72.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.2.tgz", + "integrity": "sha512-llP+T1YuJOLlxEciP6OyQGtz5SMxyn+9LA2BvZw5RmToGVa1y4beEDVQL6Gje7T5WFYh19WREjDCN9Ev4gm5AA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.72.2", + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/typescript-estree": "8.72.2", + "@typescript-eslint/visitor-keys": "8.72.2", "debug": "^4.4.3" }, "engines": { @@ -7062,14 +7062,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.2.tgz", + "integrity": "sha512-6y5dLTZdHhI8Eh3yIQ/bfQhRKwn5e+TpJj64o18Cngc0iW/BCrVvrr6zGYqXPnYH31u6YXAYiFh0+qxX5mybJw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.72.2", + "@typescript-eslint/types": "^8.72.2", "debug": "^4.4.3" }, "engines": { @@ -7084,14 +7084,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.2.tgz", + "integrity": "sha512-iIoMJ6/8c1/QpFNyFjU0d1YzPco4MsZjQw0k3V+vfzNWAFmUmvhlyMoYEoYYDQZXc+J6Gi1X5XcxlzW2taIIGQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/visitor-keys": "8.72.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7102,9 +7102,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.2.tgz", + "integrity": "sha512-+h3iCgZXuTNtoLXcnnjP12ZUHLMqoUcIHPfQrFEXTUqvb9dF8QK9b5nv5VtSo5fLkdHuks8ehbTzZj3rImAm9w==", "dev": true, "license": "MIT", "engines": { @@ -7119,15 +7119,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.2.tgz", + "integrity": "sha512-STfkR38qIdhfkLpgtXnUjAzsNy4xouqiBO0lrVc/Wpk9Kem5/aHXUx1J8dGC16zpdYN1+yc2ILQ28er1kXX7ag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/typescript-estree": "8.72.2", + "@typescript-eslint/utils": "8.72.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -7144,9 +7144,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.2.tgz", + "integrity": "sha512-Xfn37OLZx03+PCU7qWx9DTv2FCRh1E6fk2+wONeNY15HXIwtcwuveaSwVpGAUptkNmPQegRxW5JyRel9T7wrog==", "dev": true, "license": "MIT", "engines": { @@ -7158,16 +7158,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.2.tgz", + "integrity": "sha512-pQEGqIK/cqEvKivVWaFLYpe7XYejFxfuAOi4mu1oAWFvIo36aRHI9esRFTUxcIy/z8N482RJyH+LpZB0ZIU7Ow==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.72.2", + "@typescript-eslint/tsconfig-utils": "8.72.2", + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/visitor-keys": "8.72.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -7186,16 +7186,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.2.tgz", + "integrity": "sha512-hv3eRYbpPbbpezuax1EfGrzpa3C4ySgNZkPUkLOWOAjU8HsOKowEDAr8C7nsNl1C+SBSKDpvno6VKHnlhvp9iA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.72.2", + "@typescript-eslint/types": "8.72.2", + "@typescript-eslint/typescript-estree": "8.72.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7210,13 +7210,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.72.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.2.tgz", + "integrity": "sha512-NAGrjn0Pitjqzq/GkgMC4kBYm233PQ8GffyQ9+kTA3AlNQYEzOf25EIM02BAHZv5FRJSpHpJk+OGUxVkOB93GQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.72.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8247,9 +8247,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.13.tgz", + "integrity": "sha512-Vh3dmFbc4wZeox7M9I/0L9GfFd83Su4+yCck/OF9Gi/P/dAHn0RT7X4K9R7gMNMhDck3DvhR6QXsnQdjjgwNvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10031,9 +10031,9 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "29.16.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.16.1.tgz", - "integrity": "sha512-tfxOIsjzaBud+f74aLbBMRcnrztt5eCIgnAdeoGdnzMAQ4IdAa/s/p8Ls55mk9MC79N3j2jbv4Qetz6Hclbcfw==", + "version": "29.17.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz", + "integrity": "sha512-6jtpU6MhYKbagkES02TupWAUUooJpZtKg94Ax0b8HhUorv3InSTBU8RRtlSBe0LocOcShXOpyec3PHzPDkJCSg==", "dev": true, "license": "MIT", "dependencies": { @@ -22663,9 +22663,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { From 53899c65099e345b66f59f73a77827ff3b374d33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 14:48:42 +0000 Subject: [PATCH 03/26] catalog: record Excluded for snippets and layouts CATALOG.pages had an Excluded column; CATALOG.snippets and CATALOG.layouts did not, so `select Name, Excluded from CATALOG.snippets` failed with "no such column: Excluded" and an excluded snippet or layout could not be told apart from an active one (mendixlabs/mxcli#1819, ako/mxcli#2137). - snippets_data and layouts_data gain Excluded, written from the reader. - The layout reader now reads the stored top-level Excluded flag. - snippets.ParameterCount is the snippet's parameter count, not a literal 0. - CatalogSchemaVersion 46, so cached catalogs rebuild. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- ...-11-catalog-excluded-snippets-layouts.json | 1 + docs-site/src/tools/catalog-tables.md | 24 ++++++ ...1819-catalog-excluded-snippets-layouts.mdl | 34 ++++++++ .../modelsdk/layout_excluded_read_test.go | 56 +++++++++++++ mdl/backend/modelsdk/page.go | 1 + mdl/catalog/builder_pages.go | 12 +-- mdl/catalog/builder_ui_excluded_test.go | 79 +++++++++++++++++++ mdl/catalog/tables.go | 8 +- sdk/pages/pages.go | 3 + 9 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-excluded-snippets-layouts.json create mode 100644 mdl-examples/bug-tests/1819-catalog-excluded-snippets-layouts.mdl create mode 100644 mdl/backend/modelsdk/layout_excluded_read_test.go create mode 100644 mdl/catalog/builder_ui_excluded_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-excluded-snippets-layouts.json b/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-excluded-snippets-layouts.json new file mode 100644 index 000000000..b672edf50 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-excluded-snippets-layouts.json @@ -0,0 +1 @@ +{"area":"mdl/catalog","date":"2026-11-11","symptom":"select Name, Excluded from CATALOG.snippets / CATALOG.layouts fails with 'no such column: Excluded', while CATALOG.pages has it; snippets.ParameterCount is always 0","cause":"snippets_data and layouts_data were created without an Excluded column; the snippet reader already carried Excluded but the builder never wrote it, the layout reader (layoutFromGen) never read the stored flag, and the snippet insert passed a literal 0 for ParameterCount","file":"mdl/catalog/tables.go, mdl/catalog/builder_pages.go, mdl/backend/modelsdk/page.go","insight":"When one document type gains a column, grep its siblings' INSERTs for the same name: pages, snippets and layouts are built by three functions in one file and drift independently. A literal 0 passed for a count the reader already has is the same defect one column over, and reads as a fact. Two layers to prove: the reader must read the BSON flag (backend test that sets Excluded:true in the stored unit, with the as-written false as control) and the builder must write it (catalog test with a mock backend). Bump CatalogSchemaVersion, or every cached catalog keeps failing the query.","refs":["mendixlabs/mxcli#1819","#2137"]} diff --git a/docs-site/src/tools/catalog-tables.md b/docs-site/src/tools/catalog-tables.md index 764ad8c71..ed2c72df8 100644 --- a/docs-site/src/tools/catalog-tables.md +++ b/docs-site/src/tools/catalog-tables.md @@ -280,6 +280,29 @@ ORDER BY Name; Page **templates** are not pages and are not in this table — see `CATALOG.PAGE_TEMPLATES`. +### CATALOG.SNIPPETS + +Snippets (`Forms$Snippet`). + +| Column | Description | +|--------|-------------| +| `Id` | Unique identifier | +| `Name` | Snippet name | +| `QualifiedName` | Full qualified name | +| `ModuleName` | Module containing the snippet | +| `Folder` | Folder path within the module | +| `Description` | Documentation text | +| `ParameterCount` | Number of snippet parameters | +| `WidgetCount` | Number of widgets (full build only; 0 otherwise) | +| `Excluded` | 1 when the snippet is excluded from the project | + +```sql +-- UI documents excluded from the project, for cleanup +SELECT 'PAGE' AS Kind, QualifiedName FROM CATALOG.PAGES WHERE Excluded +UNION ALL SELECT 'SNIPPET', QualifiedName FROM CATALOG.SNIPPETS WHERE Excluded +UNION ALL SELECT 'LAYOUT', QualifiedName FROM CATALOG.LAYOUTS WHERE Excluded; +``` + ### CATALOG.WIDGETS One row per widget **instance** on a page or snippet. Full build only @@ -348,6 +371,7 @@ Atlas_Core. | `LayoutType` | `Responsive`, `Phone`, `Tablet`, `ModalPopup` (web); `Default`, `Popup` (native) | | `Platform` | `Web` or `Native` — the platform every page on this layout renders on. React-client errors such as CE0582 apply to `Web` pages only | | `Description` | Documentation text | +| `Excluded` | 1 when the layout is excluded from the project | | `Class`, `Style`, `DynamicClasses` | The layout's own Appearance (full build only). Its widgets are in `CATALOG.WIDGETS` with `ContainerType = 'LAYOUT'` | ```sql diff --git a/mdl-examples/bug-tests/1819-catalog-excluded-snippets-layouts.mdl b/mdl-examples/bug-tests/1819-catalog-excluded-snippets-layouts.mdl new file mode 100644 index 000000000..794c45913 --- /dev/null +++ b/mdl-examples/bug-tests/1819-catalog-excluded-snippets-layouts.mdl @@ -0,0 +1,34 @@ +mdl 1; +-- ============================================================================ +-- Bug mendixlabs/mxcli#1819: catalog Excluded column missing for snippets and layouts +-- ============================================================================ +-- +-- Symptom (before fix): +-- select Name, Excluded from CATALOG.snippets -- fails: no such column: Excluded +-- select Name, Excluded from CATALOG.layouts -- fails: no such column: Excluded +-- (CATALOG.pages had the column.) snippets.ParameterCount was also always 0. +-- +-- After fix: both tables have Excluded (1 when the document is excluded from +-- the project), and snippets.ParameterCount counts the snippet's parameters. +-- +-- MDL cannot author an excluded snippet or layout, so an excluded row comes +-- from a document set to "Exclude from project" in Studio Pro. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1819-catalog-excluded-snippets-layouts.mdl -p app.mpr +-- mxcli -p app.mpr -c "refresh catalog full force" +-- mxcli -p app.mpr -c "select Name, ParameterCount, Excluded from CATALOG.snippets where ModuleName = 'BugTest1819'" +-- Expected: SN_Header | 1 | 0 +-- mxcli -p app.mpr -c "select Name, Excluded from CATALOG.layouts where ModuleName = 'Atlas_Core'" +-- Expected: one row per Atlas layout, Excluded 0 — no "no such column" error +-- ============================================================================ + +create module BugTest1819; + +create persistent entity BugTest1819.Item (Name: String(50)); + +create snippet BugTest1819.SN_Header (Params: ($Item: BugTest1819.Item)) { + dataview dvItem (DataSource: $Item) { + dynamictext txtName (Content: '{1}', ContentParams: ({1} = Name)) + } +}; diff --git a/mdl/backend/modelsdk/layout_excluded_read_test.go b/mdl/backend/modelsdk/layout_excluded_read_test.go new file mode 100644 index 000000000..437ff72ae --- /dev/null +++ b/mdl/backend/modelsdk/layout_excluded_read_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// mendixlabs/mxcli#1819: a layout's top-level `Excluded: true` was never read, +// so CATALOG.layouts could not report it. Read the stored flag back, with the +// as-written false as the control. +func TestListLayouts_ReadsExcluded(t *testing.T) { + b, l := layoutCanvasFixture(t) + + find := func() bool { + t.Helper() + ls, err := b.ListLayouts() + if err != nil { + t.Fatalf("ListLayouts: %v", err) + } + for _, x := range ls { + if x.ID == l.ID { + return x.Excluded + } + } + t.Fatalf("layout %s not listed", l.Name) + return false + } + if find() { + t.Fatal("control: a freshly written layout reads as excluded") + } + + raw, err := b.reader.GetRawUnitBytes(string(l.ID)) + if err != nil { + t.Fatal(err) + } + var d bson.D + if err := bson.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + bsonnav.DSet(d, "Excluded", true) + out, err := bson.Marshal(d) + if err != nil { + t.Fatal(err) + } + if err := b.writer.UpdateRawUnit(string(l.ID), out); err != nil { + t.Fatal(err) + } + if !find() { + t.Error("a layout stored with Excluded: true reads as not excluded") + } +} diff --git a/mdl/backend/modelsdk/page.go b/mdl/backend/modelsdk/page.go index 2fd5fd210..56c84a6f5 100644 --- a/mdl/backend/modelsdk/page.go +++ b/mdl/backend/modelsdk/page.go @@ -274,6 +274,7 @@ func layoutFromGen(u mprread.Unit[*genPg.Layout]) *pages.Layout { ContainerID: u.ContainerID, Name: u.Element.Name(), Documentation: u.Element.Documentation(), + Excluded: u.Element.Excluded(), LayoutType: layoutTypeOf(u.Element), Native: layoutIsNative(u.Element), Class: appearanceClassOf(u.Element), diff --git a/mdl/catalog/builder_pages.go b/mdl/catalog/builder_pages.go index 54e7a888d..f3fdcfc9b 100644 --- a/mdl/catalog/builder_pages.go +++ b/mdl/catalog/builder_pages.go @@ -156,9 +156,9 @@ func (b *Builder) buildSnippets() error { snippetStmt, err := b.tx.Prepare(` INSERT INTO snippets_data (Id, Name, QualifiedName, ModuleName, Folder, Description, - ParameterCount, WidgetCount, + ParameterCount, WidgetCount, Excluded, ProjectId, SnapshotId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -211,8 +211,9 @@ func (b *Builder) buildSnippets() error { moduleName, folder, sn.Documentation, - 0, // ParameterCount + len(sn.Parameters), len(rawWidgets), + sn.Excluded, projectID, snapshotID, ); err != nil { return err @@ -1044,8 +1045,8 @@ func (b *Builder) buildLayouts() error { layoutStmt, err := b.tx.Prepare(` INSERT INTO layouts_data (Id, Name, QualifiedName, ModuleName, Folder, LayoutType, Platform, - Description, Class, Style, DynamicClasses, ProjectId, SnapshotId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + Description, Excluded, Class, Style, DynamicClasses, ProjectId, SnapshotId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -1103,6 +1104,7 @@ func (b *Builder) buildLayouts() error { string(l.LayoutType), layoutPlatform(l), l.Documentation, + l.Excluded, class, style, dynamicClasses, projectID, snapshotID, ) diff --git a/mdl/catalog/builder_ui_excluded_test.go b/mdl/catalog/builder_ui_excluded_test.go new file mode 100644 index 000000000..0f8c859d6 --- /dev/null +++ b/mdl/catalog/builder_ui_excluded_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1819: CATALOG.pages has an Excluded column, CATALOG.snippets +// and CATALOG.layouts did not — `select Name, Excluded from CATALOG.snippets` +// failed with "no such column: Excluded", so a snippet or layout set to +// "Exclude from project" could not be told apart from an active one. +func TestSnippetAndLayoutTables_RecordExcluded(t *testing.T) { + reader := &mock.MockBackend{ + ListSnippetsFunc: func() ([]*pages.Snippet, error) { + return []*pages.Snippet{ + {BaseElement: model.BaseElement{ID: "sn-excluded"}, ContainerID: twinModuleID, Name: "SN_Old", Excluded: true}, + {BaseElement: model.BaseElement{ID: "sn-live"}, ContainerID: twinModuleID, Name: "SN_New", + Parameters: []*pages.SnippetParameter{{Name: "Item"}, {Name: "Other"}}}, + }, nil + }, + ListLayoutsFunc: func() ([]*pages.Layout, error) { + return []*pages.Layout{ + {BaseElement: model.BaseElement{ID: "ly-excluded"}, ContainerID: twinModuleID, Name: "Ly_Old", Excluded: true}, + {BaseElement: model.BaseElement{ID: "ly-live"}, ContainerID: twinModuleID, Name: "Ly_New"}, + }, nil + }, + } + b, cat := twinBuilder(t, reader, nil) + if err := b.buildSnippets(); err != nil { + t.Fatalf("buildSnippets: %v", err) + } + if err := b.buildLayouts(); err != nil { + t.Fatalf("buildLayouts: %v", err) + } + if err := b.tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + for _, tc := range []struct{ table, liveID, excludedID string }{ + {"snippets", "sn-live", "sn-excluded"}, + {"layouts", "ly-live", "ly-excluded"}, + } { + rows, err := cat.db.Query("SELECT Id, Excluded FROM " + tc.table) + if err != nil { + t.Fatalf("%s: %v", tc.table, err) + } + got := map[string]bool{} + for rows.Next() { + var id string + var excluded bool + if err := rows.Scan(&id, &excluded); err != nil { + t.Fatalf("%s scan: %v", tc.table, err) + } + got[id] = excluded + } + rows.Close() + if ex, ok := got[tc.excludedID]; !ok || !ex { + t.Errorf("%s: the excluded document must have Excluded=1; got %v", tc.table, got) + } + if ex, ok := got[tc.liveID]; !ok || ex { + t.Errorf("%s: the active document must have Excluded=0; got %v", tc.table, got) + } + } + + // The snippet's ParameterCount was a literal 0 beside the same row: the + // same "absent" answer for a fact the reader already had. + var n int + if err := cat.db.QueryRow("SELECT ParameterCount FROM snippets WHERE Id = 'sn-live'").Scan(&n); err != nil { + t.Fatalf("ParameterCount: %v", err) + } + if n != 2 { + t.Errorf("snippets.ParameterCount = %d, want 2", n) + } +} diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index f9037ca5b..1faa983e0 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,10 @@ package catalog // // History: // +// 46 (excluded UI documents): snippets and layouts gain Excluded, as pages +// already had, and snippets.ParameterCount is the snippet's parameter +// count instead of a literal 0 (mendixlabs/mxcli#1819). Without the bump +// a cached catalog lacks the column and every query naming it fails. // 45 (agent tools and MCP endpoints): agent_tools no longer holds an // agent's MCP services, which agent_mcp_services already lists, so it // matches ToolCount; consumed_mcp_services gains Endpoint and @@ -247,7 +251,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "45" +const CatalogSchemaVersion = "46" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. @@ -541,6 +545,7 @@ func (c *Catalog) createTables() error { Description TEXT, ParameterCount INTEGER DEFAULT 0, WidgetCount INTEGER DEFAULT 0, + Excluded BOOLEAN DEFAULT 0, ProjectId TEXT, SnapshotId TEXT )`, @@ -573,6 +578,7 @@ func (c *Catalog) createTables() error { LayoutType TEXT, Platform TEXT, Description TEXT, + Excluded BOOLEAN DEFAULT 0, -- The layout's own Forms$Appearance (full build only). Class TEXT, Style TEXT, diff --git a/sdk/pages/pages.go b/sdk/pages/pages.go index 3353e9448..1abb73c6f 100644 --- a/sdk/pages/pages.go +++ b/sdk/pages/pages.go @@ -63,6 +63,9 @@ type Layout struct { Name string `json:"name"` Documentation string `json:"documentation,omitempty"` LayoutType LayoutType `json:"layoutType"` + // Excluded mirrors Studio Pro's "Exclude from project" (read only; the + // layout writer still writes false). + Excluded bool `json:"excluded,omitempty"` // Native selects Forms$NativeLayoutContent over Forms$WebLayoutContent. Native bool `json:"native,omitempty"` // Class and Style are the layout's own Forms$Appearance. From f822deec0b95b82bdbd4a68a2c16efe5588f5a84 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:02:28 +0000 Subject: [PATCH 04/26] pages: write the data view's parameter beside it in a flow argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A microflow or nanoflow argument naming an enclosing data view that shows a page or snippet parameter (`Item = $dvOuter`) was written as Forms$PageVariable { Widget: dvOuter } alone, and mx check reported CE0115 "The arguments that are passed to microflow '…' do not match the expected parameters" on every button, container, list view data source and pluggable action using it (ako/mxcli#2095, mendixlabs/mxcli#1796). The mapping now stores the pair Studio Pro wants, Widget plus the PageParameter or SnippetParameter the data view shows — the pair registerDataViewVariable already records for `$dv.Attr` bindings. A grid's selection and a microflow-fed data view keep Widget alone. describe reads the Widget slot first, so the pair round-trips as `$dvOuter`. Measured on Mendix 11.14.0: the same script was 5 CE0115 before, 0 errors after, and describe -> exec reports the documents already in sync. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- ...arg-dataview-showing-parameter-ce0115.json | 1 + ...95-flow-arg-dataview-showing-parameter.mdl | 71 ++++++++++++ mdl/backend/modelsdk/widget_write.go | 14 ++- ...dget_write_dataview_param_arg_2095_test.go | 65 +++++++++++ .../modelsdk/widget_write_legacy_gaps.go | 2 +- mdl/executor/cmd_pages_builder_v3.go | 3 + mdl/executor/cmd_pages_flow_args.go | 30 +++++- ...ages_flow_args_dataview_param_2095_test.go | 102 ++++++++++++++++++ sdk/pages/pages_widgets_action.go | 18 +++- 9 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-flow-arg-dataview-showing-parameter-ce0115.json create mode 100644 mdl-examples/bug-tests/2095-flow-arg-dataview-showing-parameter.mdl create mode 100644 mdl/backend/modelsdk/widget_write_dataview_param_arg_2095_test.go create mode 100644 mdl/executor/cmd_pages_flow_args_dataview_param_2095_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-flow-arg-dataview-showing-parameter-ce0115.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-flow-arg-dataview-showing-parameter-ce0115.json new file mode 100644 index 000000000..0e19739cf --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-flow-arg-dataview-showing-parameter-ce0115.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"A flow argument naming an enclosing data view that shows a page or snippet parameter (Item = $dvOuter) passes check and exec, then mx check reports CE0115 'The arguments that are passed to microflow ... do not match the expected parameters and need to be refreshed' on every button, container, list view data source and pluggable action using it; regression since v0.28","cause":"classifyFlowArgValue returns kind 'widget' for any document widget (#1560, a grid's selection), and bindParameterMappingValue wrote PageVariable { Widget } alone; a data view showing a parameter needs the pair Widget + PageParameter/SnippetParameter. describe also read PageParameter before Widget, so a stored pair printed as $Item and the round trip dropped the widget","file":"mdl/executor/cmd_pages_flow_args.go (flowArgWidgetParameter, flowArgVariableValue), mdl/backend/modelsdk/widget_write.go (bindParameterMappingValue), sdk/pages/pages_widgets_action.go","insight":"The pair was already known: registerDataViewVariable records it for $dv.Attr text bindings (#826). When a kind covers two different things (a selection and a data view's object) the fix for one is the regression of the other; split on what the builder already recorded rather than adding a second classifier. The reporter's variant table settled it: dvMf (microflow-fed data view) passes Widget-only, so the pair is needed exactly when a parameter is behind the data view. Prove at mx check: same script, pre-fix binary 5 CE0115, fixed 0, and describe -> exec reports 'already in sync'.","refs":["#2095","mendixlabs/mxcli#1796","#1560","#826"],"ce":["CE0115"]} diff --git a/mdl-examples/bug-tests/2095-flow-arg-dataview-showing-parameter.mdl b/mdl-examples/bug-tests/2095-flow-arg-dataview-showing-parameter.mdl new file mode 100644 index 000000000..65f3449b6 --- /dev/null +++ b/mdl-examples/bug-tests/2095-flow-arg-dataview-showing-parameter.mdl @@ -0,0 +1,71 @@ +mdl 1; +-- ============================================================================ +-- Bug ako/mxcli#2095 (mendixlabs/mxcli#1796): a flow argument naming an enclosing +-- data view that shows a page/snippet parameter is written as PageVariable { Widget } only +-- ============================================================================ +-- +-- Symptom (before fix): check and exec pass, then mx check reports +-- [CE0115] "The arguments that are passed to microflow 'ModV.ACT_Open' do not +-- match the expected parameters and need to be refreshed." at Action button 'btnDv' +-- on every `Item = $dvOuter` (button microflow/nanoflow, container on-click, +-- list view data source, Badge on-click, snippet button) — 6 errors here. +-- +-- After fix: the mapping stores the pair Widget = 'dvOuter' + PageParameter +-- (or SnippetParameter) = 'Item', as a `$dvOuter.Attr` binding does; mx check +-- 0 errors. `$dvMf` (a data view fed by a microflow) keeps Widget alone, which +-- passes. describe prints `$dvOuter` back, and describe -> exec is unchanged. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/2095-flow-arg-dataview-showing-parameter.mdl -p app.mpr +-- mxcli docker check -p app.mpr -- expect 0 errors +-- ============================================================================ +create module BugTest2095; +create persistent entity BugTest2095.Item (Name: String(50)); +create non-persistent entity BugTest2095.Row (Line: String(200)); + +create microflow BugTest2095.ACT_Open ($Item: BugTest2095.Item) +begin + log info node 'BugTest2095' 'open'; +end; +create nanoflow BugTest2095.NF_Open ($Item: BugTest2095.Item) +begin + log info node 'BugTest2095' 'open'; +end; +create microflow BugTest2095.DS_Rows ($Item: BugTest2095.Item) returns List of BugTest2095.Row as $Rows +begin + $Rows = create list of BugTest2095.Row; + return $Rows; +end; +create microflow BugTest2095.DS_One ($Item: BugTest2095.Item) returns BugTest2095.Item as $One +begin + return $Item; +end; + +create page BugTest2095.PageA ( Title: 'A', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: BugTest2095.Item ) ) { + dataview dvOuter (DataSource: $Item) { + dataview dvInner (DataSource: $Item) { + badge bdgDv ( type: 'badge', onClick: CALL MICROFLOW BugTest2095.ACT_Open(Item = $dvOuter) ) + actionbutton btnDv (Caption: 'dv', Action: CALL MICROFLOW BugTest2095.ACT_Open(Item = $dvOuter)) + actionbutton btnPar (Caption: 'par', Action: CALL MICROFLOW BugTest2095.ACT_Open(Item = $Item)) + actionbutton btnNfDv (Caption: 'nf dv', Action: CALL NANOFLOW BugTest2095.NF_Open(Item = $dvOuter)) + container ctDv (OnClick: CALL MICROFLOW BugTest2095.ACT_Open(Item = $dvOuter)) { dynamictext ctDvT (Content: 'dv') } + listview lvDv (DataSource: MICROFLOW BugTest2095.DS_Rows(Item = $dvOuter)) { dynamictext lvDvT (Content: '{1}', ContentParams: ({1} = Line)) } + } + } +}; + +create page BugTest2095.PageB ( Title: 'B', Layout: Atlas_Core.Atlas_Default, Params: ( $Item: BugTest2095.Item ) ) { + dataview dvMf (DataSource: MICROFLOW BugTest2095.DS_One(Item = $Item)) { + dataview dvMfInner (DataSource: $Item) { + actionbutton btnMf (Caption: 'mf', Action: CALL MICROFLOW BugTest2095.ACT_Open(Item = $dvMf)) + } + } +}; + +create snippet BugTest2095.SN_V ( Params: ( $Item: BugTest2095.Item ) ) { + dataview dvSnip (DataSource: $Item) { + dataview dvSnipInner (DataSource: $Item) { + actionbutton btnSn (Caption: 'sn', Action: CALL MICROFLOW BugTest2095.ACT_Open(Item = $dvSnip)) + } + } +}; diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 8b8f87a6d..49885aeab 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -2099,13 +2099,17 @@ type parameterMappingTarget interface { // // Kind "widget" is a widget's selection: the PageVariable names the widget and // nothing else, as on all 13 such mappings in TestApp's WorkflowCommons -// (ako/mxcli#1560). It used to fall to the page-parameter slot. -func bindParameterMappingValue(m parameterMappingTarget, variable, kind, expression string) { +// (ako/mxcli#1560). It used to fall to the page-parameter slot. A data view +// showing a page or snippet parameter names that parameter beside the widget +// (widgetParam, widgetParamKind "" or "snippet"): Widget alone is CE0115 there +// (ako/mxcli#2095), while a data view with no parameter behind it keeps Widget +// alone, which passes. +func bindParameterMappingValue(m parameterMappingTarget, variable, kind, expression, widgetParam, widgetParamKind string) { if variable != "" && kind != "" { name := strings.TrimPrefix(variable, "$") switch kind { case "widget": - m.SetVariable(pageVariableToGen(name, "", "")) + m.SetVariable(pageVariableToGen(name, widgetParam, widgetParamKind)) case "parameter": // sourceVariableToGen spells the page-parameter slot as the empty kind. m.SetVariable(sourceVariableToGen(name, "")) @@ -2154,7 +2158,7 @@ func microflowSettingsToGen(microflowName string, mappings []*pages.MicroflowPar assignID(gm) // Parameter is a BY_NAME reference: .. gm.SetParameterQualifiedName(microflowName + "." + pm.ParameterName) - bindParameterMappingValue(gm, pm.Variable, pm.VariableKind, pm.Expression) + bindParameterMappingValue(gm, pm.Variable, pm.VariableKind, pm.Expression, pm.WidgetParameter, pm.WidgetParameterKind) s.AddParameterMappings(gm) } for _, om := range outputs { @@ -2505,7 +2509,7 @@ func clientActionToGen(a pages.ClientAction) (element.Element, error) { assignID(m) // Parameter is a BY_NAME reference: Nanoflow.ParamName. m.SetParameterQualifiedName(x.NanoflowName + "." + pm.ParameterName) - bindParameterMappingValue(m, pm.Variable, pm.VariableKind, pm.Expression) + bindParameterMappingValue(m, pm.Variable, pm.VariableKind, pm.Expression, pm.WidgetParameter, pm.WidgetParameterKind) g.AddParameterMappings(m) } for _, om := range x.OutputMappings { diff --git a/mdl/backend/modelsdk/widget_write_dataview_param_arg_2095_test.go b/mdl/backend/modelsdk/widget_write_dataview_param_arg_2095_test.go new file mode 100644 index 000000000..04cb033ff --- /dev/null +++ b/mdl/backend/modelsdk/widget_write_dataview_param_arg_2095_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + genPg "github.com/mendixlabs/mxcli/modelsdk/gen/pages" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#2095: a flow argument naming a data view that shows a page or +// snippet parameter is the pair Widget + PageParameter / SnippetParameter, the +// shape the reporter hand-patched to mx check 0 errors. Widget alone is CE0115. +func TestFlowCallWritesDataViewParameterPair(t *testing.T) { + for _, tc := range []struct { + name string + param, slot string + wantPage, wantSnippet string + }{ + {"page parameter", "Item", "", "Item", ""}, + {"snippet parameter", "Item", "snippet", "", "Item"}, + {"no parameter (control)", "", "", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + mf, err := clientActionToGen(&pages.MicroflowClientAction{ + BaseElement: model.BaseElement{ID: "a"}, + MicroflowName: "ModV.ACT_Open", + ParameterMappings: []*pages.MicroflowParameterMapping{{ + ParameterName: "Item", Variable: "$dvOuter", VariableKind: "widget", + WidgetParameter: tc.param, WidgetParameterKind: tc.slot, + }}, + }) + if err != nil { + t.Fatal(err) + } + nf, err := clientActionToGen(&pages.NanoflowClientAction{ + BaseElement: model.BaseElement{ID: "b"}, + NanoflowName: "ModV.NF_Open", + ParameterMappings: []*pages.NanoflowParameterMapping{{ + ParameterName: "Item", Variable: "$dvOuter", VariableKind: "widget", + WidgetParameter: tc.param, WidgetParameterKind: tc.slot, + }}, + }) + if err != nil { + t.Fatal(err) + } + mfm := mf.(*genPg.MicroflowClientAction).MicroflowSettings().(*genPg.MicroflowSettings).ParameterMappingsItems()[0].(*genPg.MicroflowParameterMapping) + nfm := nf.(*genPg.CallNanoflowClientAction).ParameterMappingsItems()[0].(*genPg.NanoflowParameterMapping) + for kind, v := range map[string]any{"microflow": mfm.Variable(), "nanoflow": nfm.Variable()} { + pv, ok := v.(*genPg.PageVariable) + if !ok { + t.Fatalf("%s: Variable = %T", kind, v) + } + if pv.WidgetQualifiedName() != "dvOuter" || pv.PageParameterQualifiedName() != tc.wantPage || + pv.SnippetParameterQualifiedName() != tc.wantSnippet { + t.Errorf("%s: PageVariable widget=%q page=%q snippet=%q, want dvOuter/%q/%q", kind, + pv.WidgetQualifiedName(), pv.PageParameterQualifiedName(), pv.SnippetParameterQualifiedName(), + tc.wantPage, tc.wantSnippet) + } + } + }) + } +} diff --git a/mdl/backend/modelsdk/widget_write_legacy_gaps.go b/mdl/backend/modelsdk/widget_write_legacy_gaps.go index c348382c6..fd7d2ca6d 100644 --- a/mdl/backend/modelsdk/widget_write_legacy_gaps.go +++ b/mdl/backend/modelsdk/widget_write_legacy_gaps.go @@ -255,7 +255,7 @@ func nanoflowSourceToGen(d *pages.NanoflowSource) element.Element { assignID(m) // Parameter is a BY_NAME reference: .. m.SetParameterQualifiedName(d.Nanoflow + "." + pm.ParameterName) - bindParameterMappingValue(m, pm.Variable, pm.VariableKind, pm.Expression) + bindParameterMappingValue(m, pm.Variable, pm.VariableKind, pm.Expression, pm.WidgetParameter, pm.WidgetParameterKind) src.AddParameterMappings(m) } return src diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index ebfc599e7..1a33cac2e 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -1969,6 +1969,7 @@ func (pb *pageBuilder) buildClientActionV3Base(action *ast.ActionV3) (pages.Clie if strVal, ok := arg.Value.(string); ok { if v, kind := pb.classifyFlowArgValue(strVal); kind != "" { mapping.Variable, mapping.VariableKind = v, kind + mapping.WidgetParameter, mapping.WidgetParameterKind = pb.flowArgWidgetParameter(v, kind) } else if strings.HasPrefix(strVal, "$") { mapping.Variable = strVal } else { @@ -2013,6 +2014,7 @@ func (pb *pageBuilder) buildClientActionV3Base(action *ast.ActionV3) (pages.Clie if strVal, ok := arg.Value.(string); ok { if v, kind := pb.classifyFlowArgValue(strVal); kind != "" { mapping.Variable, mapping.VariableKind = v, kind + mapping.WidgetParameter, mapping.WidgetParameterKind = pb.flowArgWidgetParameter(v, kind) } else if strings.HasPrefix(strVal, "$") { mapping.Variable = strVal } else { @@ -3363,6 +3365,7 @@ func (pb *pageBuilder) flowArgsToParameterMappings(args []ast.FlowArgV3) []*page if strVal, ok := arg.Value.(string); ok { if v, kind := pb.classifyFlowArgValue(strVal); kind != "" { mapping.Variable, mapping.VariableKind = v, kind + mapping.WidgetParameter, mapping.WidgetParameterKind = pb.flowArgWidgetParameter(v, kind) } else if strings.HasPrefix(strVal, "$") { mapping.Variable = strVal } else { diff --git a/mdl/executor/cmd_pages_flow_args.go b/mdl/executor/cmd_pages_flow_args.go index ff9e7d9de..d0e7188e4 100644 --- a/mdl/executor/cmd_pages_flow_args.go +++ b/mdl/executor/cmd_pages_flow_args.go @@ -68,6 +68,26 @@ func (pb *pageBuilder) classifyFlowArgValue(value string) (variable, kind string return "", "" } +// flowArgWidgetParameter is the parameter a "widget" flow argument names beside +// the widget, when that widget is an enclosing data view showing a page or +// snippet parameter: the pair registerDataViewVariable records for the data +// view's `$dv.Attr` bindings (ako/mxcli#826). Studio Pro wants the same pair on +// a flow argument — Widget alone there is mx check CE0115 "The arguments that +// are passed to microflow '…' do not match the expected parameters" +// (ako/mxcli#2095), while hand-patching PageParameter beside it measured 0 +// errors. A grid's or combo box's selection, and a data view whose object comes +// from a microflow, have no parameter behind them and keep Widget alone (#1560). +func (pb *pageBuilder) flowArgWidgetParameter(variable, kind string) (param, paramKind string) { + if kind != "widget" { + return "", "" + } + wv, ok := pb.dataViewVariables[strings.TrimPrefix(variable, "$")] + if !ok || wv.Variable == "" { + return "", "" + } + return wv.Variable, wv.Kind +} + // pageVariableArgValue renders a mapping's Variable — a Forms$PageVariable — as // the MDL `$name` that produced it, or "" when there is no variable binding. // @@ -107,16 +127,18 @@ func pageVariableArgValue(raw any) string { // comboBox1). describe printed the call with the argument gone, so check // reported CE1571 on a page that builds and exec wrote the call unbound // (ako/mxcli#1560); the builder binds `$widget` back to the same slot. +// +// The Widget slot is read first: a data view showing a page or snippet +// parameter is stored as the pair, Widget plus that parameter, and naming the +// parameter instead would make describe -> exec drop the widget +// (ako/mxcli#2095). The builder writes `$widget` back as the same pair. func flowArgVariableValue(raw any) string { - if value := pageVariableArgValue(raw); value != "" { - return value - } if v, ok := raw.(map[string]any); ok { if w := extractString(v["Widget"]); w != "" { return "$" + w } } - return "" + return pageVariableArgValue(raw) } // widgetNameSet is isDocumentWidget for a statement that declares the whole diff --git a/mdl/executor/cmd_pages_flow_args_dataview_param_2095_test.go b/mdl/executor/cmd_pages_flow_args_dataview_param_2095_test.go new file mode 100644 index 000000000..00ff87a8d --- /dev/null +++ b/mdl/executor/cmd_pages_flow_args_dataview_param_2095_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#2095 (mendixlabs/mxcli#1796): a flow argument naming an enclosing +// data view that shows a page or snippet parameter (`Item = $dvOuter`) was +// written as PageVariable { Widget: dvOuter } alone, and mx check reported +// CE0115 "The arguments that are passed to microflow 'ModV.ACT_Open' do not +// match the expected parameters and need to be refreshed." Studio Pro wants the +// pair the data view's `$dvOuter.Attr` binding already stores (#826): Widget +// plus the parameter it shows. A data view whose object comes from a microflow +// keeps Widget alone, which passes (variant 8 of the report). +func TestFlowArgNamingParameterDataViewCarriesTheParameter(t *testing.T) { + for _, tc := range []struct { + name string + snippet bool + dataViews map[string]pages.DataSource + value string + wantVar, wantKind string + wantParam, wantSlot string + }{ + { + name: "data view showing a page parameter", + dataViews: map[string]pages.DataSource{"dvOuter": &pages.DataViewSource{ParameterName: "Item"}}, + value: "$dvOuter", wantVar: "$dvOuter", wantKind: "widget", + wantParam: "Item", wantSlot: "", + }, + { + name: "data view showing a snippet parameter", + snippet: true, + dataViews: map[string]pages.DataSource{"dvSnip": &pages.DataViewSource{ParameterName: "Item", IsSnippetParameter: true}}, + value: "$dvSnip", wantVar: "$dvSnip", wantKind: "widget", + wantParam: "Item", wantSlot: "snippet", + }, + { + // Control (variant 8): no parameter behind the data view, Widget alone. + name: "data view whose object comes from a microflow", + dataViews: map[string]pages.DataSource{"dvMf": &pages.MicroflowSource{}}, + value: "$dvMf", wantVar: "$dvMf", wantKind: "widget", + }, + { + // Control (#1560): a grid's selection stays Widget alone. + name: "a grid's selection", + value: "$grid2", wantVar: "$grid2", wantKind: "widget", + }, + { + // Control: the parameter itself is unchanged. + name: "the page parameter", + dataViews: map[string]pages.DataSource{"dvOuter": &pages.DataViewSource{ParameterName: "Item"}}, + value: "$Item", wantVar: "$Item", wantKind: "parameter", + }, + } { + t.Run(tc.name, func(t *testing.T) { + pb := builderWithParams("Item") + pb.isSnippet = tc.snippet + widgets := map[string]bool{"grid2": true} + for name, ds := range tc.dataViews { + widgets[name] = true + defer pb.registerDataViewVariable(name, ds)() + } + pb.isDocumentWidget = func(name string) bool { return widgets[name] } + + got := pb.flowArgsToParameterMappings([]ast.FlowArgV3{{Name: "Item", Value: tc.value}}) + if len(got) != 1 { + t.Fatalf("%d mappings, want 1", len(got)) + } + m := got[0] + if m.Variable != tc.wantVar || m.VariableKind != tc.wantKind || + m.WidgetParameter != tc.wantParam || m.WidgetParameterKind != tc.wantSlot { + t.Errorf("mapping = {Variable %q, Kind %q, WidgetParameter %q, WidgetParameterKind %q}, want {%q, %q, %q, %q}", + m.Variable, m.VariableKind, m.WidgetParameter, m.WidgetParameterKind, + tc.wantVar, tc.wantKind, tc.wantParam, tc.wantSlot) + } + }) + } +} + +// The read half: the stored pair describes as the data view, so describe -> +// exec writes the same pair back instead of the parameter alone. +func TestDescribeFlowArgReadsDataViewParameterPairAsTheWidget(t *testing.T) { + pair := map[string]any{ + "$Type": "Forms$PageVariable", "LocalVariable": "", "PageParameter": "Item", + "SnippetParameter": "", "SubKey": "", "UseAllPages": false, "Widget": "dvOuter", + } + if got := flowArgVariableValue(pair); got != "$dvOuter" { + t.Errorf("pair described as %q, want $dvOuter", got) + } + // Controls: the parameter alone and the widget alone read as before. + if got := flowArgVariableValue(map[string]any{"PageParameter": "Item", "Widget": ""}); got != "$Item" { + t.Errorf("page parameter described as %q, want $Item", got) + } + if got := flowArgVariableValue(map[string]any{"Widget": "grid2"}); got != "$grid2" { + t.Errorf("selection described as %q, want $grid2", got) + } +} diff --git a/sdk/pages/pages_widgets_action.go b/sdk/pages/pages_widgets_action.go index c61f79832..a2949889c 100644 --- a/sdk/pages/pages_widgets_action.go +++ b/sdk/pages/pages_widgets_action.go @@ -286,8 +286,15 @@ type MicroflowParameterMapping struct { model.BaseElement ParameterName string `json:"parameterName"` // Parameter name (without $) Variable string `json:"variable,omitempty"` // Variable reference (e.g., "$Customer") - VariableKind string `json:"variableKind,omitempty"` // "" | "parameter" | "snippet" | "local" + VariableKind string `json:"variableKind,omitempty"` // "" | "parameter" | "snippet" | "local" | "widget" Expression string `json:"expression,omitempty"` // Expression value + // WidgetParameter is set with VariableKind "widget" when the named widget is + // a data view showing a page or snippet parameter: Studio Pro stores the + // pair, Widget plus that parameter, and Widget alone is CE0115 + // (ako/mxcli#2095). WidgetParameterKind is "" for a page parameter and + // "snippet" for a snippet parameter. + WidgetParameter string `json:"widgetParameter,omitempty"` + WidgetParameterKind string `json:"widgetParameterKind,omitempty"` } // MicroflowClientAction calls a microflow. @@ -314,8 +321,15 @@ type NanoflowParameterMapping struct { model.BaseElement ParameterName string `json:"parameterName"` // Parameter name (without $) Variable string `json:"variable,omitempty"` // Variable reference (e.g., "$Customer") - VariableKind string `json:"variableKind,omitempty"` // "" | "parameter" | "snippet" | "local" + VariableKind string `json:"variableKind,omitempty"` // "" | "parameter" | "snippet" | "local" | "widget" Expression string `json:"expression,omitempty"` // Expression value + // WidgetParameter is set with VariableKind "widget" when the named widget is + // a data view showing a page or snippet parameter: Studio Pro stores the + // pair, Widget plus that parameter, and Widget alone is CE0115 + // (ako/mxcli#2095). WidgetParameterKind is "" for a page parameter and + // "snippet" for a snippet parameter. + WidgetParameter string `json:"widgetParameter,omitempty"` + WidgetParameterKind string `json:"widgetParameterKind,omitempty"` } // NanoflowClientAction calls a nanoflow. From 2388d02b0fb65184f8054bf4d726a32f34b6c5d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:06:20 +0000 Subject: [PATCH 05/26] fix(run): make the serve build and runtime start limits configurable `run --local` could not start a 188-module, 12,900-document app (mendixlabs/mxcli#1818, mirrored as #2136): the cold first build hit a fixed 10-minute HTTP limit, and the runtime start hit the admin connection's fixed 150 s while the after-startup microflow was running, reported as "cannot connect to Mendix admin API ... is the app running?". - --build-timeout / $MXCLI_BUILD_TIMEOUT bounds one serve build (default raised to 30m); running out names the limit and how to raise it, and stays a net.Error timeout for the #1834 retry. - --start-timeout / $MXCLI_START_TIMEOUT bounds the start and execute_ddl_commands actions only (default 15m); every other admin action keeps the short connection timeout. - An admin call that times out is reported as "did not answer within ", not as an unreachable app. Control: with the limits stubbed back to 10m/150s and the timeout branch disabled, the behaviour tests fail and the start test prints the reported "cannot connect ... is the app running?" text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rXm5FHEucVx832Gw6 --- ...-local-fixed-build-and-start-timeouts.json | 1 + .claude/skills/mendix/run-local/SKILL.md | 30 ++-- cmd/mxcli/cmd_run.go | 6 + cmd/mxcli/docker/localboot.go | 4 + cmd/mxcli/docker/m2ee.go | 16 ++ cmd/mxcli/docker/mxserve.go | 47 +++++- cmd/mxcli/docker/run_timeouts_test.go | 144 ++++++++++++++++++ cmd/mxcli/docker/runlocal.go | 13 +- cmd/mxcli/docker/runtime_controller.go | 40 ++++- docs-site/src/tools/run-local.md | 2 + 10 files changed, 282 insertions(+), 21 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-fixed-build-and-start-timeouts.json create mode 100644 cmd/mxcli/docker/run_timeouts_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-fixed-build-and-start-timeouts.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-fixed-build-and-start-timeouts.json new file mode 100644 index 000000000..5ba31a9c4 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-fixed-build-and-start-timeouts.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-11", "symptom": "`run --local` could not start a 188-module, 12,900-document app (Mendix 10.24.26): the cold first build failed with \"serve build request ... context deadline exceeded (Client.Timeout exceeded while awaiting headers)\" at a fixed 10 minutes, and the runtime start failed with \"cannot connect to Mendix admin API at 127.0.0.1:24811 -- is the app running?\" while the after-startup microflow was still running, after which mxcli shut the runtime down. Neither limit had a flag or an environment variable.", "cause": "ServeServer.Build used http.Client{Timeout: 10 * time.Minute}; StartLocalRuntime gave the M2EE connection a 150 s timeout that bounded every admin call, including `start`, which does not answer until the after-startup action returns. callM2EEDirect reported any client error, a timeout included, as an unreachable app.", "file": "cmd/mxcli/docker/mxserve.go (BuildTimeout, serveBuildTimeout, durationSetting), cmd/mxcli/docker/runtime_controller.go (StartTimeout, callStartPhase), cmd/mxcli/docker/m2ee.go (timeout is 'did not answer', isAdminTimeout), cmd/mxcli/cmd_run.go (--build-timeout, --start-timeout)", "insight": "A per-connection timeout is the wrong unit when one action on the connection is a different kind of call: `start` and `execute_ddl_commands` run user code and a schema update, every other admin action is a quick query. Give the long ones their own limit and keep the short one for the rest, so a hung status probe still fails fast. And a timeout is the server holding the request, not refusing it: reporting it as 'is the app running?' sent the user looking for a dead process in a live one. The isTimeout helper buildRetryingServeTimeout already used is the seam for both; wrap with %w so the retry still recognises the error. Control: with the limits stubbed back to 10m/150s and the timeout branch disabled, the two behaviour tests fail and the start test prints the reported 'cannot connect ... is the app running?' text.", "refs": ["mendixlabs/mxcli#1818", "ako/mxcli#2136"], "test": "cmd/mxcli/docker/run_timeouts_test.go"} diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index 56925f3ec..b2a4531bb 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -19,20 +19,20 @@ description: "The warm Docker-free dev loop, `mxcli run --local` — model chang | 250-277 | Editing themes (SCSS) — rebuild, never clear caches | | 278-304 | `--watch` waits for a write to finish before it builds | | 305-333 | "My edit didn't show up" — stale process, not stale cache | -| 334-364 | Flags | -| 365-418 | Metrics and OpenTelemetry | -| 419-476 | Pages render in the browser | -| 477-494 | Pixel-perfect page loop | -| 495-515 | Debugging a server-side error | -| 516-539 | "Sign in failed" that is not about the password | -| 540-572 | The same licence also stops the app after a few hours | -| 573-652 | External browser preview (`--hub`) | -| 653-661 | Validation checklist | -| 662-718 | Constant values come from a configuration | -| 719-722 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | -| 723-726 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | -| 727-730 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | -| 731-733 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | +| 334-366 | Flags | +| 367-420 | Metrics and OpenTelemetry | +| 421-478 | Pages render in the browser | +| 479-496 | Pixel-perfect page loop | +| 497-517 | Debugging a server-side error | +| 518-541 | "Sign in failed" that is not about the password | +| 542-574 | The same licence also stops the app after a few hours | +| 575-654 | External browser preview (`--hub`) | +| 655-663 | Validation checklist | +| 664-720 | Constant values come from a configuration | +| 721-724 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | +| 725-728 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | +| 729-732 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | +| 733-735 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | # Warm Local Dev Loop — `mxcli run --local` @@ -341,6 +341,8 @@ run's process session), and `run status` reports them. Do **not** | *(hub API key)* | — | For an **authenticated** hub: get one from `https:///cli`, set `MXCLI_HUB_KEY` (see below) | | `--watch` | off | Rebuild + hot-apply on each change | | `--web-client-timeout` | `$MXCLI_WEB_CLIENT_TIMEOUT`, else `5m` | Limit for one web client bundle build; on timeout the tail of `deployment/log/web-client-build.log` is printed | +| `--build-timeout` | `$MXCLI_BUILD_TIMEOUT`, else `30m` | Limit for one `mxbuild --serve` build. The cold first build of a large app (thousands of documents) can take ten minutes or more | +| `--start-timeout` | `$MXCLI_START_TIMEOUT`, else `15m` | Limit for the runtime start, after-startup microflow and schema update included. Running out says "did not answer", not "is the app running?" | | `--ensure-db` | off | Provision local Postgres + app database if missing | | `--db-admin-user` / `--db-admin-password` | postgres / — | Superuser login for `--ensure-db` when it has a password (Windows); only with `--ensure-db` | | `--app-port` / `--admin-port` / `--serve-port` | 8080 / 8090 / 6543 | Ports | diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index 7798c9252..2aabb9030 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -214,6 +214,8 @@ Examples: printGitStateWarnings(projectPath, os.Stderr) webClientTimeout, _ := cmd.Flags().GetDuration("web-client-timeout") + buildTimeout, _ := cmd.Flags().GetDuration("build-timeout") + startTimeout, _ := cmd.Flags().GetDuration("start-timeout") testEndpoint, _ := cmd.Flags().GetBool("test-endpoint") allowCall, _ := cmd.Flags().GetBool("allow-call") if allowCall && !testEndpoint { @@ -296,6 +298,8 @@ Examples: MxBuildPath: mxbuildPath, Watch: watch, WebClientTimeout: webClientTimeout, + BuildTimeout: buildTimeout, + StartTimeout: startTimeout, EnsureDB: ensureDB, SetupOnly: setupOnly, Screenshot: screenshot, @@ -442,6 +446,8 @@ func init() { runCmd.Flags().Bool("watch", false, "Rebuild and hot-apply on every project change") runCmd.Flags().Bool("detach", false, "Run in the background and return once the app is serving (or failed to boot); manage it with 'mxcli run status|wait|stop|restart'. Output goes to /.mxcli/run.log") runCmd.Flags().Duration("web-client-timeout", 0, "Limit for one web client bundle build, e.g. 10m (default $MXCLI_WEB_CLIENT_TIMEOUT, else 5m); on timeout the tail of deployment/log/web-client-build.log is printed") + runCmd.Flags().Duration("build-timeout", 0, "Limit for one mxbuild --serve build, e.g. 45m (default $MXCLI_BUILD_TIMEOUT, else 30m). The cold first build of a large app can take many minutes") + runCmd.Flags().Duration("start-timeout", 0, "Limit for the runtime start, after-startup microflow and schema update included, e.g. 30m (default $MXCLI_START_TIMEOUT, else 15m)") runCmd.Flags().Bool("test-endpoint", false, "Host mxcli's token-guarded test endpoint so 'mxcli test --attach' can run tests against this app without booting its own runtime (removed on exit)") runCmd.Flags().Bool("allow-call", false, "With --test-endpoint: let 'mxcli test --attach' run a test straight against this app, without injecting or building it, when every statement of the test is one the endpoint can execute. The endpoint then calls the microflows a test names and reads and writes data in a system context, so it is off unless asked for") runCmd.Flags().Bool("ensure-db", false, "Provision the local Postgres + app database if missing (fresh-session bootstrap)") diff --git a/cmd/mxcli/docker/localboot.go b/cmd/mxcli/docker/localboot.go index 0a2fa3cf3..9017632b1 100644 --- a/cmd/mxcli/docker/localboot.go +++ b/cmd/mxcli/docker/localboot.go @@ -119,6 +119,9 @@ type LocalRuntimeOptions struct { // ReadyTimeout bounds how long StartLocalRuntime waits for the admin API // (default 90s). ReadyTimeout time.Duration + // StartTimeout bounds the runtime's start action, which runs the + // after-startup microflow (default $MXCLI_START_TIMEOUT, else 15m). + StartTimeout time.Duration // Env are extra "KEY=value" entries layered onto the runtime JVM's // environment, last-wins over the inherited process environment. Used to hand // the runtime a secret that must not be written to disk — the test runner @@ -549,6 +552,7 @@ func StartLocalRuntime(opts LocalRuntimeOptions) (*LocalRuntime, error) { }, } rt.ctrl = NewRuntimeController(rt.m2ee) + rt.ctrl.StartTimeout = opts.StartTimeout // Attach a file log subscriber (post-start, inside Start) so the runtime's // application log lands in the same file the JVM stdout/stderr is tee'd to. // An absolute path keeps the runtime (cwd = /runtime) and the JVM diff --git a/cmd/mxcli/docker/m2ee.go b/cmd/mxcli/docker/m2ee.go index 29b40dbcd..bf5edc8bb 100644 --- a/cmd/mxcli/docker/m2ee.go +++ b/cmd/mxcli/docker/m2ee.go @@ -58,6 +58,15 @@ var ErrAdminAuth = errors.New("admin API refused the credentials") type adminError struct { msg string kind error + // timedOut marks a request the admin API accepted and did not answer in + // time, as opposed to one it never received. + timedOut bool +} + +// isAdminTimeout reports whether err is an admin call that ran out of time. +func isAdminTimeout(err error) bool { + var ae *adminError + return errors.As(err, &ae) && ae.timedOut } func (e *adminError) Error() string { return e.msg } @@ -142,6 +151,13 @@ func callM2EEDirect(opts M2EEOptions, action string, params map[string]any) (*M2 client := &http.Client{Timeout: timeout} resp, err := client.Do(req) if err != nil { + if isTimeout(err) { + // It accepted the request and is still working on it: "cannot + // connect ... is the app running?" sent a user hunting for a dead + // process while a slow after-startup microflow ran (#1818). + return nil, &adminError{kind: ErrAdminUnreachable, timedOut: true, + msg: fmt.Sprintf("Mendix admin API at %s:%d did not answer %q within %s", opts.Host, opts.Port, action, timeout)} + } return nil, adminUnreachable(fmt.Sprintf("cannot connect to Mendix admin API at %s:%d -- is the app running? Start with 'mxcli docker up'", opts.Host, opts.Port)) } defer resp.Body.Close() diff --git a/cmd/mxcli/docker/mxserve.go b/cmd/mxcli/docker/mxserve.go index 925c22586..7cbd3fa72 100644 --- a/cmd/mxcli/docker/mxserve.go +++ b/cmd/mxcli/docker/mxserve.go @@ -49,6 +49,42 @@ type ServeOptions struct { Port int // ReadyTimeout bounds how long StartServe waits for the API (default 90s). ReadyTimeout time.Duration + // BuildTimeout bounds one /build request. Zero falls back to + // $MXCLI_BUILD_TIMEOUT, then 30m (see serveBuildTimeout). + BuildTimeout time.Duration +} + +// defaultBuildTimeout bounds one serve build when nothing overrides it. +// +// It was a fixed 10 minutes, and the cold build of a 188-module, 12,900- +// document app takes about that long on a laptop, so it passed or failed by +// the run (mendixlabs/mxcli#1818). A build that hangs is caught by the watchdog +// on mxbuild's output, not by this; this only has to outlast a slow honest one. +const defaultBuildTimeout = 30 * time.Minute + +// buildTimeoutEnv overrides defaultBuildTimeout (a Go duration, e.g. "45m"). +// The --build-timeout flag of `run --local` wins over it. +const buildTimeoutEnv = "MXCLI_BUILD_TIMEOUT" + +// serveBuildTimeout resolves the build limit: an explicit value first, then +// $MXCLI_BUILD_TIMEOUT, then the default. An unparsable or non-positive +// environment value is ignored, as for MXCLI_WEB_CLIENT_TIMEOUT. +func serveBuildTimeout(explicit time.Duration) time.Duration { + return durationSetting(explicit, buildTimeoutEnv, defaultBuildTimeout) +} + +// durationSetting is the flag -> environment -> default chain the local loop's +// time limits share. +func durationSetting(explicit time.Duration, env string, def time.Duration) time.Duration { + if explicit > 0 { + return explicit + } + if v := strings.TrimSpace(os.Getenv(env)); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + } + return def } // ServeTarget is the build target for a serve request. @@ -387,10 +423,17 @@ func (s *ServeServer) Build(req BuildRequest) (*BuildResult, error) { if err != nil { return nil, fmt.Errorf("marshaling build request: %w", err) } - // Cold builds can take ~60s; keep a generous timeout. - client := &http.Client{Timeout: 10 * time.Minute} + limit := serveBuildTimeout(s.opts.BuildTimeout) + client := &http.Client{Timeout: limit} resp, err := client.Post(s.baseURL()+"/build", "application/json", bytes.NewReader(bodyBytes)) if err != nil { + if isTimeout(err) { + // %w keeps it a net.Error timeout, which buildRetryingServeTimeout + // recognises. + return nil, fmt.Errorf("serve build request to %s: mxbuild did not finish the build within %s "+ + "(a large app's cold build can take longer: raise the limit with --build-timeout or $%s): %w", + s.baseURL(), limit, buildTimeoutEnv, err) + } return nil, fmt.Errorf("serve build request to %s: %w", s.baseURL(), err) } defer resp.Body.Close() diff --git a/cmd/mxcli/docker/run_timeouts_test.go b/cmd/mxcli/docker/run_timeouts_test.go new file mode 100644 index 000000000..1b7a6a979 --- /dev/null +++ b/cmd/mxcli/docker/run_timeouts_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" +) + +// Reported (mendixlabs/mxcli#1818, mirrored as ako/mxcli#2136): on a 12,900- +// document app the first build hit a fixed 10-minute HTTP limit ("context +// deadline exceeded (Client.Timeout exceeded while awaiting headers)") and the +// runtime start a fixed 150 s one, reported as "cannot connect to Mendix admin +// API ... -- is the app running?" while the after-startup microflow was still +// running. Neither had a flag or an environment variable. + +func TestServeBuildTimeout_Resolution(t *testing.T) { + t.Setenv(buildTimeoutEnv, "") + if got := serveBuildTimeout(0); got != defaultBuildTimeout { + t.Errorf("default = %v, want %v", got, defaultBuildTimeout) + } + if defaultBuildTimeout <= 10*time.Minute { + t.Errorf("default build timeout %v is not above the 10m a large app's cold build was measured to need", defaultBuildTimeout) + } + t.Setenv(buildTimeoutEnv, "45m") + if got := serveBuildTimeout(0); got != 45*time.Minute { + t.Errorf("env = %v, want 45m", got) + } + if got := serveBuildTimeout(time.Hour); got != time.Hour { + t.Errorf("explicit = %v, want 1h (the flag wins over the environment)", got) + } + t.Setenv(buildTimeoutEnv, "soon") + if got := serveBuildTimeout(0); got != defaultBuildTimeout { + t.Errorf("unparsable env = %v, want the default", got) + } +} + +func TestRuntimeStartTimeout_Resolution(t *testing.T) { + t.Setenv(startTimeoutEnv, "") + if got := runtimeStartTimeout(0); got != defaultStartTimeout { + t.Errorf("default = %v, want %v", got, defaultStartTimeout) + } + if defaultStartTimeout <= 150*time.Second { + t.Errorf("default start timeout %v is not above the 150s an after-startup microflow outran", defaultStartTimeout) + } + t.Setenv(startTimeoutEnv, "20m") + if got := runtimeStartTimeout(0); got != 20*time.Minute { + t.Errorf("env = %v, want 20m", got) + } + if got := runtimeStartTimeout(time.Minute); got != time.Minute { + t.Errorf("explicit = %v, want 1m", got) + } +} + +// The serve build honours the configured limit, and running out of it names the +// limit and how to raise it rather than only "context deadline exceeded". +func TestServeBuild_HonoursBuildTimeoutAndNamesIt(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(400 * time.Millisecond) + _, _ = w.Write([]byte(`{"status":"Success"}`)) + })) + defer srv.Close() + host, port := hostPort(t, srv.URL) + + s := &ServeServer{Host: host, Port: port, opts: ServeOptions{BuildTimeout: 50 * time.Millisecond}} + _, err := s.Build(BuildRequest{ProjectFilePath: "/abs/app.mpr"}) + if err == nil { + t.Fatal("build returned before the server answered: the BuildTimeout was not applied") + } + for _, want := range []string{"--build-timeout", buildTimeoutEnv} { + if !strings.Contains(err.Error(), want) { + t.Errorf("timeout error does not say how to raise the limit (missing %q):\n%v", want, err) + } + } + if !isTimeout(err) { + t.Errorf("the timeout must stay recognisable to buildRetryingServeTimeout: %v", err) + } + + s.opts.BuildTimeout = 5 * time.Second + if _, err := s.Build(BuildRequest{ProjectFilePath: "/abs/app.mpr"}); err != nil { + t.Fatalf("a build inside a raised limit failed: %v", err) + } +} + +// The start action runs the after-startup microflow, so it is bounded by the +// start timeout, not by the short limit every other admin action has. +func TestRuntimeControllerStart_UsesStartTimeout(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Action string `json:"action"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Action == "start" { + time.Sleep(400 * time.Millisecond) // a slow after-startup action + } + _ = json.NewEncoder(w).Encode(M2EEResponse{}) + })) + defer ts.Close() + host, port := hostPort(t, ts.URL) + opts := M2EEOptions{Host: host, Port: port, Token: "t", Direct: true, Timeout: 100 * time.Millisecond} + + c := NewRuntimeController(opts) + c.StartTimeout = 5 * time.Second + if _, err := c.Start(); err != nil { + t.Fatalf("start outran the short admin timeout although StartTimeout allows it: %v", err) + } + + // And when the start does run out of time, say so — not "is the app running?". + c.StartTimeout = 50 * time.Millisecond + _, err := c.Start() + if err == nil { + t.Fatal("start returned success past its StartTimeout") + } + msg := err.Error() + if strings.Contains(msg, "is the app running") { + t.Errorf("a start that ran out of time is reported as an unreachable app:\n%s", msg) + } + for _, want := range []string{"did not answer", "--start-timeout", startTimeoutEnv} { + if !strings.Contains(msg, want) { + t.Errorf("start timeout error missing %q:\n%s", want, msg) + } + } +} + +func hostPort(t *testing.T, raw string) (string, int) { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + h, p, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatal(err) + } + n, _ := strconv.Atoi(p) + return h, n +} diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index c98949c1f..848f85e46 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -112,6 +112,12 @@ type LocalRunOptions struct { // WebClientTimeout bounds one web client bundle build. Zero falls back to // $MXCLI_WEB_CLIENT_TIMEOUT, then 5m (see webClientTimeout). WebClientTimeout time.Duration + // BuildTimeout bounds one mxbuild --serve build. Zero falls back to + // $MXCLI_BUILD_TIMEOUT, then 30m (see serveBuildTimeout). + BuildTimeout time.Duration + // StartTimeout bounds the runtime start, after-startup microflow included. + // Zero falls back to $MXCLI_START_TIMEOUT, then 15m. + StartTimeout time.Duration // Screenshot, when set, captures a PNG of the app after boot and after each // applied change (requires the Playwright CLI + a browser). Screenshot bool @@ -828,7 +834,9 @@ func RunLocal(opts LocalRunOptions) error { // 5. Start the warm build server. fmt.Fprintln(w, "Starting mxbuild --serve...") javaMajor, _ := ProjectJavaMajor(opts.ProjectPath) - serve, err := StartServe(serveOptionsFor(mxbuildPath, version, javaMajor, opts.ServePort)) + serveOpts := serveOptionsFor(mxbuildPath, version, javaMajor, opts.ServePort) + serveOpts.BuildTimeout = opts.BuildTimeout + serve, err := StartServe(serveOpts) if err != nil { return fmt.Errorf("starting mxbuild serve: %w", err) } @@ -840,7 +848,7 @@ func RunLocal(opts LocalRunOptions) error { // instead of being folded silently into a baseline taken after the boot. bootSource := sourceMTime(opts.ProjectPath) opts.State.BuildStarted(1, bootSource) - fmt.Fprintln(w, "Building (first build is cold, ~10-15s)...") + fmt.Fprintf(w, "Building (first build is cold: ~10-15s for a small app, minutes for a large one; limit %s)...\n", serveBuildTimeout(opts.BuildTimeout)) scaffold := buildScaffold{} themeBefore := themeModuleFolders(opts.ProjectPath) build, err := serve.Build(BuildRequest{Target: TargetDeploy, ProjectFilePath: opts.ProjectPath}) @@ -966,6 +974,7 @@ func RunLocal(opts LocalRunOptions) error { ConstantOverrides: opts.ConstantOverrides, JVMArgs: opts.JVMArgs, Env: opts.Env, + StartTimeout: opts.StartTimeout, Stdout: w, Stderr: stderr, }) diff --git a/cmd/mxcli/docker/runtime_controller.go b/cmd/mxcli/docker/runtime_controller.go index 7fe2b8213..2b13b6e1a 100644 --- a/cmd/mxcli/docker/runtime_controller.go +++ b/cmd/mxcli/docker/runtime_controller.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" ) // RuntimeController drives the Mendix runtime admin (M2EE) API for the local dev @@ -29,6 +30,39 @@ type RuntimeController struct { // Stdout receives a non-fatal warning if the log subscriber cannot be // attached (the app is already up at that point). nil is silent. Stdout io.Writer + // StartTimeout bounds the start and execute_ddl_commands actions, which run + // the after-startup microflow and the schema update. Zero falls back to + // $MXCLI_START_TIMEOUT, then 15m (see runtimeStartTimeout). Every other + // admin action keeps the connection's own Timeout. + StartTimeout time.Duration +} + +// defaultStartTimeout bounds one start (or schema update) when nothing +// overrides it. It was the admin connection's 150 s, which an app's +// after-startup microflow outran on every start — and mxcli then shut the +// runtime down mid-startup (mendixlabs/mxcli#1818). A start that fails reports +// its failure in the response; this only has to outlast a slow successful one. +const defaultStartTimeout = 15 * time.Minute + +// startTimeoutEnv overrides defaultStartTimeout. The --start-timeout flag of +// `run --local` wins over it. +const startTimeoutEnv = "MXCLI_START_TIMEOUT" + +func runtimeStartTimeout(explicit time.Duration) time.Duration { + return durationSetting(explicit, startTimeoutEnv, defaultStartTimeout) +} + +// callStartPhase runs a start-phase admin action under the start timeout, and +// says how to raise it when it runs out. +func (c *RuntimeController) callStartPhase(action string) (*M2EEResponse, error) { + opts := c.opts + opts.Timeout = runtimeStartTimeout(c.StartTimeout) + resp, err := CallM2EE(opts, action, nil) + if err != nil && isAdminTimeout(err) { + return nil, fmt.Errorf("%w (the runtime was still starting -- an after-startup microflow or a schema "+ + "update can take longer; raise the limit with --start-timeout or $%s)", err, startTimeoutEnv) + } + return resp, err } // logSubscriberName identifies the file log subscriber run --local attaches. A @@ -90,13 +124,13 @@ func (c *RuntimeController) ReloadModel() error { // database: start -> if the runtime reports the schema must change -> // execute_ddl_commands -> start. Returns the final start response. func (c *RuntimeController) Start() (*M2EEResponse, error) { - resp, err := CallM2EE(c.opts, "start", nil) + resp, err := c.callStartPhase("start") if err != nil { return nil, err } schemaAttempted := false if needsDBUpdate(resp) { - ddl, err := CallM2EE(c.opts, "execute_ddl_commands", nil) + ddl, err := c.callStartPhase("execute_ddl_commands") if err != nil { return nil, err } @@ -104,7 +138,7 @@ func (c *RuntimeController) Start() (*M2EEResponse, error) { return nil, fmt.Errorf("execute_ddl_commands failed: %s", msg) } schemaAttempted = true - resp, err = CallM2EE(c.opts, "start", nil) + resp, err = c.callStartPhase("start") if err != nil { return nil, err } diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index d0010f7ba..148cd3d3f 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -161,6 +161,8 @@ would make Studio Pro 11.13 fail to open it — a branch with no upstream, or | *(hub API key)* | — | For a **GitHub-authenticated** hub: get one from `https:///cli`, set `MXCLI_HUB_KEY` (see below) | | `--watch` | off | Rebuild + hot-apply on every project change | | `--web-client-timeout` | `$MXCLI_WEB_CLIENT_TIMEOUT`, else `5m` | Limit for one web client bundle build (e.g. `10m`); on timeout the tail of `deployment/log/web-client-build.log` is printed | +| `--build-timeout` | `$MXCLI_BUILD_TIMEOUT`, else `30m` | Limit for one `mxbuild --serve` build. The cold first build of a large app (thousands of documents) can take ten minutes or more | +| `--start-timeout` | `$MXCLI_START_TIMEOUT`, else `15m` | Limit for the runtime start, after-startup microflow and schema update included. Running out says "did not answer", not "is the app running?" | | `--ensure-db` | off | Provision local Postgres + the app database if missing (fresh-session bootstrap) | | `--db-admin-user` / `--db-admin-password` | postgres / — | Superuser login `--ensure-db` creates the role and database with when it has a password (Windows). Only with `--ensure-db`; never passed to the runtime | | `--setup` | off | Prepare prerequisites (cache MxBuild+runtime, ensure DB) and exit without booting — for a SessionStart hook | From 2d7a61df161e77420e89401fe9b5aa84d6e5c927 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:13:57 +0000 Subject: [PATCH 06/26] pages: bind a primitive page parameter in a text template `ContentParams: ({1} = $IpAddress)` with $IpAddress a String page parameter was stored as AttributePath "$IpAddress" with no AttributeRef and no SourceVariable: check and exec passed, mxbuild reported CE0402 "No value specified." and describe printed `{1} = ` (ako/mxcli#2132, mendixlabs/mxcli#1813). paramScope holds only entity-typed parameters, so the bare-$name branch, which handled page Variables, never saw a primitive parameter. The builder now records the page's primitive parameters and binds one as a Forms$PageVariable in the PageParameter slot, like a page variable in its LocalVariable slot. describe reads that shape back as `$IpAddress`. Measured on Mendix 11.14.0: CE0402 before; 0 errors after for a String and an Integer parameter and a button caption, and describe -> exec reports the page unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- ...param-primitive-page-parameter-ce0402.json | 1 + ...emplate-param-primitive-page-parameter.mdl | 28 ++++++++++ mdl/executor/cmd_pages_builder.go | 7 +++ mdl/executor/cmd_pages_builder_v3.go | 13 +++++ mdl/executor/cmd_pages_describe_pluggable.go | 9 ++++ ...ages_template_primitive_param_2132_test.go | 51 +++++++++++++++++++ 6 files changed, 109 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-template-param-primitive-page-parameter-ce0402.json create mode 100644 mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl create mode 100644 mdl/executor/cmd_pages_template_primitive_param_2132_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-template-param-primitive-page-parameter-ce0402.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-template-param-primitive-page-parameter-ce0402.json new file mode 100644 index 000000000..dd15f6a90 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-template-param-primitive-page-parameter-ce0402.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"dynamictext (Content: 'Address {1}', ContentParams: ({1} = $IpAddress)) with $IpAddress a String page parameter passes check and exec, then mxbuild reports CE0402 'No value specified.'; describe prints {1} = ","cause":"resolveTemplateAttributePathFull handled a bare $name only for page Variables (LocalVariable slot); paramScope holds only entity-typed parameters, so a primitive page parameter fell through to the attribute path \"$IpAddress\" and nothing was bound. describe had no case for a SourceVariable without an AttributeRef outside the LocalVariable slot","file":"mdl/executor/cmd_pages_builder_v3.go (primitiveParams, resolveTemplateAttributePathFull), mdl/executor/cmd_pages_describe_pluggable.go (extractTextTemplateParameters)","insight":"paramScope is entity-only by design (it maps a name to an entity ID), so every 'is this a parameter?' question asked of it is silently false for String/Integer/… parameters; check for that blind spot before writing a new branch. The fix copies the existing bare-$localVar shape into the PageParameter slot, and mx check settled it: same script, CE0402 before, 0 errors after for String, Integer and a button caption. A describe that prints for a stored binding is the read half of the same gap.","refs":["#2132","mendixlabs/mxcli#1813"],"ce":["CE0402"]} diff --git a/mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl b/mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl new file mode 100644 index 000000000..280ebe995 --- /dev/null +++ b/mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl @@ -0,0 +1,28 @@ +mdl 1; +-- ============================================================================ +-- Bug ako/mxcli#2132 (mendixlabs/mxcli#1813): a String page parameter in a +-- dynamic text template is stored without a value +-- ============================================================================ +-- +-- Symptom (before fix): check and exec pass, then mxbuild reports +-- CE0402 "No value specified." at Text 'txtIp' +-- The parameter was stored as AttributePath "$IpAddress", with no AttributeRef +-- and no SourceVariable; describe printed `{1} = `. +-- +-- After fix: a primitive page parameter binds as a Forms$PageVariable naming it +-- (PageParameter slot, no attribute), the way a page variable binds its +-- LocalVariable slot. mx check 0 errors on Mendix 11.14.0; describe prints +-- `{1} = $IpAddress` and describe -> exec reports the page unchanged. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl -p app.mpr +-- mxcli docker check -p app.mpr -- expect 0 errors +-- ============================================================================ + +create module BugTest2132; + +create or modify page BugTest2132.PageParam (Params: ($IpAddress: String, $Port: Integer), Layout: Atlas_Core.Atlas_Default, Title: 'Param') { + dynamictext txtIp (Content: 'Address {1}', ContentParams: ({1} = $IpAddress)) + dynamictext txtPort (Content: 'Port {1}', ContentParams: ({1} = $Port)) + actionbutton btnIp (Caption: 'Go to {1}', CaptionParams: ({1} = $IpAddress), Action: close_page) +}; diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index fd248f426..146900f28 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -106,6 +106,13 @@ type pageBuilder struct { // Forms$PageVariable.LocalVariable in BSON, not as a literal Expression. localVariables map[string]bool + // primitiveParams are the page's primitive-typed parameters (String, + // Integer, …). paramScope holds only the entity-typed ones, so without this + // a bare `{1} = $IpAddress` template parameter was stored as the attribute + // path "$IpAddress", bound nothing, and mxbuild reported CE0402 "No value + // specified." (ako/mxcli#2132). Snippets cannot declare one (CE0046). + primitiveParams map[string]bool + // isDocumentWidget reports whether a name is a widget of the document being // built. A flow argument `$name` naming one passes that widget's selection, // which Studio Pro binds through the PageVariable's Widget slot diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 1a33cac2e..8c6c0b41a 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -140,6 +140,10 @@ func (pb *pageBuilder) buildPageV3(s *ast.CreatePageStmtV3) (*pages.Page, error) if bsonType := pageParamBSONType(param.Type); bsonType != "" { // Primitive type parameter pageParam.TypeName = bsonType + if pb.primitiveParams == nil { + pb.primitiveParams = map[string]bool{} + } + pb.primitiveParams[param.Name] = true } else if param.EntityType.Name != "" { // Entity type parameter entityID, err := pb.resolveEntity(param.EntityType) @@ -2416,6 +2420,15 @@ func (pb *pageBuilder) resolveTemplateAttributePathFull(attrRef string, param *p param.SourceVariableKind = "local" return } + // A primitive page parameter binds the same way, in the PageParameter + // slot. It used to fall through to the attribute path "$IpAddress", + // which binds nothing: mxbuild CE0402 "No value specified." + // (ako/mxcli#2132). + if pb.primitiveParams[after] { + param.SourceVariable = after + param.SourceVariableKind = "" + return + } } // Check for $paramName.Attribute pattern where paramName is a page parameter diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 7c1579073..7d88c76f5 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -687,6 +687,15 @@ func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any continue } } + // A page parameter or page variable with no attribute: a primitive + // page parameter bound as itself (ako/mxcli#2132), which the builder + // writes back to the same slot from the bare `$Name`. + if sourceVarName != "" { + if srcVar, _ := pMap["SourceVariable"].(map[string]any); extractString(srcVar["Widget"]) == "" { + result = append(result, "$"+sourceVarName) + continue + } + } // Parameter exists but has no binding result = append(result, "") } diff --git a/mdl/executor/cmd_pages_template_primitive_param_2132_test.go b/mdl/executor/cmd_pages_template_primitive_param_2132_test.go new file mode 100644 index 000000000..e3b314e47 --- /dev/null +++ b/mdl/executor/cmd_pages_template_primitive_param_2132_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#2132 (mendixlabs/mxcli#1813): a dynamic text whose template +// parameter is a String page parameter (`{1} = $IpAddress`) was stored as +// AttributePath "$IpAddress" with no AttributeRef and no SourceVariable, so +// check and exec passed and mxbuild reported CE0402 "No value specified." at +// Text 'txtIp'. A primitive page parameter binds the way a page variable does: +// a Forms$PageVariable naming it, no attribute. +func TestTemplateParamPrimitivePageParameterBindsTheParameter(t *testing.T) { + pb := builderWithParams("Item") + pb.paramEntityNames = map[string]string{"Item": "M.Item"} + pb.primitiveParams = map[string]bool{"IpAddress": true} + + param := &pages.ClientTemplateParameter{} + pb.resolveTemplateAttributePathFull("$IpAddress", param) + if param.SourceVariable != "IpAddress" || param.SourceVariableKind != "" || param.AttributeRef != "" || param.SourceWidget != "" { + t.Errorf("$IpAddress bound as {SourceVariable %q, Kind %q, AttributeRef %q, Widget %q}, want the PageParameter slot naming IpAddress and no attribute", + param.SourceVariable, param.SourceVariableKind, param.AttributeRef, param.SourceWidget) + } + + // Control: an entity parameter's attribute is bound as before. + param = &pages.ClientTemplateParameter{} + pb.resolveTemplateAttributePathFull("$Item.Name", param) + if param.SourceVariable != "Item" || param.AttributeRef != "M.Item.Name" { + t.Errorf("$Item.Name bound as {SourceVariable %q, AttributeRef %q}, want Item / M.Item.Name", param.SourceVariable, param.AttributeRef) + } +} + +// The read half: the stored PageVariable with no attribute describes as the +// bare parameter, not ``, so describe -> exec writes it back. +func TestDescribeTemplateParamPrimitivePageParameter(t *testing.T) { + tpl := map[string]any{"Parameters": []any{int32(2), map[string]any{ + "$Type": "Forms$ClientTemplateParameter", "AttributeRef": nil, "Expression": "", + "SourceVariable": map[string]any{ + "$Type": "Forms$PageVariable", "LocalVariable": "", "PageParameter": "IpAddress", + "SnippetParameter": "", "Widget": "", + }, + }}} + got := extractTextTemplateParameters(nil, tpl) + if len(got) != 1 || got[0] != "$IpAddress" { + t.Errorf("described %v, want [$IpAddress]", got) + } +} From 90cfc1604c1c2843f30fd6657e59cfa8bb081376 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:08:06 +0000 Subject: [PATCH 07/26] fix(run): offer the 11.15.0 upgrade only for the serve-build failure it fixes ako/mxcli#606 follow-up. 11.15.0 was measured to fix the 11.14 warm-loop failure (BankV1: builds #2-#4 reloaded in 2.8-3.9 s after converting from 11.14.0), and the issue asked for mxcli to point at the upgrade. The hint covers two failures with different fates: the bundler-config one (ERR_MODULE_NOT_FOUND web/rollup.config.mjs) is gone on 11.15.0, but the per-document client one (web/pages/) was recorded on macOS 11.15.0 three weeks after that comment. So the bundler-config hint now names 11.15.0 as the fix, and the client-directory hint lists 11.15.0 among the versions it was seen on without promising an upgrade. The bootstrap-app skill skips a cached 11.14.x for the newest CDN version; the run-local docs say which half each version fixes. Control: the new test fails against the old single hint on both halves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rXm5FHEucVx832Gw6 --- ...ve-second-build-hint-upgrade-per-half.json | 1 + .claude/skills/mendix/bootstrap-app/SKILL.md | 16 +++++++----- cmd/mxcli/docker/webclient_legacy_paths.go | 25 +++++++++++++------ .../docker/webclient_legacy_paths_test.go | 23 +++++++++++++++++ docs-site/src/tools/run-local.md | 11 ++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-serve-second-build-hint-upgrade-per-half.json diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-serve-second-build-hint-upgrade-per-half.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-serve-second-build-hint-upgrade-per-half.json new file mode 100644 index 000000000..2d2eabd53 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-serve-second-build-hint-upgrade-per-half.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-11", "symptom": "ako/mxcli#606 follow-up: after 11.15.0 was measured to fix the 11.14 warm-loop failure (BankV1, Linux, builds #2-#4 in 2.8-3.9 s), the remaining ask was for mxcli's hint and the bootstrap default to point at 11.15.0 as the fix. The hint still said only 'On Mendix 11.12.5 and 11.14 ... drop --watch and restart per change'.", "cause": "One hintText served two different failures: the bundler-config one (ERR_MODULE_NOT_FOUND web/rollup.config.mjs or rspack.config.mjs) and the per-document client one (web/pages/, web/layouts/). They share a symptom class but not a remedy: the first is gone on 11.15.0, the second was recorded on macOS 11.15.0 (fixture real1115MacOSMissingClientDirsByKey, 2026-11-06).", "file": "cmd/mxcli/docker/webclient_legacy_paths.go (hintText takes the per-half version line), .claude/skills/mendix/bootstrap-app/SKILL.md (skip a cached 11.14.x), docs-site/src/tools/run-local.md", "insight": "Before writing 'fixed in X' into a hint, grep the test fixtures for X: the issue comment said 11.15.0 fixed it, and a fixture three weeks younger showed half of it still failing on 11.15.0 macOS. The comment measured the half it happened to hit. Writing the upgrade advice for both halves would have been a wrong diagnosis printed under every macOS failure. Control: the new test fails against the old single hint on both assertions (no 11.15.0 on the config half, no 11.15 on the client half).", "refs": ["ako/mxcli#606"], "test": "cmd/mxcli/docker/webclient_legacy_paths_test.go"} diff --git a/.claude/skills/mendix/bootstrap-app/SKILL.md b/.claude/skills/mendix/bootstrap-app/SKILL.md index 35e812664..0c40780d1 100644 --- a/.claude/skills/mendix/bootstrap-app/SKILL.md +++ b/.claude/skills/mendix/bootstrap-app/SKILL.md @@ -11,11 +11,11 @@ description: "Provision a Mendix project in a repo that has none — interview, | lines | section | |---|---| | 23-43 | When to Use This Skill | -| 44-130 | Step 0 — interview, and WAIT for the answers | -| 131-263 | Provision | -| 264-333 | If this is a solution (several apps in one repo) | -| 334-393 | Then propose the model — do not build it yet | -| 394-457 | After bootstrap — the inner loop | +| 44-134 | Step 0 — interview, and WAIT for the answers | +| 135-267 | Provision | +| 268-337 | If this is a solution (several apps in one repo) | +| 338-397 | Then propose the model — do not build it yet | +| 398-461 | After bootstrap — the inner loop | # Bootstrap a Mendix App in an Empty Repo @@ -104,7 +104,11 @@ ls ~/.mxcli/mxbuild/ 2>/dev/null | sort -V | tail -1 # e.g. 11.13.0, or empty ``` If that names a version, use it and say so in one line ("using 11.13.0, already cached -in this environment"). If the user asked for a specific version, they win — check it on +in this environment"). **Except an 11.14.x:** its `mxbuild --serve` fails every build +after the first (its own bundler config is gone), so `run --local --watch` rebuilds +through a serve restart per change (~30 s instead of ~3 s); 11.15.0 is measured to +fix that failure (ako/mxcli#606). Take the +newest version on the CDN instead and say why in one line. If the user asked for a specific version, they win — check it on the CDN as below and accept the download. **Otherwise take the newest version on the CDN.** Everything mxcli does starts with diff --git a/cmd/mxcli/docker/webclient_legacy_paths.go b/cmd/mxcli/docker/webclient_legacy_paths.go index 95ef90718..4a6b3ca2c 100644 --- a/cmd/mxcli/docker/webclient_legacy_paths.go +++ b/cmd/mxcli/docker/webclient_legacy_paths.go @@ -92,12 +92,17 @@ var bundlerConfigs = []string{"rollup.config.mjs", "rspack.config.mjs"} // goes quiet on its own and no unrelated build failure is explained away. func legacyClientBuildHint(deployDir, message, raw string) string { if missing := missingClientDirs(deployDir, message); len(missing) > 0 { - return hintText("this build wants " + strings.Join(missing, " and ") + - ", which this Mendix version's first build does not create") + return hintText("this build wants "+strings.Join(missing, " and ")+ + ", which this Mendix version's first build does not create", + " Seen on Mendix 11.12.5, 11.14 and 11.15.0 (macOS).\n") } if name := missingBundlerConfig(deployDir, message+raw); name != "" { - return hintText("mxbuild's own bundler cannot find web/" + name + - ", which its own first build does not leave behind") + return hintText("mxbuild's own bundler cannot find web/"+name+ + ", which its own first build does not leave behind", + // Measured on BankV1 (ako/mxcli#606, 2026-10-03): converted from + // 11.14.0 to 11.15.0, builds #2-#4 reloaded in 2.8-3.9 s each. + " This half is fixed in Mendix 11.15.0: on 11.14 a project converted to 11.15.0\n"+ + " rebuilt warm in ~3 s per change (upgrade in Studio Pro, or `mx convert`).\n") } return "" } @@ -146,16 +151,22 @@ func missingBundlerConfig(deployDir, body string) string { return "" } -func hintText(what string) string { +// hintText is the shared explanation; versions says where this half of the +// failure was seen and, where one is measured, which version fixes it. The two +// halves differ (ako/mxcli#606): the bundler-config one is gone on 11.15.0, the +// per-document client one was still seen there on macOS, so an upgrade is only +// offered for the half it was measured to fix. +func hintText(what, versions string) string { return fmt.Sprintf( " This is not a problem with your model, and deployment/ is not corrupt:\n"+ " %s.\n"+ - " On Mendix 11.12.5 and 11.14 the first build in a serve process succeeds and\n"+ + " On the affected Mendix versions the first build in a serve process succeeds and\n"+ " every later one fails this way — measured with the model untouched between two\n"+ " identical builds, and with either app bundler, so switching bundlers does not help.\n"+ " Deleting deployment/ does not either: the next second build fails the same way.\n"+ + "%s"+ " mxcli already retried on a fresh mxbuild --serve; if this persists, drop --watch\n"+ " and restart per change:\n"+ " mxcli run --local --screenshot\n", - what) + what, versions) } diff --git a/cmd/mxcli/docker/webclient_legacy_paths_test.go b/cmd/mxcli/docker/webclient_legacy_paths_test.go index c92a9e4cf..31599523e 100644 --- a/cmd/mxcli/docker/webclient_legacy_paths_test.go +++ b/cmd/mxcli/docker/webclient_legacy_paths_test.go @@ -164,3 +164,26 @@ func TestLegacyClientBuildHintStaysSilentWhenTheArtifactsExist(t *testing.T) { } } } + +// ako/mxcli#606: the two halves of the failure have different remedies, and the +// hint must not promise one for the other. The bundler-config half was measured +// gone on 11.15.0 (BankV1, Linux, 2026-10-03: builds #2-#4 reloaded in 2.8-3.9 s +// after a convert from 11.14.0), so its hint names the upgrade. The per-document +// client half was still seen on macOS 11.15.0 (the fixture above), so its hint +// must not. +func TestLegacyClientBuildHint_UpgradeAdviceOnlyForTheHalfItFixes(t *testing.T) { + config := legacyClientBuildHint(deploy1114(t), bundleFailedMessage, real1114MissingRollupConfig) + if !strings.Contains(config, "11.15.0") { + t.Errorf("the bundler-config hint does not name the version that fixes it:\n%s", config) + } + pages := legacyClientBuildHint(deploy1114(t), real1115MacOSMissingClientDirsByKey, "") + if pages == "" { + t.Fatal("no hint for the client-directory half") + } + if strings.Contains(pages, "fixed in") || strings.Contains(pages, "upgrade") { + t.Errorf("the client-directory hint promises an upgrade fix that 11.15.0 (macOS) did not deliver:\n%s", pages) + } + if !strings.Contains(pages, "11.15") { + t.Errorf("the client-directory hint still lists only 11.12.5 and 11.14 though 11.15.0 was measured failing:\n%s", pages) + } +} diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index 148cd3d3f..2e8a432e3 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -577,6 +577,17 @@ build reports `true` even for an unchanged model, and taking it would restart th runtime — dropping every browser session — on every change. Measured end to end on 11.12.5; on 11.14 the same restart applies to the same failure shape. +**Which versions.** The two halves have different fates (ako/mxcli#606). The +bundler-config failure (`Cannot find module '…/rollup.config.mjs'`) is **fixed in +Mendix 11.15.0**: a project converted from 11.14.0 to 11.15.0 rebuilt warm in +2.8–3.9 s per change, where 11.14.0 failed every build after the first — so on +11.14 the remedy is to upgrade. The per-document client failure (`web/pages/…`) +was still seen on macOS 11.15.0, where mxcli's serve restart is what keeps the +loop going. The hint mxcli prints offers the upgrade only for the half it fixes. +`mxcli test --attach` rebuilds through the hosting app's own serve process, so it +meets the same second-build failure on the same versions (read off the code, not +measured). + ## Pixel-perfect page loop Pass `--screenshot` and each applied change is captured to a PNG (default From d0907854afd30bac55c5a02023f349b153fd8abb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:16:08 +0000 Subject: [PATCH 08/26] feat(run): run clear-sessions frees the unlicensed runtime's 6 sign-in slots mendixlabs/mxcli#1810 (mirrored as #2129): under `run --local` with security on, probes that sign in and never sign out fill the trial licence's 6 sessions; the 7th sign-in answers HTTP 560 and the login page says only "Sign in failed". The only way out was stopping the app and deleting from "system$session" by hand. `mxcli run clear-sessions -p app.mpr` deletes every session in the run's database (its recorded --db-* flags, run defaults applied; --db-password again when the run was given one literally) while the app keeps running. The sign-in hint now names it, and the cap (6). Measured on Mendix 11.12.1 with a live app and PostgreSQL: - 6 sign-ins 200, 7th 560, 6 rows; control without clearing: still 560; after clear-sessions: 6 more sign-ins 200 on the same running app. - The rows survive a graceful stop, but the runtime deletes them as it starts ("Deleting old sessions from database..."), so a restart also frees them; a boot-time clear would be redundant and is not added. - The schema has session_user ON DELETE CASCADE, so a plain delete is safe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rXm5FHEucVx832Gw6 --- ...ocal-trial-session-cap-clear-sessions.json | 1 + .claude/skills/mendix/run-local/SKILL.md | 31 +++-- cmd/mxcli/cmd_run_sessions.go | 120 ++++++++++++++++++ cmd/mxcli/cmd_run_sessions_test.go | 57 +++++++++ cmd/mxcli/docker/runlocal.go | 10 ++ cmd/mxcli/docker/screenshot_login.go | 10 +- cmd/mxcli/docker/sessions_clear.go | 93 ++++++++++++++ .../docker/sessions_clear_integration_test.go | 57 +++++++++ cmd/mxcli/docker/sessions_clear_test.go | 115 +++++++++++++++++ docs-site/src/tools/run-local.md | 1 + 10 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-trial-session-cap-clear-sessions.json create mode 100644 cmd/mxcli/cmd_run_sessions.go create mode 100644 cmd/mxcli/cmd_run_sessions_test.go create mode 100644 cmd/mxcli/docker/sessions_clear.go create mode 100644 cmd/mxcli/docker/sessions_clear_integration_test.go create mode 100644 cmd/mxcli/docker/sessions_clear_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-trial-session-cap-clear-sessions.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-trial-session-cap-clear-sessions.json new file mode 100644 index 000000000..e83b013dc --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-run-local-trial-session-cap-clear-sessions.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-11", "symptom": "mendixlabs/mxcli#1810 (ako/mxcli#2129): under `run --local` with security on, Playwright probes that sign in once each and never sign out exhaust the unlicensed runtime's sessions; sign-ins 1-6 answer 200, the 7th and later HTTP 560 'Sign in failed', runtime log 'Maximum number of sessions exceeded! (You are currently using a trial license)'. mxcli had no way to free them short of stopping the app and deleting from \"system$session\" by hand.", "cause": "The trial licence caps concurrent sessions at 6 and counts the System.Session rows in the database. Each sign-in holds a row until sign-out or expiry; a probe loop fills them in seconds. Nothing in `mxcli run` deleted them.", "file": "cmd/mxcli/cmd_run_sessions.go (run clear-sessions, runDatabaseFromArgs), cmd/mxcli/docker/sessions_clear.go (ClearSessions, clearSessionRows), cmd/mxcli/docker/runlocal.go (RunDatabase), cmd/mxcli/docker/screenshot_login.go (hint names the command)", "insight": "Measure before building on the report's theory. The first fix cleared System.Session at boot, on the report's claim that rows outlive a restart (20 had piled up). On 11.12.1 the rows do survive a graceful stop (6 left), but the runtime logs 'Deleting old sessions from database...' as it starts and boots with 0, so the boot clear was redundant and the old hint's 'restart clears them' was right. The question that mattered was whether the cap is counted in memory or in the table: deleting the rows from a LIVE app made the next sign-in succeed, so a no-restart command is possible. Run it end to end with curl against /xas/ login (no browser needed: each curl without a cookie jar is a fresh session): 6x200, 560, control 560 without clearing, clear-sessions, 6x200. The real schema also has FKs (session_user ON DELETE CASCADE, backgroundjob/changehash SET NULL), so a plain delete is safe. Infra trap: a Postgres data dir under the 0700 session scratchpad PANICs mid-test when the tree's perms are tightened; put it in /var/tmp.", "refs": ["mendixlabs/mxcli#1810", "ako/mxcli#2129"], "test": "cmd/mxcli/cmd_run_sessions_test.go, cmd/mxcli/docker/sessions_clear_test.go, cmd/mxcli/docker/sessions_clear_integration_test.go"} diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index b2a4531bb..60e55a48d 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -24,15 +24,15 @@ description: "The warm Docker-free dev loop, `mxcli run --local` — model chang | 421-478 | Pages render in the browser | | 479-496 | Pixel-perfect page loop | | 497-517 | Debugging a server-side error | -| 518-541 | "Sign in failed" that is not about the password | -| 542-574 | The same licence also stops the app after a few hours | -| 575-654 | External browser preview (`--hub`) | -| 655-663 | Validation checklist | -| 664-720 | Constant values come from a configuration | -| 721-724 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | -| 725-728 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | -| 729-732 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | -| 733-735 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | +| 518-552 | "Sign in failed" that is not about the password | +| 553-585 | The same licence also stops the app after a few hours | +| 586-665 | External browser preview (`--hub`) | +| 666-674 | Validation checklist | +| 675-731 | Constant values come from a configuration | +| 732-735 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | +| 736-739 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | +| 740-743 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | +| 744-746 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | # Warm Local Dev Loop — `mxcli run --local` @@ -532,7 +532,18 @@ password in the model. `--screenshot-user` and `mxcli playwright check` do this for you — a rejected sign-in reads the log and says so instead of quietly screenshotting or checking the login page. -Sessions are held until they expire; restarting `run --local` clears them all. +The cap is **6**, and the licence counts the `System.Session` rows in the +database. Free every slot without a restart (the app keeps running; signed-in +browsers sign in again): + +```bash +mxcli run clear-sessions -p app.mpr +``` + +Measured on 11.12.1: six sign-ins, the seventh HTTP 560, `clear-sessions`, six +more succeed on the same running app. Restarting `run --local` clears them too +(the runtime deletes old sessions as it starts), at the cost of a boot. +Otherwise sessions are held until they expire. A script that drives the app through a browser should **sign out at the end**, otherwise each run leaks a slot and the fifth or sixth run is the one that fails — which makes it look like a change you just made broke authentication. diff --git a/cmd/mxcli/cmd_run_sessions.go b/cmd/mxcli/cmd_run_sessions.go new file mode 100644 index 000000000..924c897e3 --- /dev/null +++ b/cmd/mxcli/cmd_run_sessions.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" +) + +var runClearSessionsCmd = &cobra.Command{ + Use: "clear-sessions", + Short: "Sign every user out of the local app, freeing the unlicensed runtime's 6 session slots", + Long: `Delete every session in the database of the 'mxcli run --local' serving this +project. The app keeps running; every signed-in browser has to sign in again. + +The local runtime has no licence, and the trial licence allows 6 concurrent +sessions. Every sign-in — a Playwright probe's included — holds one until it signs +out or expires, and a 7th sign-in fails with HTTP 560, which the login page shows +as "Sign in failed" as if the password were wrong (the runtime log says "Maximum +number of sessions exceeded"). The licence counts the sessions in the database, +so deleting them frees every slot at once; 'mxcli run restart' does the same at +the cost of a boot. + +The database is the one the recorded run was started with (its --db-* flags). A +--db-password given literally is not stored with the run: pass it again here. The +built-in HSQLDB database (--db-type hsqldb) is held open by the runtime; use +'mxcli run restart' there.`, + Example: ` mxcli run clear-sessions -p app.mpr`, + Run: func(cmd *cobra.Command, args []string) { + projectPath := lifecycleProject(cmd) + st, err := docker.ReadRunState(projectPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(runExitFailed) + } + noteForeignRunState(projectPath, os.Stderr) + if st == nil || len(st.Args) == 0 { + fmt.Fprintf(os.Stderr, "no run of this project is recorded, so its database is not known\n"+ + " start one: mxcli run --local --detach -p %s\n", projectPath) + os.Exit(runExitNotRunning) + } + password, _ := cmd.Flags().GetString("db-password") + if password != "" { + if password, err = resolveValueSource(password); err != nil { + fmt.Fprintf(os.Stderr, "Error: --db-password: %v\n", err) + os.Exit(runExitFailed) + } + } + db, err := runDatabaseFromArgs(projectPath, st.Args, password) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(runExitFailed) + } + if db.IsFileBased() { + fmt.Fprintf(os.Stderr, "Error: this run uses the built-in HSQLDB database, which the runtime holds open.\n"+ + " Restart it instead; the runtime deletes old sessions as it starts:\n"+ + " mxcli run restart -p %s\n", projectPath) + os.Exit(runExitFailed) + } + n, err := docker.ClearSessions(db) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: clearing the sessions in %s on %s: %v\n", db.Name, db.Host, err) + os.Exit(runExitFailed) + } + fmt.Fprintf(os.Stdout, "Cleared %d session(s) in %s: all 6 sign-in slots are free; signed-in browsers must sign in again.\n", n, db.Name) + }, +} + +func init() { + runClearSessionsCmd.Flags().String("db-password", "", "Database password, when the run was given one literally (it is not stored)"+secretRefHelp) + runCmd.AddCommand(runClearSessionsCmd) +} + +// runDatabaseFromArgs is the database a run started with args connects to: its +// --db-* flags with the run's own defaults. password, when set, replaces the +// stored --db-password (needed when that was redacted). +func runDatabaseFromArgs(projectPath string, args []string, password string) (docker.DBConfig, error) { + db := docker.DBConfig{ + Type: storedFlag(args, "db-type"), + Host: storedFlag(args, "db-host"), + Name: storedFlag(args, "db-name"), + User: storedFlag(args, "db-user"), + Password: storedFlag(args, "db-password"), + } + switch { + case password != "": + db.Password = password + case db.Password == redactedValue || strings.Contains(db.Password, redactedValue): + return docker.DBConfig{}, fmt.Errorf("the run was given --db-password literally, and it is not stored: pass it again with --db-password") + case db.Password != "": + resolved, err := resolveValueSource(db.Password) + if err != nil { + return docker.DBConfig{}, fmt.Errorf("the run's --db-password: %w", err) + } + db.Password = resolved + } + return docker.RunDatabase(projectPath, db) +} + +// storedFlag returns the last value of --name in stored run arguments, in +// either the "--name value" or the "--name=value" form, or "". +func storedFlag(args []string, name string) string { + flag := "--" + name + val := "" + for i := 0; i < len(args); i++ { + switch { + case args[i] == flag && i+1 < len(args): + val = args[i+1] + i++ + case strings.HasPrefix(args[i], flag+"="): + val = strings.TrimPrefix(args[i], flag+"=") + } + } + return val +} diff --git a/cmd/mxcli/cmd_run_sessions_test.go b/cmd/mxcli/cmd_run_sessions_test.go new file mode 100644 index 000000000..c825e77f4 --- /dev/null +++ b/cmd/mxcli/cmd_run_sessions_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +// `run clear-sessions` connects to the database the recorded run uses, so it +// reads the --db-* flags back from the run's stored arguments, with the run's +// own defaults (ako/mxcli#2129). +func TestRunDatabaseFromArgs(t *testing.T) { + args := []string{"run", "--local", "--db-host", "127.0.0.1:55432", "--db-name=sessapp", + "--db-user", "dev", "--db-password", "@env:MXCLI_TEST_DBPW", "-p", "/w/App.mpr"} + t.Setenv("MXCLI_TEST_DBPW", "s3cret") + db, err := runDatabaseFromArgs("/w/App.mpr", args, "") + if err != nil { + t.Fatal(err) + } + if db.Host != "127.0.0.1:55432" || db.Name != "sessapp" || db.User != "dev" || db.Password != "s3cret" { + t.Errorf("db = %+v", db) + } +} + +func TestRunDatabaseFromArgs_Defaults(t *testing.T) { + db, err := runDatabaseFromArgs("/w/App.mpr", []string{"run", "--local", "-p", "/w/App.mpr"}, "") + if err != nil { + t.Fatal(err) + } + if db.Host != "127.0.0.1:5432" || db.User != "mendix" || db.Password != "mendix" || db.Name == "" { + t.Errorf("defaults not applied: %+v", db) + } +} + +// A password given literally is not stored, so the command needs it again +// rather than connecting with the placeholder. +func TestRunDatabaseFromArgs_RedactedPasswordNeedsTheFlag(t *testing.T) { + args := []string{"run", "--local", "--db-password", redactedValue} + if _, err := runDatabaseFromArgs("/w/App.mpr", args, ""); err == nil || !strings.Contains(err.Error(), "--db-password") { + t.Fatalf("err = %v, want one asking for --db-password", err) + } + db, err := runDatabaseFromArgs("/w/App.mpr", args, "given") + if err != nil || db.Password != "given" { + t.Fatalf("override: db=%+v err=%v", db, err) + } +} + +func TestRunDatabaseFromArgs_FileDatabase(t *testing.T) { + db, err := runDatabaseFromArgs("/w/App.mpr", []string{"run", "--local", "--db-type", "hsqldb"}, "") + if err != nil { + t.Fatal(err) + } + if !db.IsFileBased() { + t.Errorf("db = %+v, want the file database", db) + } +} diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 848f85e46..f5a072902 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -228,6 +228,16 @@ func (o *LocalRunOptions) applyDefaults() { } } +// RunDatabase is the database a `run --local` of projectPath connects to, given +// the --db-* flags it was started with: the same defaults the run applies. +func RunDatabase(projectPath string, db DBConfig) (DBConfig, error) { + o := LocalRunOptions{ProjectPath: projectPath, DB: db} + if err := o.applyDatabaseDefaults(); err != nil { + return DBConfig{}, err + } + return o.DB, nil +} + // applyDatabaseDefaults validates --db-type and fills the connection settings the // chosen database needs. It is separate from applyDefaults because it can fail: // a flag combination that cannot work (--db-type hsqldb with --db-host) is a user diff --git a/cmd/mxcli/docker/screenshot_login.go b/cmd/mxcli/docker/screenshot_login.go index 1878e1a5b..010955f46 100644 --- a/cmd/mxcli/docker/screenshot_login.go +++ b/cmd/mxcli/docker/screenshot_login.go @@ -162,10 +162,12 @@ func loginFailureHint(runtimeLogPath string) string { return "" } return "\nThe runtime log reports: " + sessionCapMarker + " — the unlicensed local\n" + - "runtime allows only a handful of concurrent sessions, and the login page reports\n" + - "that as \"Sign in failed\" as if the password were wrong. Sessions are released by\n" + - "restarting `mxcli run --local`; a script that drives the app through a browser\n" + - "should sign out when it finishes so it does not consume a slot per run." + "runtime allows 6 concurrent sessions, and the login page reports a 7th sign-in\n" + + "(HTTP 560) as \"Sign in failed\" as if the password were wrong. Every sign-in\n" + + "holds a slot until it signs out or expires. Free them all without a restart with\n" + + "`mxcli run clear-sessions -p ` (signed-in browsers sign in again), or\n" + + "restart `mxcli run --local`, which deletes old sessions as it starts. A script\n" + + "that drives the app through a browser should sign out when it finishes." } // readLogTail reads at most the last n bytes of a file. diff --git a/cmd/mxcli/docker/sessions_clear.go b/cmd/mxcli/docker/sessions_clear.go new file mode 100644 index 000000000..79cd10cbf --- /dev/null +++ b/cmd/mxcli/docker/sessions_clear.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "time" + + "github.com/jackc/pgx/v5/pgconn" + _ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver +) + +// A local run's runtime has no licence, and the trial licence caps concurrent +// sessions at six (mendixlabs/mxcli#1810). Every browser sign-in — a +// Playwright probe's included — leaves a System.Session row, and the licence +// check counts those ROWS: measured on 11.12.1, a seventh sign-in answers HTTP +// 560 with six rows present, and deleting them from the running app's database +// makes the next sign-in succeed at once, no restart. The login page reports +// the 560 as "Sign in failed", which reads as a defect in whatever was just +// changed. +// +// A restart frees the slots too — the runtime logs "Deleting old sessions from +// database..." at startup — but costs a boot. `mxcli run clear-sessions` is +// the cheap way: it deletes the rows the cap is counted from. + +// sessionClearTimeout bounds the clear. It is housekeeping: a database that +// does not answer in this time is reported, not waited on. +const sessionClearTimeout = 10 * time.Second + +// sqlExecer is the part of *sql.DB the clear uses, so it can be tested without +// a server. +type sqlExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// clearSessionRows deletes every System.Session row and the Session_User links +// that point at them, and returns how many sessions it deleted. A database the +// runtime has not initialised yet has neither table, which is nothing to clear. +func clearSessionRows(ctx context.Context, db sqlExecer) (int64, error) { + // The association table first. Its foreign key cascades (measured on + // 11.12.1: ON DELETE CASCADE; backgroundjob and changehash SET NULL), so this + // is belt and braces for a schema that lacks the constraint. + if _, err := db.ExecContext(ctx, `delete from "system$session_user"`); err != nil && !isUndefinedTable(err) { + return 0, err + } + res, err := db.ExecContext(ctx, `delete from "system$session"`) + if err != nil { + if isUndefinedTable(err) { + return 0, nil + } + return 0, err + } + n, _ := res.RowsAffected() + return n, nil +} + +// isUndefinedTable reports PostgreSQL's 42P01: the relation does not exist. +func isUndefinedTable(err error) bool { + var pe *pgconn.PgError + return errors.As(err, &pe) && pe.Code == "42P01" +} + +// ClearSessions deletes every session in a local run's database and returns +// how many it deleted. Every signed-in browser has to sign in again. +func ClearSessions(db DBConfig) (int64, error) { + if db.IsFileBased() { + return 0, fmt.Errorf("the built-in HSQLDB database is held open by the runtime and cannot be reached from another process") + } + return clearLocalSessions(db) +} + +func clearLocalSessions(db DBConfig) (int64, error) { + host, port := splitHostPort(db.Host) + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(db.User, db.Password), + Host: host + ":" + port, + Path: "/" + db.Name, + RawQuery: "sslmode=disable&connect_timeout=5", + } + conn, err := sql.Open("pgx", u.String()) + if err != nil { + return 0, err + } + defer conn.Close() + ctx, cancel := context.WithTimeout(context.Background(), sessionClearTimeout) + defer cancel() + return clearSessionRows(ctx, conn) +} diff --git a/cmd/mxcli/docker/sessions_clear_integration_test.go b/cmd/mxcli/docker/sessions_clear_integration_test.go new file mode 100644 index 000000000..224a96b70 --- /dev/null +++ b/cmd/mxcli/docker/sessions_clear_integration_test.go @@ -0,0 +1,57 @@ +//go:build integration + +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "database/sql" + "net/url" + "os" + "testing" +) + +// Against a real PostgreSQL: MXCLI_TEST_PG_HOST=127.0.0.1:5432 with a database +// "app" (user/password mendix) holding "system$session" and +// "system$session_user", and an empty database "fresh". +func TestClearLocalSessions_RealPostgres(t *testing.T) { + host := os.Getenv("MXCLI_TEST_PG_HOST") + if host == "" { + t.Skip("MXCLI_TEST_PG_HOST not set") + } + db := DBConfig{Type: RuntimeDatabaseType(DBTypePostgreSQL), Host: host, Name: "app", User: "mendix", Password: "mendix"} + + n, err := ClearSessions(db) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Error("cleared 0 sessions from a database seeded with some") + } + for _, table := range []string{`"system$session"`, `"system$session_user"`} { + if got := countRows(t, db, table); got != 0 { + t.Errorf("%s has %d rows after the clear, want 0", table, got) + } + } + + db.Name = "fresh" + if n, err := ClearSessions(db); err != nil || n != 0 { + t.Errorf("a database without System tables: n=%d err=%v, want 0, nil", n, err) + } +} + +func countRows(t *testing.T, db DBConfig, table string) int { + t.Helper() + h, p := splitHostPort(db.Host) + u := url.URL{Scheme: "postgres", User: url.UserPassword(db.User, db.Password), Host: h + ":" + p, Path: "/" + db.Name, RawQuery: "sslmode=disable"} + c, err := sql.Open("pgx", u.String()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + var n int + if err := c.QueryRow("select count(*) from " + table).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} diff --git a/cmd/mxcli/docker/sessions_clear_test.go b/cmd/mxcli/docker/sessions_clear_test.go new file mode 100644 index 000000000..9580e281e --- /dev/null +++ b/cmd/mxcli/docker/sessions_clear_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "context" + "database/sql" + "errors" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgconn" +) + +// Reported (mendixlabs/mxcli#1810, mirrored as ako/mxcli#2129): under +// `run --local` with security on and the trial licence, every probe sign-in +// leaves a System.Session row; after six the runtime refuses sign-in with HTTP +// 560 "Maximum number of sessions exceeded! (You are currently using a trial +// license)". The rows survive a restart — 20 had piled up in the field — and +// the only way out was to stop the app, delete from "system$session", and start +// it again. + +type fakeExec struct { + stmts []string + results map[string]int64 + missing map[string]bool // statements that fail with undefined_table +} + +type rowsResult int64 + +func (r rowsResult) LastInsertId() (int64, error) { return 0, nil } +func (r rowsResult) RowsAffected() (int64, error) { return int64(r), nil } + +func (f *fakeExec) ExecContext(_ context.Context, q string, _ ...any) (sql.Result, error) { + f.stmts = append(f.stmts, q) + for k := range f.missing { + if strings.Contains(q, k) { + return nil, &pgconn.PgError{Code: "42P01", Message: "relation does not exist"} + } + } + for k, n := range f.results { + if strings.Contains(q, k) { + return rowsResult(n), nil + } + } + return rowsResult(0), nil +} + +func TestClearSessionRows_DeletesSessionsAndTheirUserLinks(t *testing.T) { + f := &fakeExec{results: map[string]int64{`"system$session"`: 6}} + n, err := clearSessionRows(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if n != 6 { + t.Errorf("cleared = %d, want 6", n) + } + joined := strings.Join(f.stmts, "\n") + for _, want := range []string{`delete from "system$session_user"`, `delete from "system$session"`} { + if !strings.Contains(joined, want) { + t.Errorf("missing statement %q in:\n%s", want, joined) + } + } +} + +// A fresh database has no System tables until the runtime's first start creates +// them; that is nothing to clear, not an error. +func TestClearSessionRows_FreshDatabaseIsNotAnError(t *testing.T) { + f := &fakeExec{missing: map[string]bool{`"system$session`: true}} + n, err := clearSessionRows(context.Background(), f) + if err != nil || n != 0 { + t.Fatalf("fresh database: n=%d err=%v, want 0, nil", n, err) + } +} + +func TestClearSessionRows_OtherErrorsAreReported(t *testing.T) { + boom := errors.New("permission denied") + n, err := clearSessionRows(context.Background(), execFunc(func(string) error { return boom })) + if !errors.Is(err, boom) || n != 0 { + t.Fatalf("n=%d err=%v, want the error", n, err) + } +} + +type execFunc func(string) error + +func (e execFunc) ExecContext(_ context.Context, q string, _ ...any) (sql.Result, error) { + return nil, e(q) +} + +func TestClearSessions_RefusesTheFileDatabase(t *testing.T) { + _, err := ClearSessions(DBConfig{Type: RuntimeDatabaseType(DBTypeHSQLDB)}) + if err == nil || !strings.Contains(err.Error(), "HSQLDB") { + t.Fatalf("err = %v, want a refusal naming HSQLDB", err) + } +} + +// The sign-in hint names the way out that needs no restart. +func TestLoginFailureHint_NamesClearSessions(t *testing.T) { + path := t.TempDir() + "/runtime.log" + mustWrite(t, path, "LicenseRuntimeException: "+sessionCapMarker+" (You are currently using a trial license)\n") + hint := loginFailureHint(path) + for _, want := range []string{"mxcli run clear-sessions", "560", "6"} { + if !strings.Contains(hint, want) { + t.Errorf("hint missing %q:\n%s", want, hint) + } + } +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index 2e8a432e3..13b1416e9 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -38,6 +38,7 @@ stopped: pid 30133 in 300ms | `run wait [--timeout 5m]` | blocks until the watch loop has applied the model as it is now, or failed to; prints `applied: build #N via reload\|restart in …` or `failed:` with the CE codes / SCSS error lines. `--ready` waits for the boot instead; `--since N` for any build newer than N. | 0 applied, 1 failed, 2 timeout, 3 not running | | `run stop` | asks the run to shut down, waits until it is gone, then stops anything left in its process session — so no orphaned `runtimelauncher` or mxbuild JVM, even from a run killed with `-9`. Nothing running is a no-op. | 0 | | `run restart` | `run stop`, then `--detach` again with the arguments recorded in the state file. | as `--detach` | +| `run clear-sessions` | Deletes every session in the run's database, so the unlicensed runtime's 6 sign-in slots are free again without a restart (a 7th sign-in otherwise fails with HTTP 560, shown as "Sign in failed"). Signed-in browsers sign in again. Uses the run's recorded `--db-*` flags; pass `--db-password` when the run was given one literally. Not for `--db-type hsqldb`: restart instead. | 0 cleared, 1 failed, 3 no run recorded | **`run wait` is race-free.** It reads the model's source time when it starts and returns the first build made from that source or newer — so a change the loop From cd2cfe985500655e4938843965392c13844bc175 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:18:01 +0000 Subject: [PATCH 09/26] fix(run --watch): recheck an incremental CE0108 on a fresh mxbuild --serve ako/mxcli#2064: an incremental serve build reported CE0108 "Variable 'X' is defined but not in scope at this location" on a model that docker check passed and a full build applied, three times in one session on 11.16.0, and the watch loop stayed on "build #N FAILED" until something forced a full build. serveRecovery now treats an incremental failure carrying CE0108 like the stale theme error it already handles: restart mxbuild --serve, build once more, and let that answer stand, saying which one did. A failure the fresh process confirms is remembered by its error signature, so a genuine CE0108 costs one recheck rather than one per save, until a build passes. Other error codes are not rechecked (each check is a cold build). Control: with the branch disabled the scripted test returns the incremental CE0108 although the full build passed. Not reproduced live: the reported failure is non-deterministic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rXm5FHEucVx832Gw6 --- ...atch-incremental-false-ce0108-recheck.json | 1 + cmd/mxcli/docker/serve_recovery.go | 79 ++++++++++++++ cmd/mxcli/docker/serve_recovery_scope_test.go | 102 ++++++++++++++++++ docs-site/src/tools/run-local.md | 18 ++++ 4 files changed, 200 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-watch-incremental-false-ce0108-recheck.json create mode 100644 cmd/mxcli/docker/serve_recovery_scope_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-watch-incremental-false-ce0108-recheck.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-watch-incremental-false-ce0108-recheck.json new file mode 100644 index 000000000..babcd3365 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-watch-incremental-false-ce0108-recheck.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-11", "symptom": "ako/mxcli#2064: under `run --local --watch` (Mendix 11.16.0) an incremental mxbuild --serve build failed with CE0108 \"Variable 'Latest' is defined but not in scope at this location\" on an mxcli-written microflow (a variable a call creates, used after a decision), while `docker check` reported 0 errors and the next build on a fresh serve process applied the same model. Three times in one session, not deterministic; the loop stayed on 'build #N FAILED' until something forced a full build.", "cause": "mxbuild --serve's incremental state, as far as can be told: the same bytes pass a full build. serveRecovery already restarted serve for failures known to be process state (the 11.14 client-source shape, a stale CE6093 theme error) but took every other failure as the model's.", "file": "cmd/mxcli/docker/serve_recovery.go (incrementalScopeErrors, recheckIncrementalErrors, confirmedErrors)", "insight": "The verdict on a suspect failure belongs to the tool, not to a heuristic: rebuild on a fresh process and let that answer stand, which is the stale-theme pattern already in serveRecovery. Two costs to bound: scope it to codes actually seen lying (CE0108 only), since each recheck is a ~25 s cold build on a genuine error; and remember a fresh-process-confirmed error signature so a user fixing a real CE0108 pays one recheck, not one per save, cleared by the next passing build. Callers route only post-boot builds through serveRecovery, so 'not restartPending' means incremental. Control: with the branch disabled, the scripted test returns the incremental CE0108 although the full build passed. Not reproduced live: the report is non-deterministic.", "refs": ["ako/mxcli#2064"], "ce": ["CE0108"], "test": "cmd/mxcli/docker/serve_recovery_scope_test.go"} diff --git a/cmd/mxcli/docker/serve_recovery.go b/cmd/mxcli/docker/serve_recovery.go index 3e91e948c..436edd8b5 100644 --- a/cmd/mxcli/docker/serve_recovery.go +++ b/cmd/mxcli/docker/serve_recovery.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "strings" ) @@ -78,6 +79,50 @@ type serveRecovery struct { // headless: nothing serves the client (the test runner), so a build that // succeeds with an incomplete web/dist is a good build and is not repeated. headless bool + + // confirmedErrors is the error signature a fresh process last confirmed + // for a suspect incremental failure (see suspectIncrementalErrors). The same + // errors again are the model's, not the process's, and are not rechecked + // until a build passes. + confirmedErrors string +} + +// incrementalScopeErrors are the error codes an INCREMENTAL serve build has +// been seen to report for a model that a full build of the same bytes accepts +// (ako/mxcli#2064, Mendix 11.16.0, three times in one session): CE0108 +// "Variable 'X' is defined but not in scope at this location", on a variable a +// call creates and a decision after it uses. `docker check` reported 0 errors +// on the same model, and the next build on a fresh serve process applied it. +// Only codes seen doing this are listed: rechecking every failure would add a +// cold build (~25 s) to every genuine error. +var incrementalScopeErrors = map[string]bool{"CE0108": true} + +// suspectIncrementalErrors reports whether a failed build carries an error the +// incremental build is known to report falsely. +func suspectIncrementalErrors(res *BuildResult) bool { + for _, p := range res.Errors() { + if incrementalScopeErrors[p.ErrorCode] { + return true + } + } + return false +} + +// errorSignature identifies a build's errors: code, message and document each. +func errorSignature(res *BuildResult) string { + if res == nil { + return "" + } + var parts []string + for _, p := range res.Errors() { + doc := "" + if len(p.Locations) > 0 { + doc = p.Locations[0].Document + } + parts = append(parts, p.ErrorCode+"|"+p.Message+"|"+doc) + } + sort.Strings(parts) + return strings.Join(parts, "\n") } func (r *serveRecovery) Build(req BuildRequest) (*BuildResult, error) { @@ -109,6 +154,7 @@ func (r *serveRecovery) Build(req BuildRequest) (*BuildResult, error) { } if res.OK() { + r.confirmedErrors = "" if !prebuilt || r.headless { return res, nil } @@ -134,6 +180,10 @@ func (r *serveRecovery) Build(req BuildRequest) (*BuildResult, error) { return fresh, nil } + if !freshProcess && suspectIncrementalErrors(res) && errorSignature(res) != r.confirmedErrors { + return r.recheckIncrementalErrors(req, res) + } + if legacyClientBuildHint(r.deployDir, res.Message, string(res.Raw)) != "" { fmt.Fprintln(r.out, " mxbuild --serve cannot build this change incrementally (it wants client source its "+ "first build does not leave behind); restarting mxbuild --serve for a full build...") @@ -146,6 +196,35 @@ func (r *serveRecovery) Build(req BuildRequest) (*BuildResult, error) { return res, nil } +// recheckIncrementalErrors builds the model once more on a fresh serve +// process, after an incremental build failed with an error it is known to +// report falsely. The fresh build's answer stands: a pass is applied, and a +// failure is the model's own and is remembered so the same errors are not +// rechecked on every save. +func (r *serveRecovery) recheckIncrementalErrors(req BuildRequest, incremental *BuildResult) (*BuildResult, error) { + var codes []string + seen := map[string]bool{} + for _, p := range incremental.Errors() { + if incrementalScopeErrors[p.ErrorCode] && !seen[p.ErrorCode] { + seen[p.ErrorCode] = true + codes = append(codes, p.ErrorCode) + } + } + fmt.Fprintf(r.out, " the incremental build reports %s, which mxbuild --serve has reported for a model a full build accepts; "+ + "restarting mxbuild --serve for a full build to check...\n", strings.Join(codes, ", ")) + fresh, err := r.rebuildFresh(req) + if err != nil || fresh == nil { + return fresh, err + } + if fresh.OK() { + fmt.Fprintln(r.out, " the full build passed: the incremental build's errors were mxbuild --serve's own state, not the model's") + return fresh, nil + } + r.confirmedErrors = errorSignature(fresh) + fmt.Fprintln(r.out, " the full build fails too: these errors are the model's") + return fresh, nil +} + // rebuildFresh replaces the serve process and builds once on the new one. Its // RestartRequired is a first build's, so it is marked ColdRestart; a caller // that has an incremental answer replaces both. diff --git a/cmd/mxcli/docker/serve_recovery_scope_test.go b/cmd/mxcli/docker/serve_recovery_scope_test.go new file mode 100644 index 000000000..7299810bc --- /dev/null +++ b/cmd/mxcli/docker/serve_recovery_scope_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "io" + "strings" + "testing" +) + +// ako/mxcli#2064: under `run --local --watch` an INCREMENTAL build in a serve +// process that had already built failed with CE0108 "Variable 'Latest' is +// defined but not in scope at this location" on an mxcli-written microflow, +// while `docker check` reported 0 errors and a full build after a serve +// restart applied the same model. The loop stayed on "build #N FAILED" until +// something forced a full build. + +func buildCE(code, msg string) *BuildResult { + return &BuildResult{Status: "Failure", Message: "The project cannot be deployed, because it contains errors.", + Problems: BuildProblems{Problems: []BuildProblem{{Severity: "Error", ErrorCode: code, Message: msg, + Locations: []BuildLocation{{Document: "Microflow 'TMP_ReproAct'"}}}}}} +} + +const ce0108Msg = "Variable 'Latest' is defined but not in scope at this location." + +func TestServeRecovery_IncrementalCE0108IsCheckedOnAFreshProcess(t *testing.T) { + f := &scriptedServe{build: func(proc, _ int) (*BuildResult, error) { + if proc == 0 { + return buildCE("CE0108", ce0108Msg), nil // the stale incremental answer + } + return buildOK(true), nil // a full build of the same model + }} + var out strings.Builder + r := &serveRecovery{serve: f, out: &out} + res, err := r.Build(BuildRequest{ProjectFilePath: "/p/App.mpr"}) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + t.Fatalf("the full build passed, but the loop reports the incremental build's CE0108: %+v", res) + } + if f.restarts != 1 { + t.Errorf("restarts = %d, want 1", f.restarts) + } + if !res.ColdRestart { + t.Error("the answer is a fresh process's first build; it must be marked ColdRestart") + } + if got := out.String(); !strings.Contains(got, "CE0108") || !strings.Contains(got, "full build") { + t.Errorf("the recheck and its verdict must be reported, got:\n%s", got) + } +} + +// A genuine CE0108 costs one recheck, not one per save while it persists. +func TestServeRecovery_ConfirmedCE0108IsNotRecheckedUntilABuildPasses(t *testing.T) { + pass := false + f := &scriptedServe{build: func(_, _ int) (*BuildResult, error) { + if pass { + return buildOK(false), nil + } + return buildCE("CE0108", ce0108Msg), nil + }} + r := &serveRecovery{serve: f, out: io.Discard} + for i := 0; i < 3; i++ { + res, err := r.Build(BuildRequest{}) + if err != nil || res.OK() { + t.Fatalf("build %d: %+v %v, want the genuine CE0108", i, res, err) + } + } + if f.restarts != 1 { + t.Fatalf("restarts = %d after three saves of one genuine error, want 1", f.restarts) + } + pass = true + if res, _ := r.Build(BuildRequest{}); !res.OK() { + t.Fatal("a fixed model must build") + } + pass = false + if _, _ = r.Build(BuildRequest{}); f.restarts != 2 { + t.Fatalf("restarts = %d, want 2: after a passing build a new CE0108 is rechecked again", f.restarts) + } +} + +// Control: an error mxbuild --serve has not been seen to invent is reported as +// it is, at no extra cost. +func TestServeRecovery_OtherModelErrorsAreNotRechecked(t *testing.T) { + f := &scriptedServe{build: func(_, _ int) (*BuildResult, error) { + return buildCE("CE0109", "Attribute 'X' does not exist."), nil + }} + r := &serveRecovery{serve: f, out: io.Discard} + if res, _ := r.Build(BuildRequest{}); res.OK() || f.restarts != 0 { + t.Fatalf("res=%+v restarts=%d, want the failure as it is and no restart", res, f.restarts) + } +} + +// A build that already ran on a fresh process is a full build: its CE0108 is +// the model's. +func TestServeRecovery_FreshProcessCE0108IsNotRechecked(t *testing.T) { + f := &scriptedServe{build: func(_, _ int) (*BuildResult, error) { return buildCE("CE0108", ce0108Msg), nil }} + r := &serveRecovery{serve: f, out: io.Discard, restartPending: true} + if res, _ := r.Build(BuildRequest{}); res.OK() || f.restarts != 1 { + t.Fatalf("res=%+v restarts=%d, want the failure after the one pending restart", res, f.restarts) + } +} diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index 13b1416e9..0dc63aa3c 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -589,6 +589,24 @@ loop going. The hint mxcli prints offers the upgrade only for the half it fixes. meets the same second-build failure on the same versions (read off the code, not measured). +### A CE0108 only the incremental build reports + +An incremental build in a serve process that has already built has been seen to +fail with CE0108 *"Variable 'X' is defined but not in scope at this location"* on a +model that `docker check` passes and a full build applies (ako/mxcli#2064, 11.16.0, +not deterministic). Under `--watch` mxcli does not report that as the model's error +straight away: it restarts `mxbuild --serve` and builds once more, and says which +answer stood: + +``` +the incremental build reports CE0108, which mxbuild --serve has reported for a model a full build accepts; restarting mxbuild --serve for a full build to check... +the full build passed: the incremental build's errors were mxbuild --serve's own state, not the model's +``` + +When the full build fails too, the error is the model's, and the same errors on +later saves are reported without another check until a build passes. Other error +codes are never rechecked: each check is a cold build. + ## Pixel-perfect page loop Pass `--screenshot` and each applied change is captured to a PNG (default From 7930fc2170828e0728c7a87832fbdeec72133461 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:18:41 +0000 Subject: [PATCH 10/26] catalog: store an absent agent-editor reference as NULL, not '' The agent-editor tables marked "absent" two ways: an absent reference (Model, Entity, Document, KnowledgeBase, KeyConstant, Endpoint, AuthenticationMicroflow) was '' while an absent optional value (SelectedModel, DisplayTitle, the streaming switches) was NULL, sometimes in one row, so every query needed per-column knowledge (ako/mxcli#2093). docRefName and constantRefName now return NULL for a missing reference. Plain text settings (ToolChoice, ToolType, UserAccessApproval) keep the stored text. No lint rule, skill or Go reader compares these columns to ''; catalog-tables.md now states the rule. CatalogSchemaVersion 47. The rest of the catalog (QueueRef, widget refs, ParentLoopId) still uses '' and lint rules query it that way; that needs its own change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- ...6-11-11-catalog-absent-reference-null.json | 1 + docs-site/src/tools/catalog-tables.md | 15 +++++++--- .../2093-catalog-absent-reference-is-null.mdl | 28 +++++++++++++++++++ mdl/catalog/builder_agent_details_test.go | 12 ++++++-- mdl/catalog/builder_agenteditor.go | 18 ++++++++---- mdl/catalog/tables.go | 8 +++++- 6 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-absent-reference-null.json create mode 100644 mdl-examples/bug-tests/2093-catalog-absent-reference-is-null.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-absent-reference-null.json b/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-absent-reference-null.json new file mode 100644 index 000000000..131757133 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-catalog-absent-reference-null.json @@ -0,0 +1 @@ +{"area":"mdl/catalog","date":"2026-11-11","symptom":"Agent-editor catalog rows mark 'absent' two ways: an absent reference (Model, Entity, Endpoint, AuthenticationMicroflow, KeyConstant, Document) is '' while an absent optional value (SelectedModel, DisplayTitle, streaming switches) is NULL, sometimes in one row (RefundAgent|Task||∅), so 'no endpoint' was Endpoint = '' but 'no selected model' was SelectedModel IS NULL","cause":"docRefName / constantRefName returned string and so could only say '' for a nil reference, while the optional-value helpers returned any and passed nil through","file":"mdl/catalog/builder_agenteditor.go (docRefName, constantRefName)","insight":"A helper's Go return type decides the column's absence convention: a string-returning ref helper cannot produce NULL. Return any and pass nil. Before flipping a column from '' to NULL, grep every consumer (.star lint rules, Go Scan into string, skills, docs) for `= ''` and for Scan targets that cannot take NULL; for the agent tables the only consumers were the views, one test and catalog-tables.md. Bump CatalogSchemaVersion or a cached catalog keeps '' and IS NULL finds nothing. The wider catalog (QueueRef, widget refs, ParentLoopId) still uses '' and is documented and queried that way by lint rules, so changing it needs its own ADR-scale change, not a drive-by.","refs":["#2093"]} diff --git a/docs-site/src/tools/catalog-tables.md b/docs-site/src/tools/catalog-tables.md index ed2c72df8..4572bcb12 100644 --- a/docs-site/src/tools/catalog-tables.md +++ b/docs-site/src/tools/catalog-tables.md @@ -538,16 +538,23 @@ The Agent Editor's documents, with the settings their JSON contents hold document does not store, as an older Agent Editor does not, is `NULL` rather than `0`, so a query can tell "off" from "never set". +**Absent is `NULL`.** Every reference column (`Model`, `Entity`, `Document`, +`KnowledgeBase`, `KeyConstant`, `Endpoint`, `AuthenticationMicroflow`) and +every optional number or switch is `NULL` when there is none, so "no endpoint" +and "no selected model" are both `IS NULL`. A plain text setting (`ToolChoice`, +`ToolType`, `UserAccessApproval`) is the stored text, `''` when the document +stores none ([ako/mxcli#2093](https://github.com/ako/mxcli/issues/2093)). + ### CATALOG.AGENTS | Column | Description | |--------|-------------| | `UsageType` | `Chat` or `Task` | -| `Model` | The AI model document, qualified name | +| `Model` | The AI model document, qualified name (`NULL` if none) | | `SelectedModel`, `SelectedModelName` | The model picked from the provider's list, by id and display name (`NULL` if none) | -| `ToolChoice`, `MaxTokens`, `Temperature`, `TopP` | Generation settings, `NULL` or empty when not set | +| `ToolChoice`, `MaxTokens`, `Temperature`, `TopP` | Generation settings: `MaxTokens`, `Temperature` and `TopP` are `NULL` when not set, `ToolChoice` is `''` | | `EnableBackendStreaming`, `EnableUIStreaming` | `1`, `0`, or `NULL` when the document does not store the switch | -| `Entity` | The context entity, qualified name | +| `Entity` | The context entity, qualified name (`NULL` if none) | | `Variables` | The prompt variable keys, comma-separated | | `ToolCount`, `KnowledgeBaseCount`, `MCPServiceCount` | Entries in the agent's tool lists. `ToolCount` excludes MCP services | @@ -588,7 +595,7 @@ One row per entry of an agent's knowledge base tools list: `Agent`, `Name`, |-------|-------------------------------| | `CATALOG.AI_MODELS` | `Provider`, `ModelType`, `DisplayName`, `KeyConstant` | | `CATALOG.KNOWLEDGE_BASES` | `Provider`, `KeyConstant` | -| `CATALOG.CONSUMED_MCP_SERVICES` | `ProtocolVersion`, `Version`, `ConnectionTimeoutSeconds`, `Endpoint` (the String constant holding the server URL, empty when none: the service connects nowhere), `AuthenticationMicroflow` | +| `CATALOG.CONSUMED_MCP_SERVICES` | `ProtocolVersion`, `Version`, `ConnectionTimeoutSeconds`, `Endpoint` (the String constant holding the server URL, `NULL` when none: the service connects nowhere), `AuthenticationMicroflow` | | `CATALOG.PUBLISHED_MCP_SERVICES` | `ServerName`, `EndpointPath`, `Version`, `ProtocolVersion`, `AuthenticationEnabled`, `AuthenticationMicroflow`, `ToolCount` | What an agent references (its tools, model, knowledge bases and context entity), diff --git a/mdl-examples/bug-tests/2093-catalog-absent-reference-is-null.mdl b/mdl-examples/bug-tests/2093-catalog-absent-reference-is-null.mdl new file mode 100644 index 000000000..a1298aa52 --- /dev/null +++ b/mdl-examples/bug-tests/2093-catalog-absent-reference-is-null.mdl @@ -0,0 +1,28 @@ +mdl 1; +-- ako/mxcli#2093: the agent-editor catalog tables marked an absent reference +-- as '' and an absent optional value as NULL, sometimes in one row +-- (`RefundAgent|Task||∅|…`: an empty Model next to a NULL SelectedModel), so +-- every query needed to know which convention each column used. +-- +-- After the fix an absent reference is NULL too. Needs an app with Agent +-- Editor 2.6+ (the Agent Builder starter app). Run, then: +-- mxcli -p App.mpr -c "refresh catalog full" +-- mxcli -p App.mpr -c "select Name from CATALOG.CONSUMED_MCP_SERVICES where Endpoint is null and ModuleName = 'Bug2093'" +-- expected: NoAuthMCP is not listed (it has an endpoint) +-- mxcli -p App.mpr -c "select Name from CATALOG.CONSUMED_MCP_SERVICES where AuthenticationMicroflow is null and ModuleName = 'Bug2093'" +-- expected: NoAuthMCP (before the fix: no rows; `= ''` matched instead) +-- An agent without a model and a service without an endpoint are refused or +-- warned at check time (MDL-AGENT02, MDL-AGENT03), so those cases are covered +-- by the catalog unit test (TestAgentDetails_Issue2075) instead. + +create module Bug2093; + +create constant Bug2093.McpUrl (Type: String, DefaultValue: 'https://example.com/mcp'); + +create consumed mcp service Bug2093.NoAuthMCP ( + ProtocolVersion: v2025_03_26, + Endpoint: @Bug2093.McpUrl, + Version: '1.0', + ConnectionTimeoutSeconds: 30 +); + diff --git a/mdl/catalog/builder_agent_details_test.go b/mdl/catalog/builder_agent_details_test.go index c77ac12b2..072ab9068 100644 --- a/mdl/catalog/builder_agent_details_test.go +++ b/mdl/catalog/builder_agent_details_test.go @@ -166,7 +166,7 @@ func TestAgentDetails_Issue2075(t *testing.T) { // The row survives undecodable Contents, with no details. "Broken|∅|∅|∅|∅|∅|∅|∅|∅|∅|∅|0|0|0", "GraphExplorer|Chat|GraphPilot_Explorer.LLM_GenAIResource|eu.anthropic.claude-sonnet-5||∅|∅|1|0|GraphPilot_Explorer.OnboardingHelper|EnvironmentUrl,GraphmartUri,GeneralInfo,OntologyOverview,Questions|1|0|0", - "RefundAgent|Task||∅|Auto|2048|0.2|∅|∅|||1|0|1", + "RefundAgent|Task|∅|∅|Auto|2048|0.2|∅|∅|∅||1|0|1", // Control: an agent that stores no streaming switch reads NULL, not "off". "WorkInstructionAgent|Task|AgenticAssistants.ClaudeSonnet46|∅||∅|∅|∅|∅|AgenticAssistants.GenericAgentInterface|Question|0|1|0", ) @@ -197,10 +197,16 @@ func TestAgentDetails_Issue2075(t *testing.T) { Endpoint, AuthenticationMicroflow FROM consumed_mcp_services ORDER BY Name`, "JiraMCP|v2025_03_26|1.0|30|Agents.JiraUrl|Agents.Jira_GetHeaders", - "NoEndpointMCP|v2025_03_26|1.0|30||") + "NoEndpointMCP|v2025_03_26|1.0|30|∅|∅") assertAgentRows(t, cat, `SELECT Name, ServerName, EndpointPath, Version, ProtocolVersion, AuthenticationEnabled, AuthenticationMicroflow, ToolCount FROM published_mcp_services`, - "Published_MCP_service|Published_MCP_service|published_mcp_service|1.0.0|v2025_03_26|0||1") + "Published_MCP_service|Published_MCP_service|published_mcp_service|1.0.0|v2025_03_26|0|∅|1") + // ako/mxcli#2093: an absent reference is NULL in every agent table, as an + // absent optional value already was, so "which services have no endpoint" + // is `IS NULL` like "which agents have no selected model". + assertAgentRows(t, cat, `SELECT Name FROM consumed_mcp_services WHERE Endpoint IS NULL`, "NoEndpointMCP") + assertAgentRows(t, cat, `SELECT Name FROM agents WHERE Model IS NULL AND SelectedModel IS NULL ORDER BY Name`, + "Broken", "RefundAgent") assertAgentRows(t, cat, `SELECT ObjectType, QualifiedName FROM objects WHERE ObjectType = 'PUBLISHED_MCP_SERVICE'`, "PUBLISHED_MCP_SERVICE|Agents.Published_MCP_service") } diff --git a/mdl/catalog/builder_agenteditor.go b/mdl/catalog/builder_agenteditor.go index aebd5a726..8bb83e772 100644 --- a/mdl/catalog/builder_agenteditor.go +++ b/mdl/catalog/builder_agenteditor.go @@ -45,16 +45,22 @@ func nullBool(b *bool) any { return boolToInt(*b) } -func docRefName(r *types.DocRef) string { - if r == nil { - return "" +// docRefName is a reference column: the referenced document's qualified name, +// or NULL when there is none. It used to be "", beside optional values that +// were already NULL in the same row, so "which consumed MCP services have no +// endpoint" compared Endpoint to the empty string while "which agents have no selected model" was +// `SelectedModel IS NULL` (ako/mxcli#2093). One rule: absent is NULL. +func docRefName(r *types.DocRef) any { + if r == nil || r.QualifiedName == "" { + return nil } return r.QualifiedName } -func constantRefName(r *types.ConstantRef) string { - if r == nil { - return "" +// constantRefName is docRefName for a reference to a constant. +func constantRefName(r *types.ConstantRef) any { + if r == nil || r.QualifiedName == "" { + return nil } return r.QualifiedName } diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index 1faa983e0..e01478b59 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,12 @@ package catalog // // History: // +// 47 (absent references are NULL): the agent-editor tables (agents, +// agent_tools, agent_knowledge_bases, ai_models, knowledge_bases, +// consumed_mcp_services, published_mcp_services) store an absent +// reference as NULL instead of '', as their optional values already were +// (ako/mxcli#2093). Without the bump a cached catalog keeps the '' and an +// `IS NULL` query finds nothing. // 46 (excluded UI documents): snippets and layouts gain Excluded, as pages // already had, and snippets.ParameterCount is the snippet's parameter // count instead of a literal 0 (mendixlabs/mxcli#1819). Without the bump @@ -251,7 +257,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "46" +const CatalogSchemaVersion = "47" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. From fd51d9fe2f6ca3692884d71264a05dd392bf8bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:25:20 +0000 Subject: [PATCH 11/26] fix: check reports access-rule XPath constraints that mxbuild rejects (CE0161) Since mendixlabs/mxcli#1472 `check -p` resolves the members an access rule's WHERE names, but a constraint whose members all exist still failed mx check with CE0161 "Error(s) in XPath constraint." at Access rule of entity (ako/mxcli#2099, mirror of mendixlabs/mxcli#1803). Measured on mxbuild 11.14.0 at security level Production, one entity and one access rule per constraint (86 forms, every bad form and its legal twin), the grant now gets the same form rules as retrieves and widgets: - MDL-XPATH04 (new, no project): any variable in an access rule, including $currentUser and $currentObject/..., is CE0161. - MDL047 (no project): `empty` compared with an association, by the retrieve's own matcher. - with a project, once the members resolve: an unqualified association (validateXPathAssociations) and a System member the entity does not store (validateRetrieveConstraints), the retrieve functions called with the grant as a one-element slice; and a literal whose type the attribute does not take: '[%CurrentUser%]' on any attribute, a quoted value on a number, a number or true() on a String, a non-'true'/'false' string on a Boolean, a number on a DateTime, and an enumeration key that is not one of the value names (case-sensitive; a caption is not a key). Silent where the type cannot be established: paths, inherited attributes, unmeasured kinds, altered entities, tokens other than [%CurrentUser%]. mdl-examples/bug-tests/936-grant-additive-merge.mdl used `[Name != $currentUser/Name]` in a grant, which is CE0161 at Production (measured); its constraint is replaced with one that builds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- ...ess-rule-xpath-form-and-literal-types.json | 1 + .../skills/mendix/xpath-constraints/SKILL.md | 25 +- ...cess-rule-xpath-form-and-literal-types.mdl | 56 +++ .../bug-tests/936-grant-additive-merge.mdl | 9 +- mdl/executor/rule_id_uniqueness_test.go | 4 + mdl/executor/validate_access_rule_xpath.go | 323 ++++++++++++++++++ .../validate_access_rule_xpath_test.go | 200 +++++++++++ mdl/executor/validate_grant_refs.go | 16 +- mdl/executor/validate_security.go | 2 + 9 files changed, 626 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-access-rule-xpath-form-and-literal-types.json create mode 100644 mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl create mode 100644 mdl/executor/validate_access_rule_xpath.go create mode 100644 mdl/executor/validate_access_rule_xpath_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-access-rule-xpath-form-and-literal-types.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-access-rule-xpath-form-and-literal-types.json new file mode 100644 index 000000000..9525743e8 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-access-rule-xpath-form-and-literal-types.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-11", "refs": ["ako/mxcli#2099", "mendixlabs/mxcli#1803", "mendixlabs/mxcli#1472"], "ce": ["CE0161"], "rules": ["MDL-XPATH04", "MDL047", "MDL-XPATH01"], "symptom": "An access rule WHERE whose members all resolve passed check -p and exec and failed mx check (11.14.0, Production) with CE0161 \"Error(s) in XPath constraint.\" at Access rule of entity: [Title = $x], [M.E_Kid = empty], [Title = '[%CurrentUser%]'], [Cnt = '5'], [Status = 'red'], [System.owner = '[%CurrentUser%]'] on an entity without owner, [E_Kid/M.Kid/Name = 'a'].", "file": "mdl/executor/validate_access_rule_xpath.go (new: accessRuleXPathFormViolations from ValidateGrantEntityAccess; grantResolver.xpathFormProblems from validate_grant_refs.go check()); test validate_access_rule_xpath_test.go", "cause": "#1472's grantResolver.xpathProblem only asked whether members resolve. The form rules the retrieve and widget slots had (MDL047, MDL-XPATH01, the System-member rule) were never called for the grant slot, and nothing anywhere typed a literal against its attribute.", "insight": "Measure with one entity + one access rule per constraint in ONE app: mxbuild attributes each CE0161 to 'Access rule of entity X', so 86 forms cost two 17-second mx checks instead of 86. The matrix overturned three guesses: an access rule takes NO variable at all ($currentUser included - the token is the spelling); any string literal on a DateTime builds ('abc' too) while a Boolean takes 'true'/'false' in any case but not 'yes'; a decimal literal on an Integer builds while '5' does not. Enumeration literals match value NAMES case-sensitively (the caption 'Green' fails, the key 'Grn' builds) - the opposite of member names, which mxbuild folds. Reuse was cheap because validateXPathAssociations and validateRetrieveConstraints take a []retrieveConstraintRef: a grant is a one-element slice. Run the form rules only once xpathProblem is clean - a standing CE1613 suppresses every CE0161. Left silent: tokens other than [%CurrentUser%] (a date token on an Integer is CE0161 too), attributes reached over a path, and a token on an association to a non-User entity."} diff --git a/.claude/skills/mendix/xpath-constraints/SKILL.md b/.claude/skills/mendix/xpath-constraints/SKILL.md index a47dfdb39..a7bb82dea 100644 --- a/.claude/skills/mendix/xpath-constraints/SKILL.md +++ b/.claude/skills/mendix/xpath-constraints/SKILL.md @@ -14,11 +14,11 @@ description: "XPath constraint syntax for MDL — retrieve WHERE clauses, page d | 35-87 | XPath vs Mendix Expressions | | 88-131 | Syntax Reference | | 132-309 | How a Constraint Is Laid Out on Disk | -| 310-360 | Usage Contexts | -| 361-400 | Enumeration Attributes | -| 401-442 | Common Patterns | -| 443-454 | Validation | -| 455-464 | Troubleshooting | +| 310-375 | Usage Contexts | +| 376-415 | Enumeration Attributes | +| 416-457 | Common Patterns | +| 458-469 | Validation | +| 470-479 | Troubleshooting | # XPath Constraints in MDL @@ -354,6 +354,21 @@ grant read *, write * on entity Module.Entity to Module.Role where [System.owner = '[%CurrentUser%]']; ``` +An access rule is stricter than a retrieve. Each of these fails the build with +CE0161 at security level Production (measured on 11.14.0), and `check` reports +it at the grant (ako/mxcli#2099): + +- **No variables at all** — not `$x`, not `$currentUser` (**MDL-XPATH04**). The + current user is the token `'[%CurrentUser%]'`, compared with an association + to `System.User` (or a specialization) or with `System.owner` — never with an + attribute. +- **A literal of the attribute's type**: `[Count = 5]`, not `'5'`; a String + takes a quoted string; a Boolean `true()`, `false()` or `'true'`/`'false'`; + an enumeration the **key**, case-sensitively (`'Red'`, not `'red'`, and not + the caption). +- `empty` on an association (MDL047), an association without its module + (MDL-XPATH01), and `System.owner` on an entity that does not store it. + Sibling groups (`where [a][b]`) are kept as one constraint. The old quoted form `grant Module.Role on Module.Entity (...) where '[...]'` still parses, warns MDL-DEPR030, and `mxcli fmt --upgrade` rewrites it. diff --git a/mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl b/mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl new file mode 100644 index 000000000..70382b28b --- /dev/null +++ b/mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl @@ -0,0 +1,56 @@ +-- Bug ako/mxcli#2099 (mendixlabs/mxcli#1803): an access rule's WHERE whose +-- members all exist still failed the build, while `check -p` and exec passed it: +-- +-- [error] [CE0161] "Error(s) in XPath constraint." at Access rule of entity 'M.E' +-- +-- Measured on mxbuild 11.14.0, security level Production, one entity and one +-- rule per constraint. Each of the issue's seven is CE0161, and `check` now +-- reports it at the grant: +-- +-- [Title = $x] a variable (MDL-XPATH04, no project needed; +-- $currentUser too — use '[%CurrentUser%]') +-- [M.E_Kid = empty] empty on an association (MDL047) +-- [Title = '[%CurrentUser%]'] the user token on an attribute (any type) +-- [Cnt = '5'] a quoted value on an Integer +-- [Status = 'red'] the key is Red — case-sensitive; a caption +-- is not a key either +-- [System.owner = '[%CurrentUser%]'] on an entity that stores no owner +-- [E_Kid/M.Kid/Name = 'a'] an association without its module (MDL-XPATH01) +-- +-- This file holds the legal twins, every one measured at 0 errors on the same +-- app. It must pass `check` (with and without a project) and build clean. + +create module M; +alter app security ( SecurityLevel: production ); + +create enumeration M.Color ( Red 'Red', Grn 'Green' ); +create persistent entity M.Kid ( Name: String(100) ); +create persistent entity M.E ( + Title: String(100), Cnt: Integer, Big: Long, Amt: Decimal, + Flag: Boolean, D: DateTime, Status: Enumeration(M.Color) +); +create persistent entity M.O ( Title: String(100), owner: AutoOwner ); +create association M.E_Kid from M.E to M.Kid; +create association M.E_User from M.E to System.User; + +create module role M.User; +create module role M.Viewer; +create module role M.Auditor; +create module role M.Clerk; + +grant read * on entity M.Kid to M.User, M.Viewer, M.Auditor, M.Clerk; + +-- the user token on an association to System.User, and on an owner the entity stores +grant read * on entity M.E to M.User where [M.E_User = '[%CurrentUser%]']; +grant read * on entity M.O to M.User where [System.owner = '[%CurrentUser%]']; + +-- an association tested by a path, qualified +grant read * on entity M.E to M.Viewer where [not(M.E_Kid/M.Kid) or M.E_Kid/M.Kid/Name = 'a']; + +-- each literal in a type its attribute takes; `empty` on attributes +grant read * on entity M.E to M.Auditor + where [Status = 'Red' and Cnt = 5 and Big = 5.5 and Amt = 5 and Flag = 'false' + and D = '2020-01-01' and Title = '5']; +grant read * on entity M.E to M.Clerk + where [(Status = 'Grn' or Status = empty) and Cnt != empty and Flag = true() + and D > '[%BeginOfCurrentDay%]']; diff --git a/mdl-examples/bug-tests/936-grant-additive-merge.mdl b/mdl-examples/bug-tests/936-grant-additive-merge.mdl index 2d8d7be14..ead8ba357 100644 --- a/mdl-examples/bug-tests/936-grant-additive-merge.mdl +++ b/mdl-examples/bug-tests/936-grant-additive-merge.mdl @@ -46,7 +46,7 @@ mdl 1; -- Expected after running (order of members follows the entity): -- grant BugTest936.Viewer on BugTest936.Customer (read (Name, Email, Phone)); -- grant BugTest936.Reader on BugTest936.Customer (read (Name, Email, Phone)) --- where '[Name != $currentUser/Name]'; +-- where '[Name != '''']'; -- grant BugTest936.Admin on BugTest936.Customer (create, delete, read *, write *); -- grant BugTest936.Scoped on BugTest936.Customer (read (Name)) where '[Name = 'a']'; -- grant BugTest936.Scoped on BugTest936.Customer (read (Email)) where '[Name = 'b']'; @@ -72,10 +72,13 @@ grant read (Phone) on entity BugTest936.Customer to BugTest936.Viewer; -- 2. The same thing with the WHERE clause the issue was filed against. It fails -- and passes together with section 1; the constraint never mattered. +-- (The issue's `[Name != $currentUser/Name]` is CE0161 at security level +-- Production — an access rule has no variables, ako/mxcli#2099 — so the +-- constraint here is one that builds.) grant read (Name, Email) on entity BugTest936.Customer to BugTest936.Reader - where [Name != $currentUser/Name]; + where [Name != '']; grant read (Phone) on entity BugTest936.Customer to BugTest936.Reader - where [Name != $currentUser/Name]; + where [Name != '']; -- 3. Structural permissions. The narrow re-grant must not revoke create, delete -- or the rule's ReadWrite default -- it used to drop all three. diff --git a/mdl/executor/rule_id_uniqueness_test.go b/mdl/executor/rule_id_uniqueness_test.go index c94a2e538..72163e413 100644 --- a/mdl/executor/rule_id_uniqueness_test.go +++ b/mdl/executor/rule_id_uniqueness_test.go @@ -48,6 +48,10 @@ var ruleIDsSharedDeliberately = map[string]string{ "validate_design_properties.go covers DesignProperties on a slot of its parent (a " + "layoutgrid row, a row's column, a dataview footer), which the builder assembles " + "without applying appearance. Someone suppressing MDL-WIDGET07 means both", + "MDL047": "one rule, one matcher (xpathAssociationEmptyMatches), one site per XPath slot: " + + "`empty` compared with an association. validate_microflow.go asks it of a retrieve, " + + "validate_widgets.go of a database data source, validate_access_rule_xpath.go of an " + + "access rule (ako/mxcli#2099). Someone suppressing MDL047 means every slot", "MDL059": "one rule, two sites: an annotation that parses and does nothing. " + "validate_flow_parameters.go covers one written on a PARAMETER, " + "validate_document_annotations.go one written before a CREATE. Someone " + diff --git a/mdl/executor/validate_access_rule_xpath.go b/mdl/executor/validate_access_rule_xpath.go new file mode 100644 index 000000000..a50ecb816 --- /dev/null +++ b/mdl/executor/validate_access_rule_xpath.go @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// An access rule's WHERE whose members all resolve can still fail the build +// (ako/mxcli#2099, mendixlabs/mxcli#1803). Since #1472 the grant check resolved +// the members a constraint names (grantResolver.xpathProblem); it applied none +// of the FORM rules the retrieve and widget constraints already had, and nothing +// anywhere typed a literal against the attribute it is compared with. Measured +// on mxbuild 11.14.0 at security level Production, one entity and one access +// rule per constraint, so each error is attributed to exactly one form: +// +// CE0161 "Error(s) in XPath constraint." at Access rule of entity … +// [Title = $x] [M.E_User = $currentUser] [M.E_User = $CurrentUser] +// [Title = $currentUser/Name] [Title = $currentObject/Title] [contains(Title, $x)] +// [M.E_Kid = empty] [M.E_Kid != empty] +// '[%CurrentUser%]' on String, Integer, Long, Decimal, Boolean, DateTime, Enumeration +// '[%CurrentUser%]' on an association to M.Kid +// [Cnt = '5'] ['5' = Cnt] [Cnt > '5'] [Cnt = 'abc'] [Cnt = ''] [Cnt = true()] +// [Big = '5'] [Big = 'abc'] [Amt = '5'] [Amt = '5.5'] [Amt = true()] +// [Title = 5] [Title = 5.5] [Title != 5] [Title = true()] +// [Flag = 'yes'] [Flag = 1] [D = 5] [D = true()] +// [Status = 'red'] [Status != 'red'] [Status = 'Nope'] [Status = 'Green'] (the caption) +// [Status = ''] [Status = 5] [Status = true()] +// [System.owner = '[%CurrentUser%]'] on an entity that stores no owner +// [E_Kid/M.Kid/Name = 'a'] +// 0 errors (the legal twins) +// [M.E_User = '[%CurrentUser%]'], and on an association to a specialization +// of System.User; [System.owner = '[%CurrentUser%]'] on an entity storing owner +// [not(M.E_Kid/M.Kid)] [Title = empty] [Cnt = empty] [Status = empty] +// [Cnt = 5] [Cnt = 5.5] [Cnt = -5] [Big = 5.5] [Amt = 5] [Title = '5'] [Title = ''] +// [Flag = 'true'] [Flag = 'True'] [Flag = 'TRUE'] [Flag = 'false'] [Flag = true] +// [Flag = false()] [D = '2020-01-01'] [D = 'abc'] [D = '[%CurrentDateTime%]'] +// [Status = 'Red'] [Status = 'Grn'] [M.E_Kid/M.Kid/Name = 'a'] +// +// The rules run where the constraint's other checks do. The two that need no +// project — a variable, `empty` on an association — are part of the per-grant +// no-project pass (ValidateGrantEntityAccess); the three that need the entity +// are part of the reference pass (grantResolver.xpathFormProblems), which exec +// runs too. The association and System-member rules are the retrieve's own +// functions, not a third copy. +// +// Could-not-establish is silence: an attribute reached over a path, an +// inherited one, one of a kind not measured (AutoNumber, HashedString, Binary, +// Date), an entity the script alters, a token other than [%CurrentUser%] (a +// date token compared with a number was measured as CE0161 too, but the token +// vocabulary is not modelled here), and an enumeration whose values neither the +// script nor the project states. + +// accessRuleVariableRule reports a variable in an access rule. A flow, a page +// and a user task have variables to hand an XPath constraint; an access rule +// has none — not even $currentUser, which the rule spells '[%CurrentUser%]'. +const accessRuleVariableRule = "MDL-XPATH04" + +// xpathVariableRe matches a variable reference, after blankXPathLiterals. +var xpathVariableRe = regexp.MustCompile(`\$[A-Za-z_][A-Za-z0-9_]*`) + +// accessRuleXPathFormViolations are the form rules an access rule's WHERE can +// be judged on without a project: a variable (MDL-XPATH04) and `empty` compared +// with an association (MDL047, the retrieve's rule and its matcher). +func accessRuleXPathFormViolations(stmt *ast.GrantEntityAccessStmt, loc linter.Location) []linter.Violation { + xpath := stmt.XPathConstraint + if strings.TrimSpace(xpath) == "" { + return nil + } + entity := stmt.Entity.String() + var out []linter.Violation + seen := map[string]bool{} + for _, v := range xpathVariableRe.FindAllString(blankXPathLiterals(xpath), -1) { + if seen[v] { + continue + } + seen[v] = true + fix := "Write the value into the constraint, or use a token: `'[%CurrentUser%]'` compared with an association to System.User (or System.owner)." + if strings.EqualFold(v, "$currentUser") { + fix = "An access rule spells the current user as a token: `[" + " = '[%CurrentUser%]']`, the association leading to System.User." + } + out = append(out, linter.Violation{ + RuleID: accessRuleVariableRule, + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("access rule on %s: the constraint uses the variable %s — an access rule has no variables, "+ + "and mxbuild rejects it with CE0161 \"Error(s) in XPath constraint.\"", entity, v), + Suggestion: fix, + }) + } + for _, h := range xpathAssociationEmptyMatches(xpath) { + out = append(out, linter.Violation{ + RuleID: "MDL047", + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("access rule on %s: the constraint tests association `%s`, which Mendix XPath does not support "+ + "(CE0161 \"Error(s) in XPath constraint\") — `empty` compares attributes, not associations", entity, h.test()), + Suggestion: h.fix(), + }) + } + return out +} + +// xpathFormProblems are the project-tier form rules for an access rule's WHERE, +// judged on the constraint as exec stores it: an association named without its +// module (the retrieve's MDL-XPATH01 function), a System member the entity does +// not store (the retrieve's function), and a literal whose type the attribute +// it is compared with does not take. Run only once the members resolve — with +// a CE1613 standing, mxbuild reports no CE0161 at all. +func (g *grantResolver) xpathFormProblems(qn ast.QualifiedName, xpath string) []string { + if strings.TrimSpace(xpath) == "" { + return nil + } + name := qn.String() + if g.whole.indirectEntities[name] { + return nil + } + stored := accessRuleXPathConstraint(g.ctx, xpath, name) + ref := []retrieveConstraintRef{{entity: name, constraint: stored, stored: stored}} + var out []string + for _, m := range validateXPathAssociations(g.ctx, ref, g.whole) { + out = append(out, "access rule "+m) + } + for _, m := range validateRetrieveConstraints(g.ctx, ref, g.whole) { + out = append(out, "access rule "+m) + } + if !g.whole.alteredEntities[name] { + out = append(out, g.literalTypeProblems(name, stored)...) + } + return out +} + +// xpathLiteral is a literal compared with an attribute. +type xpathLiteral struct { + class string // "string", "number", "boolean" + value string // the string's value; the text otherwise +} + +// xpathLiteralOf classifies a comparison operand as a literal. `empty` and +// anything that is not a literal is not one. +func xpathLiteralOf(e ast.Expression) (xpathLiteral, bool) { + switch x := e.(type) { + case *ast.LiteralExpr: + switch x.Kind { + case ast.LiteralString: + s, _ := x.Value.(string) + return xpathLiteral{"string", s}, true + case ast.LiteralInteger, ast.LiteralDecimal: + return xpathLiteral{"number", fmt.Sprint(x.Value)}, true + case ast.LiteralBoolean: + return xpathLiteral{"boolean", fmt.Sprint(x.Value)}, true + } + case *ast.UnaryExpr: + if x.Operator == "-" { + if l, ok := xpathLiteralOf(x.Operand); ok && l.class == "number" { + return xpathLiteral{"number", "-" + l.value}, true + } + } + case *ast.FunctionCallExpr: + if len(x.Arguments) == 0 && (strings.EqualFold(x.Name, "true") || strings.EqualFold(x.Name, "false")) { + return xpathLiteral{"boolean", strings.ToLower(x.Name) + "()"}, true + } + } + return xpathLiteral{}, false +} + +func (l xpathLiteral) text() string { + if l.class == "string" { + return "'" + strings.ReplaceAll(l.value, "'", "''") + "'" + } + return l.value +} + +// walkXPathLiteralComparisons calls fn for each comparison of a bare attribute +// with a literal, either way round, through and/or, not() and parentheses. +func walkXPathLiteralComparisons(e ast.Expression, fn func(attr, op string, lit xpathLiteral)) { + switch x := e.(type) { + case *ast.ParenExpr: + walkXPathLiteralComparisons(x.Inner, fn) + case *ast.UnaryExpr: + walkXPathLiteralComparisons(x.Operand, fn) + case *ast.FunctionCallExpr: + if strings.EqualFold(x.Name, "not") { + for _, a := range x.Arguments { + walkXPathLiteralComparisons(a, fn) + } + } + case *ast.BinaryExpr: + op := strings.ToLower(x.Operator) + switch op { + case "and", "or": + walkXPathLiteralComparisons(x.Left, fn) + walkXPathLiteralComparisons(x.Right, fn) + case "=", "!=", "<>", "<", ">", "<=", ">=": + if id, ok := x.Left.(*ast.IdentifierExpr); ok { + if lit, ok := xpathLiteralOf(x.Right); ok { + fn(id.Name, op, lit) + } + } else if id, ok := x.Right.(*ast.IdentifierExpr); ok { + if lit, ok := xpathLiteralOf(x.Left); ok { + fn(id.Name, op, lit) + } + } + } + } +} + +// literalTypeProblems reports each literal in the constraint whose type the +// attribute it is compared with does not take (the table in the file comment). +func (g *grantResolver) literalTypeProblems(entityQN, constraint string) []string { + groups := visitor.SplitXPathPredicateGroups(constraint) + if len(groups) == 0 { + groups = []string{constraint} + } + var out []string + seen := map[string]bool{} + for _, grp := range groups { + expr, ok := visitor.ParseXPathConstraint(grp) + if !ok || expr == nil { + continue + } + walkXPathLiteralComparisons(expr, func(attr, op string, lit xpathLiteral) { + at, ok := retrieveAttributeType(g.ctx, g.whole, entityQN, attr) + if !ok { + return + } + msg := g.literalTypeProblem(at, op, lit) + if msg == "" { + return + } + p := fmt.Sprintf("the access rule's constraint compares %s (%s) with %s — %s; mxbuild rejects the constraint "+ + "with CE0161 \"Error(s) in XPath constraint.\"", attr, xpathOperandTypeName(at), lit.text(), msg) + if !seen[p] { + seen[p] = true + out = append(out, p) + } + }) + } + return out +} + +// literalTypeProblem is why an attribute of type at does not take lit, or "" +// when it does or that cannot be established. +func (g *grantResolver) literalTypeProblem(at ast.DataType, op string, lit xpathLiteral) string { + class := xpathOperandClass(at.Kind) + if class == "" { + return "" + } + if lit.class == "string" && lit.value == "[%CurrentUser%]" { + return "[%CurrentUser%] is the current user object, which compares with an association to System.User " + + "(or System.owner), not with an attribute value" + } + if lit.class == "string" && strings.Contains(lit.value, "[%") { + return "" // another token: its type is not modelled here + } + switch class { + case "string": + if lit.class != "string" { + return "a String attribute compares with a quoted string" + } + case "number": + if lit.class != "number" { + return "a number attribute compares with an unquoted number, such as 5 rather than '5'" + } + case "boolean": + if lit.class == "number" || (lit.class == "string" && !strings.EqualFold(lit.value, "true") && !strings.EqualFold(lit.value, "false")) { + return "a Boolean attribute compares with true() or false()" + } + case "datetime": + if lit.class != "string" { + return "a DateTime attribute compares with a date string or a date token such as '[%CurrentDateTime%]'" + } + case "enumeration": + if op != "=" && op != "!=" { + return "" + } + enumQN := qualifiedEnumRef(at) + if lit.class != "string" { + return "an enumeration attribute compares with the quoted key of one of " + enumQN + "'s values" + } + values, known := g.enumValues(enumQN) + if !known || slices.Contains(values, lit.value) { + return "" + } + return fmt.Sprintf("%s has no value named %q (its values: %s; the key is case-sensitive, and a caption is not a key)", + enumQN, lit.value, strings.Join(values, ", ")) + } + return "" +} + +// enumValues is an enumeration's value names as the script leaves them, or the +// project's; false when neither states them. +func (g *grantResolver) enumValues(enumQN string) ([]string, bool) { + if enumQN == "" { + return nil, false + } + if g.scriptEnums == nil { + g.scriptEnums, g.unjudgedEnums = scriptEnumerationValues(g.prog) + } + if g.unjudgedEnums[enumQN] { + return nil, false + } + if v, ok := g.scriptEnums[enumQN]; ok { + return v, true + } + mod, name, _ := strings.Cut(enumQN, ".") + e := findEnumeration(g.ctx, mod, name) + if e == nil || len(e.Values) == 0 { + return nil, false + } + v := make([]string, 0, len(e.Values)) + for _, ev := range e.Values { + v = append(v, ev.Name) + } + return v, true +} diff --git a/mdl/executor/validate_access_rule_xpath_test.go b/mdl/executor/validate_access_rule_xpath_test.go new file mode 100644 index 000000000..b4cb87d16 --- /dev/null +++ b/mdl/executor/validate_access_rule_xpath_test.go @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// ako/mxcli#2099 (mendixlabs/mxcli#1803): an access rule's WHERE whose members +// all resolve still failed the build — +// +// [error] [CE0161] "Error(s) in XPath constraint." at Access rule of entity 'M.E' +// +// — while `check -p` and exec passed it. Measured on mxbuild 11.14.0 at security +// level Production, one entity per constraint (see validate_access_rule_xpath.go +// for the table). + +// accessRuleDecls is the issue's entity, declared by the script. +const accessRuleDecls = `create enumeration M.Color ( Red 'Red', Grn 'Green' ); +create persistent entity M.Kid ( Name: String(100) ); +create persistent entity M.E ( Title: String(100), Cnt: Integer, Big: Long, Amt: Decimal, Flag: Boolean, D: DateTime, Status: Enumeration(M.Color) ); +create persistent entity M.O ( Title: String(100), owner: AutoOwner ); +create association M.E_Kid from M.E to M.Kid; +create association M.E_User from M.E to System.User; +create module role M.User; +` + +func accessRuleCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("M") + dm := &domainmodel.DomainModel{BaseElement: model.BaseElement{ID: model.ID("dm-m")}, ContainerID: mod.ID} + h := mkHierarchy(mod) + withContainer(h, dm.ID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleByNameFunc: func(string) (*model.Module, error) { return mod, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return []*domainmodel.DomainModel{dm}, nil }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx +} + +// accessRuleProblems runs both tiers a grant goes through — the no-project +// rules (ValidateProgram) and the project's reference pass — and returns every +// message, errors only. +func accessRuleProblems(t *testing.T, ctx *ExecContext, src string) []string { + t.Helper() + prog := parseMDL(t, src) + var out []string + for _, v := range ValidateProgram(prog, "") { + if v.Severity.String() == "error" { + out = append(out, v.RuleID+": "+v.Message) + } + } + for _, e := range validateGrantReferences(ctx, prog) { + out = append(out, e.Error()) + } + return out +} + +func TestAccessRuleXPath_ReportsWhatMxbuildRejects(t *testing.T) { + cases := []struct{ name, where, want string }{ + // the issue's seven + {"a variable", "[Title = $x]", "$x"}, + {"empty on an association", "[M.E_Kid = empty]", "M.E_Kid = empty"}, + {"user token on a String", "[Title = '[%CurrentUser%]']", "[%CurrentUser%]"}, + {"quoted value on an Integer", "[Cnt = '5']", "Integer"}, + {"enumeration key is Red", "[Status = 'red']", "Red, Grn"}, + {"entity stores no owner", "[System.owner = '[%CurrentUser%]']", "does not store owner"}, + {"association without its module", "[E_Kid/M.Kid/Name = 'a']", "M.E_Kid"}, + // their measured siblings + {"$currentUser", "[M.E_User = $currentUser]", "$currentUser"}, + {"a variable path", "[Title = $currentObject/Title]", "$currentObject"}, + {"!= empty on an association", "[M.E_Kid != empty]", "M.E_Kid != empty"}, + {"user token on an Integer", "[Cnt = '[%CurrentUser%]']", "[%CurrentUser%]"}, + {"user token on an enumeration", "[Status = '[%CurrentUser%]']", "[%CurrentUser%]"}, + {"quoted value, reversed", "['5' = Cnt]", "Integer"}, + {"quoted value on a Long", "[Big = 'abc']", "Long"}, + {"quoted value on a Decimal", "[Amt = '5.5']", "Decimal"}, + {"quoted value in an ordering", "[Cnt > '5']", "Integer"}, + {"boolean on an Integer", "[Cnt = true()]", "Integer"}, + {"number on a String", "[Title = 5]", "String"}, + {"boolean on a String", "[Title = true()]", "String"}, + {"non-boolean string on a Boolean", "[Flag = 'yes']", "Boolean"}, + {"number on a Boolean", "[Flag = 1]", "Boolean"}, + {"number on a DateTime", "[D = 5]", "DateTime"}, + {"caption, not key", "[Status = 'Green']", "Red, Grn"}, + {"empty key", "[Status = '']", "Red, Grn"}, + {"number on an enumeration", "[Status = 5]", "M.Color"}, + {"inside an and", "[Title = 'a' and Cnt = '5']", "Integer"}, + } + ctx := accessRuleCtx(t) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + src := accessRuleDecls + "grant read * on entity M.E to M.User where " + c.where + ";" + got := strings.Join(accessRuleProblems(t, ctx, src), "\n") + if !strings.Contains(got, c.want) || !strings.Contains(got, "CE0161") { + t.Errorf("%s: want a CE0161 report naming %q, got:\n%s", c.where, c.want, got) + } + }) + } +} + +// The legal twins: each measured at 0 errors on the same app. +func TestAccessRuleXPath_LegalTwinsPass(t *testing.T) { + cases := []struct{ entity, where string }{ + {"M.E", "[M.E_User = '[%CurrentUser%]']"}, + {"M.E", "[not(M.E_Kid/M.Kid)]"}, + {"M.E", "[M.E_Kid/M.Kid/Name = 'a']"}, + {"M.O", "[System.owner = '[%CurrentUser%]']"}, + {"M.E", "[Cnt = 5]"}, + {"M.E", "[Cnt = 5.5]"}, + {"M.E", "[Cnt = -5]"}, + {"M.E", "[Cnt < 5 and Cnt >= -3]"}, + {"M.E", "[Big = 5.5]"}, + {"M.E", "[Amt = 5]"}, + {"M.E", "[Title = '5']"}, + {"M.E", "[Title = '']"}, + {"M.E", "[Flag = 'true']"}, + {"M.E", "[Flag = 'TRUE']"}, + {"M.E", "[Flag = 'false']"}, + {"M.E", "[Flag = true]"}, + {"M.E", "[Flag = false()]"}, + {"M.E", "[D = '2020-01-01']"}, + {"M.E", "[D = 'abc']"}, + {"M.E", "[D = '[%CurrentDateTime%]']"}, + {"M.E", "[D > '[%BeginOfCurrentDay%]']"}, + {"M.E", "[Status = 'Red']"}, + {"M.E", "[Status = 'Red' or Status = 'Grn']"}, + {"M.E", "[Title = empty]"}, + {"M.E", "[Cnt = empty]"}, + {"M.E", "[Status = empty]"}, + {"M.E", "[Status = 'Red' and Cnt = 5 and Flag = 'false' and D = '2020-01-01' and Title = 'x']"}, + // Not established, so not judged: a token other than CurrentUser on a + // number (measured CE0161, but the token vocabulary is not modelled), + // and an attribute reached over a path. + {"M.E", "[Cnt = '[%DayLength%]']"}, + {"M.E", "[M.E_Kid/M.Kid/Name = 5]"}, + } + ctx := accessRuleCtx(t) + for _, c := range cases { + t.Run(c.where, func(t *testing.T) { + src := accessRuleDecls + "grant read * on entity " + c.entity + " to M.User where " + c.where + ";" + if got := accessRuleProblems(t, ctx, src); len(got) != 0 { + t.Errorf("%s: a constraint mxbuild accepts was refused:\n%s", c.where, strings.Join(got, "\n")) + } + }) + } +} + +// The same rules against a STORED entity and enumeration, which the project +// answers for rather than the script. +func TestAccessRuleXPath_StoredEntity(t *testing.T) { + mod := mkModule("M") + enum := &model.Enumeration{BaseElement: model.BaseElement{ID: model.ID("en-color")}, ContainerID: mod.ID, Name: "Color", + Values: []model.EnumerationValue{{Name: "Red"}, {Name: "Grn"}}} + e := &domainmodel.Entity{BaseElement: model.BaseElement{ID: model.ID("e-e")}, Name: "E", Persistable: true, + Attributes: []*domainmodel.Attribute{ + {Name: "Cnt", Type: &domainmodel.IntegerAttributeType{}}, + {Name: "Status", Type: &domainmodel.EnumerationAttributeType{EnumerationRef: "M.Color"}}, + }} + dm := &domainmodel.DomainModel{BaseElement: model.BaseElement{ID: model.ID("dm-m")}, ContainerID: mod.ID, + Entities: []*domainmodel.Entity{e}} + h := mkHierarchy(mod) + withContainer(h, dm.ID, mod.ID) + withContainer(h, enum.ID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleByNameFunc: func(string) (*model.Module, error) { return mod, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return []*domainmodel.DomainModel{dm}, nil }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return []*model.Enumeration{enum}, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + for where, want := range map[string]string{ + "[Cnt = '5']": "Integer", + "[Status = 'red']": "Red, Grn", + "[Status = 'Red']": "", + "[Cnt = 5]": "", + } { + t.Run(where, func(t *testing.T) { + got := strings.Join(accessRuleProblems(t, ctx, + "create module role M.User;\ngrant read * on entity M.E to M.User where "+where+";"), "\n") + if want == "" && got != "" { + t.Errorf("%s: refused a legal constraint:\n%s", where, got) + } + if want != "" && (!strings.Contains(got, want) || !strings.Contains(got, "CE0161")) { + t.Errorf("%s: want a CE0161 report naming %q, got:\n%s", where, want, got) + } + }) + } +} diff --git a/mdl/executor/validate_grant_refs.go b/mdl/executor/validate_grant_refs.go index 663227c26..00d6ba3b6 100644 --- a/mdl/executor/validate_grant_refs.go +++ b/mdl/executor/validate_grant_refs.go @@ -38,7 +38,7 @@ func validateGrantReferences(ctx *ExecContext, prog *ast.Program) []error { if prog == nil || !ctx.Connected() { return nil } - g := &grantResolver{ctx: ctx, sc: newScriptContext(), whole: newScriptContext()} + g := &grantResolver{ctx: ctx, sc: newScriptContext(), whole: newScriptContext(), prog: prog} g.whole.collectDefinitions(prog) g.scriptModuleRoles, g.scriptUserRoles = map[string]bool{}, map[string]bool{} g.laterModuleRoles, g.laterUserRoles = map[string]bool{}, map[string]bool{} @@ -94,6 +94,12 @@ type grantResolver struct { entities map[string]bool docs map[string]map[string]bool xpath *grantXPathResolver // built on the first WHERE + + // prog, and the enumerations it creates or changes, for the access + // rule's literal check (validate_access_rule_xpath.go). + prog *ast.Program + scriptEnums map[string][]string + unjudgedEnums map[string]bool } func (g *grantResolver) recordLater(stmt ast.Statement) { @@ -182,7 +188,13 @@ func (g *grantResolver) check(stmt ast.Statement) []string { roles(s.Roles, true) if entityOK { add(g.memberProblem(s.Entity, s.Rights)) - add(g.xpathProblem(s.Entity, s.XPathConstraint)) + if p := g.xpathProblem(s.Entity, s.XPathConstraint); p != "" { + add(p) + } else { + // The form rules (ako/mxcli#2099), once the members resolve: + // a CE1613 standing hides every CE0161. + out = append(out, g.xpathFormProblems(s.Entity, s.XPathConstraint)...) + } } case *ast.RevokeEntityAccessStmt: g.entityTargetProblem(s.Entity, &out) diff --git a/mdl/executor/validate_security.go b/mdl/executor/validate_security.go index 6063eb212..2ef0c0081 100644 --- a/mdl/executor/validate_security.go +++ b/mdl/executor/validate_security.go @@ -92,5 +92,7 @@ func ValidateGrantEntityAccess(stmt *ast.GrantEntityAccessStmt) []linter.Violati } } } + // The constraint's no-project form rules (ako/mxcli#2099). + out = append(out, accessRuleXPathFormViolations(stmt, loc)...) return out } From de7e18d10981979608292297260517b91f1564c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:30:46 +0000 Subject: [PATCH 12/26] fix: alter workflow reports a user task left with one outcome holding activities (CE1876) create workflow refused a user task whose only outcome holds activities (MDL-WF02, CE1876), but alter workflow skipped the rule because a later op could still add the second outcome - so no alter was ever judged. Dropping a stored task's other outcome, or inserting such a task, passed check -p and exec and then failed mx check with CE1876. MDL-WF02 is now judged over the whole script (ValidateProgram, so check and exec's preflight both run it): each workflow's user tasks are replayed through the script's ALTER statements from the script's own CREATE, else the stored workflow (-p), else only the inserted tasks. A later op or statement that adds an outcome, empties the remaining one or drops the task silences it; anything the model cannot follow (@N, ambiguous or unresolved refs) makes the task unknown and silent. Fixes ako/mxcli#2097 (mirror of mendixlabs/mxcli#1798). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- ...-alter-workflow-single-outcome-ce1876.json | 1 + ...097-alter-workflow-single-outcome.fail.mdl | 54 ++ .../2097-alter-workflow-single-outcome.mdl | 83 +++ mdl/executor/rule_id_uniqueness_test.go | 4 + mdl/executor/validate_program.go | 6 + mdl/executor/validate_workflow.go | 4 +- .../validate_workflow_single_outcome.go | 512 ++++++++++++++++++ ...workflow_alter_single_outcome_2097_test.go | 177 ++++++ 8 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-workflow-single-outcome-ce1876.json create mode 100644 mdl-examples/bug-tests/2097-alter-workflow-single-outcome.fail.mdl create mode 100644 mdl-examples/bug-tests/2097-alter-workflow-single-outcome.mdl create mode 100644 mdl/executor/validate_workflow_single_outcome.go create mode 100644 mdl/executor/workflow_alter_single_outcome_2097_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-workflow-single-outcome-ce1876.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-workflow-single-outcome-ce1876.json new file mode 100644 index 000000000..7c983d4f6 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-workflow-single-outcome-ce1876.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"`alter workflow` leaving a user task with one outcome that holds activities (dropping its other outcome, or inserting such a task) passed `check -p` and `exec`; mx check 11.14.0 then CE1876 \"Single outcome must not contain any activities in its flow.\" — while `create workflow` refused the same shape as MDL-WF02","cause":"ValidateAlterWorkflow skipped WF02 deliberately because a later op can still add the second outcome, so no alter was ever judged — a correct concern answered by not checking at all","file":"mdl/executor/validate_workflow_single_outcome.go (validateAlterWorkflowSingleOutcomes, wired in ValidateProgram)","insight":"When a per-statement rule is skipped because 'a later op may repair it', the fix is to move the rule to the scope where the repair is visible — a script-level replay over a small model of the affected state — not to keep skipping it. Baseline preference: the script's own CREATE (makes it decidable without -p, so the corpus can carry a .fail.mdl), then the stored document, then nothing (only inserted tasks known → a stored-task drop stays silent). Every op the model cannot follow (@N, an unresolved ref that may be a builder-generated name, an ambiguous name) marks the task unknown rather than guessing; that keeps the expensive direction (a false refusal now blocks exec) closed. Measured on 11.14.0 with two copies: drop -> CE1876; drop then `insert into Review { outcomes 'Escalate' { } }` in a later statement -> 0 errors.","refs":["ako/mxcli#2097","mendixlabs/mxcli#1798"],"ce":["CE1876"],"rules":["MDL-WF02"]} diff --git a/mdl-examples/bug-tests/2097-alter-workflow-single-outcome.fail.mdl b/mdl-examples/bug-tests/2097-alter-workflow-single-outcome.fail.mdl new file mode 100644 index 000000000..a46c5f2ca --- /dev/null +++ b/mdl-examples/bug-tests/2097-alter-workflow-single-outcome.fail.mdl @@ -0,0 +1,54 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#2097 (mendixlabs/mxcli#1798) — the refused side. +-- ============================================================================ +-- +-- Dropping 'Reject' leaves Review with one outcome, 'Approve', that holds an +-- activity; nothing later adds a second one. mx check 11.14.0 on the same +-- shape: [error] [CE1876] "Single outcome must not contain any activities in +-- its flow." at User task 'Review'. +-- +-- The script creates the workflow itself, so this is decided without a +-- project; against a STORED workflow the same drop needs `-p`, since only the +-- project knows the task's other outcomes. +-- +-- EXPECTED: `mxcli check` fails with MDL-WF02. +-- Legal side: 2097-alter-workflow-single-outcome.mdl +-- ============================================================================ + +create module BugSingleOutcomeFail2097; + +create persistent entity BugSingleOutcomeFail2097.Request ( + Title: String(200) +); + +create page BugSingleOutcomeFail2097.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve') + } +}; + +create microflow BugSingleOutcomeFail2097.ACT_Notify ($WorkflowContext: BugSingleOutcomeFail2097.Request) +begin + log info node 'WF' 'notified'; +end; + +create workflow BugSingleOutcomeFail2097.WF + parameter $WorkflowContext: BugSingleOutcomeFail2097.Request +begin + user task Review 'Review' + page BugSingleOutcomeFail2097.WF_Review + outcomes + 'Approve' { call microflow BugSingleOutcomeFail2097.ACT_Notify; } + 'Reject' { }; +end workflow; + +alter workflow BugSingleOutcomeFail2097.WF { + drop Review outcome 'Reject' +}; diff --git a/mdl-examples/bug-tests/2097-alter-workflow-single-outcome.mdl b/mdl-examples/bug-tests/2097-alter-workflow-single-outcome.mdl new file mode 100644 index 000000000..eb2bcc0ea --- /dev/null +++ b/mdl-examples/bug-tests/2097-alter-workflow-single-outcome.mdl @@ -0,0 +1,83 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#2097 (mendixlabs/mxcli#1798) — "`create workflow` refuses a user +-- task whose only outcome holds activities (MDL-WF02, CE1876), but `alter +-- workflow` does not." +-- ============================================================================ +-- +-- Dropping a stored task's other outcomes so one outcome with activities +-- remains, or inserting a task whose single outcome holds an activity, passed +-- `check -p` and `exec`; mx check (11.14.0) then reported +-- [error] [CE1876] "Single outcome must not contain any activities in its +-- flow." at User task 'Review' +-- +-- MDL-WF02 now judges the state the SCRIPT leaves, over every ALTER WORKFLOW +-- in it. This file is the legal side: each shape below passes through a +-- single outcome holding an activity, and a later op — in the same statement +-- or a later statement — adds the second outcome, so nothing is reported. +-- The refused side is 2097-alter-workflow-single-outcome.fail.mdl. +-- +-- Measured on 11.14.0 (blank app, Review with 'Approve' { call microflow } and +-- 'Reject' { }): `drop Review outcome 'Reject'` -> CE1876 at User task +-- 'Review'; the same drop followed by `insert into Review { outcomes +-- 'Escalate' { } }` -> 0 errors. Inserting a task whose one outcome holds a +-- call microflow -> CE1876 at that task. +-- +-- EXPECTED: `mxcli check` passes this file (no MDL-WF02). +-- Guard: mdl/executor/workflow_alter_single_outcome_2097_test.go +-- ============================================================================ + +create module BugSingleOutcome2097; + +create persistent entity BugSingleOutcome2097.Request ( + Title: String(200) +); + +create page BugSingleOutcome2097.WF_Review +( + params: ( $WorkflowUserTask: System.WorkflowUserTask ), + title: 'Review', + layout: Atlas_Core.Atlas_Default +) +{ + dataview dvTask (DataSource: $WorkflowUserTask) { + actionbutton btnApprove (Caption: 'Approve', Action: complete task 'Approve') + actionbutton btnEscalate (Caption: 'Escalate', Action: complete task 'Escalate') + } +}; + +create microflow BugSingleOutcome2097.ACT_Notify ($WorkflowContext: BugSingleOutcome2097.Request) +begin + log info node 'WF' 'notified'; +end; + +create workflow BugSingleOutcome2097.WF + parameter $WorkflowContext: BugSingleOutcome2097.Request +begin + user task Review 'Review' + page BugSingleOutcome2097.WF_Review + outcomes + 'Approve' { call microflow BugSingleOutcome2097.ACT_Notify; } + 'Reject' { }; +end workflow; + +-- Drop 'Reject': for now 'Approve' is alone and holds ACT_Notify ... +alter workflow BugSingleOutcome2097.WF { + drop Review outcome 'Reject' +}; + +-- ... and a later statement adds the second outcome back. +alter workflow BugSingleOutcome2097.WF { + insert into Review { outcomes 'Escalate' { } } +}; + +-- An inserted task with one outcome holding an activity, given its second +-- outcome by a later op of the same statement. +alter workflow BugSingleOutcome2097.WF { + insert after Review { + user task Verify 'Verify' + page BugSingleOutcome2097.WF_Review + outcomes 'Approve' { call microflow BugSingleOutcome2097.ACT_Notify; }; + }; + insert into Verify { outcomes 'Escalate' { } } +}; diff --git a/mdl/executor/rule_id_uniqueness_test.go b/mdl/executor/rule_id_uniqueness_test.go index 72163e413..d46ee7fd2 100644 --- a/mdl/executor/rule_id_uniqueness_test.go +++ b/mdl/executor/rule_id_uniqueness_test.go @@ -52,6 +52,10 @@ var ruleIDsSharedDeliberately = map[string]string{ "`empty` compared with an association. validate_microflow.go asks it of a retrieve, " + "validate_widgets.go of a database data source, validate_access_rule_xpath.go of an " + "access rule (ako/mxcli#2099). Someone suppressing MDL047 means every slot", + "MDL-WF02": "one rule, two sites: a user task with a single outcome holding activities " + + "(CE1876). validate_workflow.go judges a CREATE body; validate_workflow_single_outcome.go " + + "judges the state a script's ALTER WORKFLOW statements leave, which a later op can repair. " + + "Someone suppressing MDL-WF02 means both (ako/mxcli#2097)", "MDL059": "one rule, two sites: an annotation that parses and does nothing. " + "validate_flow_parameters.go covers one written on a PARAMETER, " + "validate_document_annotations.go one written before a CREATE. Someone " + diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index f1587ef60..0641e54a9 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -197,6 +197,12 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { } } + // MDL-WF02 for ALTER WORKFLOW: a user task the script leaves with one outcome + // holding activities (CE1876). Judged over the whole script, since a later + // statement may add the second outcome; a stored task's outcomes need the + // project (ako/mxcli#2097, mendixlabs/mxcli#1798). + violations = append(violations, validateAlterWorkflowSingleOutcomes(prog, project)...) + // Check for intra-script duplicate definitions (CREATE X … CREATE X without DROP) violations = append(violations, CheckScriptDuplicates(prog)...) diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index c24c3af13..fa46dc30e 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -370,7 +370,9 @@ func workflowActivityBoundaryEvents(a ast.WorkflowActivityNode) []ast.WorkflowBo // does, so guarding only CREATE would leave each one a keyword away. // // The other rules are not simply un-ported. MDL-WF01/WF02 describe a state a -// later op in the same script can still repair (`SET ACTIVITY … PAGE`), and +// later op in the same script can still repair (`SET ACTIVITY … PAGE`, a second +// `outcomes`) — so WF02 is judged over the whole script instead, by +// validateAlterWorkflowSingleOutcomes (ako/mxcli#2097) — and // MDL-WF05 resolves jump targets against activities the statement cannot see. // An inserted activity's outcome SET, by contrast, is complete where it is // written — `INSERT OUTCOME` cannot extend it, since on a decision it writes a diff --git a/mdl/executor/validate_workflow_single_outcome.go b/mdl/executor/validate_workflow_single_outcome.go new file mode 100644 index 000000000..4c666a9de --- /dev/null +++ b/mdl/executor/validate_workflow_single_outcome.go @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: Apache-2.0 + +// MDL-WF02 for ALTER WORKFLOW: a user task left with one outcome that holds +// activities (CE1876, "Single outcome must not contain any activities in its +// flow."). +// +// CREATE judges this per statement (ValidateWorkflow). ALTER cannot: a later op +// in the same statement, or a later statement in the script, may still add the +// second outcome — which is why ValidateAlterWorkflow skipped the rule, and why +// no alter was ever judged (ako/mxcli#2097, mendixlabs/mxcli#1798). This pass +// replays the script's workflow statements over a small model of each workflow's +// user tasks and judges the state the SCRIPT leaves. +// +// The baseline of a workflow is, in order of preference: the script's own +// CREATE of it; the stored workflow, when a project is given; nothing. With no +// baseline only the tasks the script inserts are known, so dropping a stored +// task's outcome without a project says nothing — the third state of +// check-mxbuild-drift: what cannot be established is not reported. +package executor + +import ( + "fmt" + "reflect" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// soActivity is one activity of an outcome's flow, as far as this rule needs it: +// the two names a reference can match. opaque marks an authored activity +// written without a name — the builder generates one, so a later reference may +// name it without this model being able to tell. +type soActivity struct { + name, caption string + opaque bool +} + +func (a soActivity) matches(ref string) bool { + return ref != "" && (a.name == ref || a.caption == ref) +} + +type soOutcome struct { + value string + top []soActivity // the flow's own activities, in order + inner []soActivity // every activity nested below them +} + +type soTask struct { + name, caption string + outcomes []*soOutcome + // touched is set once an ALTER changed the task (or inserted it); only + // those are judged — an untouched CREATE task is ValidateWorkflow's. + touched bool + // unknown is set when an op may have changed the task in a way this model + // cannot follow; the task is then never reported. + unknown bool + removed bool +} + +func (t *soTask) label() string { + if t.name != "" { + return "'" + t.name + "'" + } + if t.caption != "" { + return "'" + t.caption + "'" + } + return "(unnamed)" +} + +// violates mirrors ValidateWorkflow's condition: exactly one outcome, holding +// at least one activity. +func (t *soTask) violates() bool { + return len(t.outcomes) == 1 && len(t.outcomes[0].top) > 0 +} + +type soWorkflow struct { + name ast.QualifiedName + tasks []*soTask +} + +// validateAlterWorkflowSingleOutcomes is the script-level MDL-WF02 for ALTER. +func validateAlterWorkflowSingleOutcomes(prog *ast.Program, project func() backend.FullBackend) []linter.Violation { + if prog == nil { + return nil + } + hasAlter := false + for _, s := range prog.Statements { + if _, ok := s.(*ast.AlterWorkflowStmt); ok { + hasAlter = true + break + } + } + if !hasAlter { + return nil + } + + var ctx *ExecContext + ctxLoaded := false + stored := func(qn ast.QualifiedName) *workflows.Workflow { + if !ctxLoaded { + ctxLoaded = true + if project != nil { + if b := project(); b != nil { + ctx = &ExecContext{Backend: b} + } + } + } + return findStoredWorkflow(ctx, qn) + } + + sims := map[string]*soWorkflow{} + var order []string + key := func(qn ast.QualifiedName) string { return strings.ToLower(qn.String()) } + put := func(k string, w *soWorkflow) { + if _, seen := sims[k]; !seen { + order = append(order, k) + } + sims[k] = w + } + + for _, s := range prog.Statements { + switch st := s.(type) { + case *ast.CreateWorkflowStmt: + w := &soWorkflow{name: st.Name} + w.addTasks(st.Activities, false) + for _, esp := range st.EventSubProcesses { + w.addTasks(esp.Activities, false) + } + put(key(st.Name), w) + case *ast.DropWorkflowStmt: + if _, ok := sims[key(st.Name)]; ok { + sims[key(st.Name)] = nil + } + case *ast.AlterWorkflowStmt: + k := key(st.Name) + w, seen := sims[k] + if !seen { + w = &soWorkflow{name: st.Name} + if wf := stored(st.Name); wf != nil { + w.addStoredTasks(wf.Flow) + } + put(k, w) + } + if w == nil { // dropped earlier in the script; exec will say so + continue + } + for _, op := range st.Operations { + w.apply(op) + } + } + } + + var out []linter.Violation + for _, k := range order { + w := sims[k] + if w == nil { + continue + } + loc := workflowLocation(w.name) + for _, t := range w.tasks { + if t.removed || t.unknown || !t.touched || !t.violates() { + continue + } + out = append(out, linter.Violation{ + RuleID: "MDL-WF02", + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("after the script's ALTER WORKFLOW statements, user task %s has a single outcome ('%s') "+ + "with nested activities — MxBuild rejects this (CE1876 \"Single outcome must not contain any activities in its flow.\")", + t.label(), t.outcomes[0].value), + Suggestion: "Keep (or add) a second outcome on the task — `insert into " + strings.Trim(t.label(), "'") + + " { outcomes '' { } }` — or move the outcome's activities to the flow after the task.", + }) + } + } + return out +} + +// addTasks records every user task in an authored body, at any depth. +func (w *soWorkflow) addTasks(acts []ast.WorkflowActivityNode, touched bool) { + walkWorkflowActivities(acts, func(a ast.WorkflowActivityNode) { + n, ok := a.(*ast.WorkflowUserTaskNode) + if !ok { + return + } + t := &soTask{name: n.Name, caption: n.Caption, touched: touched} + for _, o := range n.Outcomes { + t.outcomes = append(t.outcomes, authoredOutcome(o.Caption, o.Activities)) + } + w.tasks = append(w.tasks, t) + }) +} + +// authoredOutcome models an outcome the script writes. Its value is the +// caption, as buildWorkflowUserTask and the mutator's InsertOutcome store it. +func authoredOutcome(value string, acts []ast.WorkflowActivityNode) *soOutcome { + o := &soOutcome{value: value} + for _, a := range acts { + if a == nil { + continue + } + o.top = append(o.top, authoredRef(a)) + } + walkWorkflowActivities(acts, func(a ast.WorkflowActivityNode) { o.inner = append(o.inner, authoredRef(a)) }) + return o +} + +// authoredRef reads an authored activity's Name and Caption. Every activity +// node spells them as those two string fields where it has them; reading them +// by field name keeps this from drifting as activity kinds are added. +func authoredRef(a ast.WorkflowActivityNode) soActivity { + v := reflect.ValueOf(a) + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return soActivity{opaque: true} + } + v = v.Elem() + } + str := func(field string) string { + if v.Kind() != reflect.Struct { + return "" + } + f := v.FieldByName(field) + if f.IsValid() && f.Kind() == reflect.String { + return f.String() + } + return "" + } + r := soActivity{name: str("Name"), caption: str("Caption")} + r.opaque = r.name == "" + return r +} + +// addStoredTasks records every user task in a stored flow, at any depth. +func (w *soWorkflow) addStoredTasks(flow *workflows.Flow) { + if flow == nil { + return + } + for _, a := range flow.Activities { + if a == nil { + continue + } + if ut, ok := a.(*workflows.UserTask); ok { + t := &soTask{name: ut.Name, caption: ut.Caption} + for _, o := range ut.Outcomes { + if o == nil { + continue + } + so := &soOutcome{value: o.Value} + if o.Flow != nil { + for _, x := range o.Flow.Activities { + if x != nil && !storedFlowMarker(x) { + so.top = append(so.top, soActivity{name: x.GetName(), caption: x.GetCaption()}) + } + } + collectStoredRefs(o.Flow, &so.inner) + } + t.outcomes = append(t.outcomes, so) + } + w.tasks = append(w.tasks, t) + } + for _, f := range storedNestedFlows(a) { + w.addStoredTasks(f) + } + } +} + +// storedFlowMarker reports the activities a flow stores as structure rather +// than as work: they are not "activities in its flow" for CE1876. +func storedFlowMarker(a workflows.WorkflowActivity) bool { + switch a.(type) { + case *workflows.StartWorkflowActivity, *workflows.EndOfParallelSplitPathActivity, *workflows.EndOfBoundaryEventPathActivity: + return true + } + return false +} + +func collectStoredRefs(flow *workflows.Flow, out *[]soActivity) { + if flow == nil { + return + } + for _, a := range flow.Activities { + if a == nil { + continue + } + *out = append(*out, soActivity{name: a.GetName(), caption: a.GetCaption()}) + for _, f := range storedNestedFlows(a) { + collectStoredRefs(f, out) + } + } +} + +// resolveTask finds the task an op addresses the way the mutator does: a Name +// match wins over caption matches, @N picks among all matches. ok is false +// when the reference is ambiguous or the position is out of range — the +// caller then treats every candidate as unknown rather than guessing. +func (w *soWorkflow) resolveTask(ref string, atPos int) (task *soTask, candidates []*soTask, ok bool) { + var all, named []*soTask + for _, t := range w.tasks { + if t.removed { + continue + } + if t.name == ref || (ref != "" && t.caption == ref) { + all = append(all, t) + if t.name == ref { + named = append(named, t) + } + } + } + switch { + case atPos > 0: + // The model's order is not guaranteed to be DESCRIBE's for inserted + // tasks, so @N is not resolved here. + return nil, all, false + case len(named) == 1: + return named[0], all, true + case len(named) == 0 && len(all) == 1: + return all[0], all, true + } + return nil, all, false +} + +func markUnknown(ts []*soTask) { + for _, t := range ts { + t.unknown = true + } +} + +// apply plays one ALTER op over the model, in the mutator's order. +func (w *soWorkflow) apply(op ast.AlterWorkflowOp) { + switch o := op.(type) { + case *ast.InsertAfterOp: + w.insertAt(o.ActivityRef, o.NewActivities) + case *ast.InsertBeforeOp: + w.insertAt(o.ActivityRef, o.NewActivities) + case *ast.ReplaceActivityOp: + w.replaceActivity(o.ActivityRef, o.AtPosition, o.NewActivities) + case *ast.DropActivityOp: + w.dropActivity(o.ActivityRef, o.AtPosition) + case *ast.InsertPathOp: + w.addTasks(o.Activities, true) + case *ast.InsertBranchOp: + w.addTasks(o.Activities, true) + case *ast.InsertBoundaryEventOp: + w.addTasks(o.Activities, true) + case *ast.InsertOutcomeOp: + t, cands, ok := w.resolveTask(o.ActivityRef, o.AtPosition) + switch { + case ok: + t.outcomes = append(t.outcomes, authoredOutcome(o.OutcomeName, o.Activities)) + t.touched = true + case len(cands) > 0: + markUnknown(cands) + default: + // A task this model does not know (an unnamed inserted task gets + // a generated name): any inserted task might be it. + w.markInsertedUnknown() + } + w.addTasks(o.Activities, true) + case *ast.DropOutcomeOp: + t, cands, ok := w.resolveTask(o.ActivityRef, o.AtPosition) + if !ok { + markUnknown(cands) + return + } + at, n := -1, 0 + for i, oc := range t.outcomes { + if oc.value == o.OutcomeName { + if at < 0 { + at = i + } + n++ + } + } + if n != 1 { // the mutator refuses both; exec will say so + return + } + t.outcomes = append(t.outcomes[:at], t.outcomes[at+1:]...) + t.touched = true + } +} + +// insertAt records what an insert next to ref adds: its tasks, and — when ref +// sits in a task's outcome — the outcome's extra activities. +func (w *soWorkflow) insertAt(ref string, acts []ast.WorkflowActivityNode) { + for _, t := range w.tasks { + if t.removed { + continue + } + for _, o := range t.outcomes { + for _, a := range o.top { + if a.matches(ref) { + for _, x := range acts { + if x != nil { + o.top = append(o.top, authoredRef(x)) + } + } + } + } + } + } + w.addTasks(acts, true) +} + +// dropActivity removes the activity ref names: a task, or an activity of an +// outcome. Ambiguity or a nested match makes the affected tasks unknown. +func (w *soWorkflow) dropActivity(ref string, atPos int) { + if t, cands, ok := w.resolveTask(ref, atPos); ok { + t.removed = true + w.removeNestedTasks(t) + return + } else if len(cands) > 0 { + markUnknown(cands) + return + } + if !w.editOutcomeActivity(ref, atPos, nil) { + w.markOpaqueUnknown() + } +} + +// replaceActivity is REPLACE: the task ref names goes and the new activities +// take its place; an outcome activity is swapped in place, so the outcome +// keeps whatever the replacement holds. +func (w *soWorkflow) replaceActivity(ref string, atPos int, acts []ast.WorkflowActivityNode) { + if t, cands, ok := w.resolveTask(ref, atPos); ok { + t.removed = true + w.removeNestedTasks(t) + } else if len(cands) > 0 { + markUnknown(cands) + } else { + var repl []soActivity + for _, x := range acts { + if x != nil { + repl = append(repl, authoredRef(x)) + } + } + if !w.editOutcomeActivity(ref, atPos, repl) { + w.markOpaqueUnknown() + } + } + w.addTasks(acts, true) +} + +// editOutcomeActivity replaces the outcome activity ref names with repl (nil +// removes it). It reports whether ref named one this model knows; with @N the +// owning task becomes unknown rather than guessing which match is meant. +func (w *soWorkflow) editOutcomeActivity(ref string, atPos int, repl []soActivity) bool { + found := false + for _, t := range w.tasks { + if t.removed { + continue + } + for _, o := range t.outcomes { + for i, a := range o.top { + if !a.matches(ref) { + continue + } + found = true + if atPos > 0 { + t.unknown = true + break + } + next := append([]soActivity{}, o.top[:i]...) + next = append(next, repl...) + o.top = append(next, o.top[i+1:]...) + break + } + } + } + return found +} + +// markOpaqueUnknown makes unknown every task with an unnamed outcome activity: +// a reference this model could not resolve may be that activity's generated +// name. +func (w *soWorkflow) markOpaqueUnknown() { + for _, t := range w.tasks { + for _, o := range t.outcomes { + for _, a := range o.top { + if a.opaque { + t.unknown = true + } + } + } + } +} + +// removeNestedTasks drops the tasks that lived in a removed task's outcomes. +func (w *soWorkflow) removeNestedTasks(parent *soTask) { + for _, o := range parent.outcomes { + for _, ref := range o.inner { + for _, t := range w.tasks { + if t != parent && !t.removed && ref.matches(t.name) { + t.removed = true + } + } + } + } +} + +// markInsertedUnknown makes every task the script inserted without a name +// unknown: it may be the target of an op this model could not resolve. +func (w *soWorkflow) markInsertedUnknown() { + for _, t := range w.tasks { + if t.touched && t.name == "" { + t.unknown = true + } + } +} diff --git a/mdl/executor/workflow_alter_single_outcome_2097_test.go b/mdl/executor/workflow_alter_single_outcome_2097_test.go new file mode 100644 index 000000000..d386e62c3 --- /dev/null +++ b/mdl/executor/workflow_alter_single_outcome_2097_test.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// ako/mxcli#2097 (mendixlabs/mxcli#1798): `create workflow` refuses a user task +// whose only outcome holds activities (MDL-WF02, CE1876), but `alter workflow` +// did not — dropping a stored task's other outcomes, or inserting a task whose +// single outcome holds an activity, passed `check -p` and `exec`, and mx check +// (11.14.0) then reported +// +// [error] [CE1876] "Single outcome must not contain any activities in its flow." at User task 'Review' +// +// The rule now judges the state the SCRIPT leaves: a later op in the statement +// or a later statement that adds the second outcome silences it, and so does a +// state it cannot establish (no project, task not found). + +// singleOutcomeProject serves Sales.WF holding user task Review with outcomes +// Approve { call microflow Notify } and Reject { }. +func singleOutcomeProject(t *testing.T) func() backend.FullBackend { + t.Helper() + mod := mkModule("Sales") + wf := mkWorkflow(mod.ID, "WF") + notify := &workflows.CallMicroflowTask{BaseWorkflowActivity: workflows.BaseWorkflowActivity{ + BaseElement: model.BaseElement{ID: "a2"}, Name: "Notify", Caption: "Notify"}} + wf.Flow = &workflows.Flow{Activities: []workflows.WorkflowActivity{ + &workflows.UserTask{ + BaseWorkflowActivity: workflows.BaseWorkflowActivity{ + BaseElement: model.BaseElement{ID: "a1"}, Name: "Review", Caption: "Review"}, + Outcomes: []*workflows.UserTaskOutcome{ + {Value: "Approve", Flow: &workflows.Flow{Activities: []workflows.WorkflowActivity{notify}}}, + {Value: "Reject"}, + }, + }, + }} + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListWorkflowsFunc: func() ([]*workflows.Workflow, error) { return []*workflows.Workflow{wf}, nil }, + } + return func() backend.FullBackend { return mb } +} + +func singleOutcomeViolations(t *testing.T, src string, project func() backend.FullBackend) []linter.Violation { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + var out []linter.Violation + for _, v := range validateAlterWorkflowSingleOutcomes(prog, project) { + if v.RuleID == "MDL-WF02" { + out = append(out, v) + } + } + return out +} + +const wf2097Create = `create workflow Sales.WF2 parameter $WorkflowContext: Sales.Req +begin + user task Review 'Review' page Sales.P + outcomes + 'Approve' { call microflow Sales.Notify; } + 'Reject' { }; +end workflow; +` + +func TestAlterWorkflow_SingleOutcomeWithActivities_2097(t *testing.T) { + for _, tc := range []struct { + name string + src string + project bool + want bool + wantInMsg string + }{ + // The reported shape: drop the stored task's other outcome, leaving + // Approve — which holds Notify — alone. + {"drop leaves one outcome with activities", + `alter workflow Sales.WF { drop Review outcome 'Reject' };`, true, true, "'Review'"}, + {"old spelling: drop outcome on", + `alter workflow Sales.WF drop outcome 'Reject' on Review;`, true, true, "'Review'"}, + // Controls. + {"drop leaves one EMPTY outcome", + `alter workflow Sales.WF { drop Review outcome 'Approve' };`, true, false, ""}, + {"a later op in the statement adds the second outcome", + `alter workflow Sales.WF { drop Review outcome 'Reject'; insert into Review { outcomes 'Escalate' { } } };`, true, false, ""}, + {"a later statement adds the second outcome", + `alter workflow Sales.WF { drop Review outcome 'Reject' }; + alter workflow Sales.WF { insert into Review { outcomes 'Escalate' { } } };`, true, false, ""}, + {"a later op empties the remaining outcome", + `alter workflow Sales.WF { drop Review outcome 'Reject'; drop Notify };`, true, false, ""}, + {"a later op drops the task", + `alter workflow Sales.WF { drop Review outcome 'Reject'; drop Review };`, true, false, ""}, + {"a later statement drops the workflow", + `alter workflow Sales.WF { drop Review outcome 'Reject' }; + drop workflow Sales.WF;`, true, false, ""}, + {"no project: the stored outcomes are unknown", + `alter workflow Sales.WF { drop Review outcome 'Reject' };`, false, false, ""}, + {"task not found in the stored workflow", + `alter workflow Sales.WF { drop Missing outcome 'Reject' };`, true, false, ""}, + {"workflow not found in the project", + `alter workflow Sales.Other { drop Review outcome 'Reject' };`, true, false, ""}, + + // An inserted task is decidable from the statement, with or without a project. + {"inserted task with one outcome holding an activity", + `alter workflow Sales.WF { insert after Review { user task Verify 'Check' page Sales.P outcomes 'Done' { call microflow Sales.Notify; }; } };`, + false, true, "'Verify'"}, + {"inserted task, nested in an inserted outcome", + `alter workflow Sales.WF { insert into Review { outcomes 'Escalate' { user task Verify 'Check' page Sales.P outcomes 'Done' { call microflow Sales.Notify; }; } } };`, + true, true, "'Verify'"}, + {"inserted task, second outcome added by a later op", + `alter workflow Sales.WF { insert after Review { user task Verify 'Check' page Sales.P outcomes 'Done' { call microflow Sales.Notify; }; }; + insert into Verify { outcomes 'Skip' { } } };`, false, false, ""}, + {"inserted task with one EMPTY outcome", + `alter workflow Sales.WF { insert after Review { user task Verify 'Check' page Sales.P outcomes 'Done' { }; } };`, false, false, ""}, + + // A workflow the script creates is the baseline, so this needs no project. + {"create then drop the other outcome, no project", + wf2097Create + `alter workflow Sales.WF2 { drop Review outcome 'Reject' };`, false, true, "'Review'"}, + {"create then drop, then re-add, no project", + wf2097Create + `alter workflow Sales.WF2 { drop Review outcome 'Reject' }; + alter workflow Sales.WF2 { insert into Review { outcomes 'Later' { } } };`, false, false, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var project func() backend.FullBackend + if tc.project { + project = singleOutcomeProject(t) + } + got := singleOutcomeViolations(t, tc.src, project) + if (len(got) > 0) != tc.want { + t.Fatalf("MDL-WF02 reported = %v, want %v (%v)", len(got) > 0, tc.want, got) + } + if tc.want { + if len(got) != 1 { + t.Fatalf("want one MDL-WF02, got %d: %v", len(got), got) + } + v := got[0] + if v.Severity != linter.SeverityError || !strings.Contains(v.Message, "CE1876") || !strings.Contains(v.Message, tc.wantInMsg) { + t.Fatalf("unexpected violation: %+v", v) + } + } + }) + } +} + +// The rule is reached from ValidateProgram, which both `check` and `exec`'s +// preflight run — without a project, for a task the statement inserts. +func TestAlterWorkflow_SingleOutcomeWithActivities_ViaValidateProgram_2097(t *testing.T) { + prog, errs := visitor.Build(`alter workflow Sales.WF { insert after Review { user task Verify 'Check' page Sales.P outcomes 'Done' { call microflow Sales.Notify; }; } };`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + if _, ok := prog.Statements[0].(*ast.AlterWorkflowStmt); !ok { + t.Fatalf("not an alter workflow: %T", prog.Statements[0]) + } + n := 0 + for _, v := range ValidateProgram(prog, "") { + if v.RuleID == "MDL-WF02" { + n++ + } + } + if n != 1 { + t.Fatalf("want exactly one MDL-WF02 from ValidateProgram, got %d", n) + } +} From 1edbc6b1873cead3ce0c1acc08376bfaca36042b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:39:09 +0000 Subject: [PATCH 13/26] docs(skill): keep run-local under the 700-line body limit The new timeout flags and the clear-sessions note took run-local/SKILL.md to 713 body lines (TestLargeSkillsWereSplit). The flag rows stay in the docs-site table and --help; the sessions note replaces the line it updated instead of adding a paragraph. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rXm5FHEucVx832Gw6 --- .claude/skills/mendix/run-local/SKILL.md | 43 +++++++++--------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index 60e55a48d..c511fe62f 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -19,20 +19,20 @@ description: "The warm Docker-free dev loop, `mxcli run --local` — model chang | 250-277 | Editing themes (SCSS) — rebuild, never clear caches | | 278-304 | `--watch` waits for a write to finish before it builds | | 305-333 | "My edit didn't show up" — stale process, not stale cache | -| 334-366 | Flags | -| 367-420 | Metrics and OpenTelemetry | -| 421-478 | Pages render in the browser | -| 479-496 | Pixel-perfect page loop | -| 497-517 | Debugging a server-side error | -| 518-552 | "Sign in failed" that is not about the password | -| 553-585 | The same licence also stops the app after a few hours | -| 586-665 | External browser preview (`--hub`) | -| 666-674 | Validation checklist | -| 675-731 | Constant values come from a configuration | -| 732-735 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | -| 736-739 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | -| 740-743 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | -| 744-746 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | +| 334-364 | Flags | +| 365-418 | Metrics and OpenTelemetry | +| 419-476 | Pages render in the browser | +| 477-494 | Pixel-perfect page loop | +| 495-515 | Debugging a server-side error | +| 516-539 | "Sign in failed" that is not about the password | +| 540-572 | The same licence also stops the app after a few hours | +| 573-652 | External browser preview (`--hub`) | +| 653-661 | Validation checklist | +| 662-718 | Constant values come from a configuration | +| 719-722 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | +| 723-726 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | +| 727-730 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | +| 731-733 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | # Warm Local Dev Loop — `mxcli run --local` @@ -341,8 +341,6 @@ run's process session), and `run status` reports them. Do **not** | *(hub API key)* | — | For an **authenticated** hub: get one from `https:///cli`, set `MXCLI_HUB_KEY` (see below) | | `--watch` | off | Rebuild + hot-apply on each change | | `--web-client-timeout` | `$MXCLI_WEB_CLIENT_TIMEOUT`, else `5m` | Limit for one web client bundle build; on timeout the tail of `deployment/log/web-client-build.log` is printed | -| `--build-timeout` | `$MXCLI_BUILD_TIMEOUT`, else `30m` | Limit for one `mxbuild --serve` build. The cold first build of a large app (thousands of documents) can take ten minutes or more | -| `--start-timeout` | `$MXCLI_START_TIMEOUT`, else `15m` | Limit for the runtime start, after-startup microflow and schema update included. Running out says "did not answer", not "is the app running?" | | `--ensure-db` | off | Provision local Postgres + app database if missing | | `--db-admin-user` / `--db-admin-password` | postgres / — | Superuser login for `--ensure-db` when it has a password (Windows); only with `--ensure-db` | | `--app-port` / `--admin-port` / `--serve-port` | 8080 / 8090 / 6543 | Ports | @@ -532,18 +530,7 @@ password in the model. `--screenshot-user` and `mxcli playwright check` do this for you — a rejected sign-in reads the log and says so instead of quietly screenshotting or checking the login page. -The cap is **6**, and the licence counts the `System.Session` rows in the -database. Free every slot without a restart (the app keeps running; signed-in -browsers sign in again): - -```bash -mxcli run clear-sessions -p app.mpr -``` - -Measured on 11.12.1: six sign-ins, the seventh HTTP 560, `clear-sessions`, six -more succeed on the same running app. Restarting `run --local` clears them too -(the runtime deletes old sessions as it starts), at the cost of a boot. -Otherwise sessions are held until they expire. +The cap is 6; `mxcli run clear-sessions -p app.mpr` frees them all at once, no restart. A script that drives the app through a browser should **sign out at the end**, otherwise each run leaks a slot and the fifth or sixth run is the one that fails — which makes it look like a change you just made broke authentication. From e7ec3502f752d92e5165d59334710e3ad68799c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:48:17 +0000 Subject: [PATCH 14/26] pages: bind an association path read from a parameter ($Param/Assoc/Attr) Studio Pro stores an association path read from a snippet or page parameter outside every data container as the association steps beside a SourceVariable naming the parameter (TestApp WorkflowCommons Snip_Workflow_Header, Snip_WorkflowUserTaskView_Details). describe printed the path without its root, so exec wrote it with no source: mx check CE0402 "No value specified." at actionButton1, and check refused the input textBox9 (ako/mxcli#833). - `$Param/Assoc/Attr` (Mendix's expression spelling) now binds as that pair in template parameters and in an input's Attribute, when the root is a parameter or an enclosing data view and there is an association hop. Anything else stays the expression it was (mendixlabs/mxcli#1406). - describe prints the root. - check refuses `{1} = $dv1.Attr` naming a data view that does not enclose the widget (was mx check CE7001). - check and exec refuse a page variable passed to a snippet parameter: page variables are primitive, snippet parameters objects (was CE0115). Measured on TestApp (Mendix 11.14.0): describe -> exec of Snip_Workflow_Header reports it unchanged, mx check 0 errors (was CE0402). The bug-test script is 0 errors; the pre-change binary gives 2x CE0117. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- ...tion-path-rooted-at-snippet-parameter.json | 1 + ...ssociation-path-from-snippet-parameter.mdl | 35 +++++ .../833-snippet-call-page-variable.fail.mdl | 15 +++ ...late-param-non-enclosing-dataview.fail.mdl | 15 +++ mdl/executor/cmd_pages_builder_v3.go | 58 +++++++- mdl/executor/cmd_pages_builder_v3_widgets.go | 21 +-- mdl/executor/cmd_pages_describe_parse.go | 9 ++ mdl/executor/cmd_pages_describe_pluggable.go | 6 + .../cmd_pages_input_binding_context.go | 86 ++++++++++++ mdl/executor/cmd_pages_parameter_binding.go | 48 +++++++ .../cmd_pages_rooted_assoc_path_833_test.go | 127 ++++++++++++++++++ mdl/executor/cmd_pages_template_attr.go | 15 +++ .../cmd_pages_widget_scoped_variable_test.go | 31 +++-- ...lidate_template_param_dataview_833_test.go | 86 ++++++++++++ 14 files changed, 530 insertions(+), 23 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-rooted-at-snippet-parameter.json create mode 100644 mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl create mode 100644 mdl-examples/bug-tests/833-snippet-call-page-variable.fail.mdl create mode 100644 mdl-examples/bug-tests/833-template-param-non-enclosing-dataview.fail.mdl create mode 100644 mdl/executor/cmd_pages_rooted_assoc_path_833_test.go create mode 100644 mdl/executor/validate_template_param_dataview_833_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-rooted-at-snippet-parameter.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-rooted-at-snippet-parameter.json new file mode 100644 index 000000000..3a6f4ad9e --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-rooted-at-snippet-parameter.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"describe of TestApp WorkflowCommons.Snip_Workflow_Header printed CaptionParams ({1} = System.Workflow_ParentWorkflow/Name) without its $Workflow root; exec wrote it with no source and mx check reported CE0402 'No value specified.' at actionButton1. textBox9 of Snip_WorkflowUserTaskView_Details (same shape on an input) was refused by check (MDL-WIDGET34). Also: {1} = $dv1.Name naming a non-enclosing data view was written as {Widget: dv1} (CE7001), and a page variable passed to a snippet parameter was written (CE0115)","cause":"resolveAssociationAttributePath handled only context-relative paths; a $param-rooted path was 'unsupported', and isTemplateExpression treats $Var/... as an expression (mendixlabs/mxcli#1406), whose form for an association path is CE0117. describe printed the association path from AttributeRef without the SourceVariable root. Attribute: $P/Assoc/Attr parses as a DataSourceV3 (ContextVariable), not a string, so input builders never saw it","file":"mdl/executor/cmd_pages_builder_v3.go (resolveRootedAssociationPath, resolveAssociationPathFrom), cmd_pages_template_attr.go (pb.isTemplateExpression), cmd_pages_parameter_binding.go (rootedAssociationPath, inputAttributeText), cmd_pages_describe_pluggable.go, cmd_pages_describe_parse.go, cmd_pages_input_binding_context.go","insight":"Test through the classifier, not the resolver: the first resolver-level test passed while the builder still routed $Var/Assoc/Attr to Expression, and only the TestApp round trip (CE0117) showed it. The spelling $Root/Assoc/Attr was already claimed as an expression by #1406, so the binding wins only when the root resolves (a parameter or enclosing data view) and there is an association hop, mirroring the existing $currentObject/Assoc/Attr exception; everything else stays an expression. The proof was the round trip of Studio Pro's own snippet: 'Unchanged snippet' (byte-identical) and mx check 0 errors, against CE0402 before. The remaining CE1613 on text11/text16 in the same snippet is a separate gap (a specialization DestinationEntity dropped by describe), confirmed pre-existing with the old binary.","refs":["#833"],"ce":["CE0402","CE0117","CE7001","CE0115"]} diff --git a/mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl b/mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl new file mode 100644 index 000000000..fe508dfa4 --- /dev/null +++ b/mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl @@ -0,0 +1,35 @@ +mdl 1; +-- ============================================================================ +-- Bug ako/mxcli#833: an association path read from a snippet parameter lost its root +-- ============================================================================ +-- +-- Symptom (before fix): describe of TestApp's WorkflowCommons.Snip_Workflow_Header +-- printed `CaptionParams: ({1} = System.Workflow_ParentWorkflow/Name)`, without +-- the $Workflow root; exec wrote it with no source, and mx check reported +-- [CE0402] "No value specified." at Action button 'actionButton1' +-- Snip_WorkflowUserTaskView_Details' textBox9 (Caption over +-- WorkflowUserTaskView_WorkflowSubProcess) described the same way and check +-- refused it (MDL-WIDGET34). +-- +-- After fix: `$Param/Assoc/Attr`, Mendix's expression form, binds as the +-- association steps beside a SourceVariable naming the parameter, in a template +-- parameter and in an input's Attribute. describe prints it that way, and +-- describe -> exec of Snip_Workflow_Header reports it unchanged; mx check 0 errors +-- for this script on Mendix 11.14.0. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl -p app.mpr +-- mxcli docker check -p app.mpr -- expect 0 errors +-- ============================================================================ + +create module BugTest833; + +create persistent entity BugTest833.Customer (Name: String(100)); +create persistent entity BugTest833.Order (Number: String(20)); +create association BugTest833.Order_Customer from BugTest833.Order to BugTest833.Customer; + +create snippet BugTest833.SN_OrderHeader (Params: ($Order: BugTest833.Order)) { + dynamictext txtCustomer (Content: 'Customer: {1}', ContentParams: ({1} = $Order/BugTest833.Order_Customer/Name)) + actionbutton btnCustomer (Caption: '{1}', CaptionParams: ({1} = $Order/BugTest833.Order_Customer/Name), Action: close_page) + textbox tbCustomer (Label: 'Customer', Attribute: $Order/BugTest833.Order_Customer/Name, Editable: Never) +}; diff --git a/mdl-examples/bug-tests/833-snippet-call-page-variable.fail.mdl b/mdl-examples/bug-tests/833-snippet-call-page-variable.fail.mdl new file mode 100644 index 000000000..1f1817d17 --- /dev/null +++ b/mdl-examples/bug-tests/833-snippet-call-page-variable.fail.mdl @@ -0,0 +1,15 @@ +mdl 1; +-- ako/mxcli#833: a page variable passed to a snippet parameter passed check and +-- exec, then mx check reported +-- [CE0115] "The arguments that are passed to snippet 'BugTest833f.SN' do not +-- match the expected parameters and need to be refreshed." at Snippet call 'sc1' +-- Page variables are primitive and a snippet parameter is an object, so it can +-- never match: check and exec now refuse it (MDL-WIDGET34). +create module BugTest833f; +create persistent entity BugTest833f.Item (Name: String(50)); +create snippet BugTest833f.SN (Params: ($Item: BugTest833f.Item)) { + dataview dvIn (DataSource: $Item) { dynamictext t (Content: '{1}', ContentParams: ({1} = Name)) } +}; +create page BugTest833f.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ($Item: BugTest833f.Item), Variables: ($Flag: Boolean = 'true')) { + snippetcall sc1 (Snippet: BugTest833f.SN, Params: (Item = $Flag)) +}; diff --git a/mdl-examples/bug-tests/833-template-param-non-enclosing-dataview.fail.mdl b/mdl-examples/bug-tests/833-template-param-non-enclosing-dataview.fail.mdl new file mode 100644 index 000000000..e8e549555 --- /dev/null +++ b/mdl-examples/bug-tests/833-template-param-non-enclosing-dataview.fail.mdl @@ -0,0 +1,15 @@ +mdl 1; +-- ako/mxcli#833: `{1} = $dv1.Name` naming a data view that does not enclose the +-- widget passed check and exec, was written as SourceVariable { Widget: dv1 }, +-- and mx check reported +-- [CE7001] "Widget should be placed inside Data view 'dv1' to use it as a +-- source widget." at Text 't2' +-- check now refuses it (MDL-WIDGET34). +create module BugTest833d; +create persistent entity BugTest833d.Item (Name: String(50)); +create page BugTest833d.P (Title: 'P', Layout: Atlas_Core.Atlas_Default, Params: ($Item: BugTest833d.Item)) { + dataview dv1 (DataSource: $Item) { dynamictext t1 (Content: 'a') } + dataview dv2 (DataSource: $Item) { + dynamictext t2 (Content: '{1}', ContentParams: ({1} = $dv1.Name)) + } +}; diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 8c6c0b41a..a2e9a3e8d 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -2499,6 +2499,16 @@ func (pb *pageBuilder) resolveTemplateAttributePathFull(attrRef string, param *p // value is not an association path or cannot be resolved, so the caller falls // back to the previous behavior. func (pb *pageBuilder) resolveTemplateAssociationPath(attrRef string, param *pages.ClientTemplateParameter) bool { + // `$Param/Assoc/Attr`: the path read from a named object, which Studio Pro + // stores as the steps beside a SourceVariable naming it (ako/mxcli#833). + if wv, finalQN, steps, ok := pb.resolveRootedAssociationPath(attrRef); ok { + param.AttributeRef = finalQN + param.AttributeRefSteps = steps + param.SourceWidget = wv.Widget + param.SourceVariable = wv.Variable + param.SourceVariableKind = wv.Kind + return true + } finalQN, steps, ok := pb.resolveAssociationAttributePath(attrRef) if !ok { return false @@ -2546,16 +2556,48 @@ func (pb *pageBuilder) resolveInputAttribute(attr string) (string, []pages.Attri func (pb *pageBuilder) resolveAssociationAttributePath(attrRef string) (finalQN string, steps []pages.AttributeRefStep, ok bool) { path := strings.TrimPrefix(attrRef, "$currentObject/") // Only context-relative association paths are handled here; a $param- or - // $widget-rooted navigation is a different (unsupported) shape. + // $widget-rooted navigation is resolveRootedAssociationPath's. if strings.HasPrefix(path, "$") || !strings.Contains(path, "/") { return "", nil, false } + return pb.resolveAssociationPathFrom(path, pb.entityContext) +} + +// resolveRootedAssociationPath resolves `$Root/Assoc/…/Attr`, an association +// path read from a page or snippet parameter or an enclosing data view rather +// than from the context object, starting at the root's entity. wv is the +// SourceVariable the binding stores beside the steps: the parameter in its +// slot, or the data view's recorded pair (ako/mxcli#833). +func (pb *pageBuilder) resolveRootedAssociationPath(attrRef string) (wv pages.WidgetVariable, finalQN string, steps []pages.AttributeRefStep, ok bool) { + root, path, ok := rootedAssociationPath(attrRef) + if !ok { + return pages.WidgetVariable{}, "", nil, false + } + var start string + if v, entity, isParam := pb.parameterVariable(root); isParam { + wv, start = v, entity + } else if v, isDV := pb.dataViewVariables[root]; isDV { + wv, start = v, pb.paramEntityNames[root] + } + if start == "" { + return pages.WidgetVariable{}, "", nil, false + } + finalQN, steps, ok = pb.resolveAssociationPathFrom(path, start) + if !ok { + return pages.WidgetVariable{}, "", nil, false + } + return wv, finalQN, steps, true +} + +// resolveAssociationPathFrom resolves a context-relative association path +// starting at the entity start. +func (pb *pageBuilder) resolveAssociationPathFrom(path, start string) (finalQN string, steps []pages.AttributeRefStep, ok bool) { segs := strings.Split(path, "/") if len(segs) < 2 { return "", nil, false } attrName := segs[len(segs)-1] - current := pb.entityContext + current := start if current == "" { return "", nil, false } @@ -2887,6 +2929,18 @@ func (pb *pageBuilder) widgetVariableFor(name string) (pages.WidgetVariable, boo // (CE7001) — is refused: the writer would otherwise store a binding that reads // nothing. func (pb *pageBuilder) resolveInputBinding(w *ast.WidgetV3, attr string) (string, []pages.AttributeRefStep, *pages.WidgetVariable, error) { + // `$Param/Assoc/Attr`: an association path read from a named object + // (ako/mxcli#833). + if root, _, isRooted := rootedAssociationPath(attr); isRooted { + wv, path, steps, ok := pb.resolveRootedAssociationPath(attr) + if !ok { + return "", nil, nil, mdlerrors.NewValidationf( + "%s `%s`: `Attribute: %s` — `$%s/…` reads an association path from a parameter of this %s or an "+ + "enclosing data view, and `$%s` is neither, or the path does not resolve from it", + strings.ToLower(w.Type), w.Name, attr, root, pb.documentKind(), root) + } + return path, steps, &wv, nil + } name, attrName, ok := strings.Cut(strings.TrimPrefix(attr, "$"), ".") if !strings.HasPrefix(attr, "$") { path, steps := pb.resolveInputAttribute(attr) diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index 3a7d255d7..1f2d0f5f5 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -190,7 +190,7 @@ func (pb *pageBuilder) buildClientTemplateParams(astParams []ast.ParamAssignment out = append(out, param) continue } - if isTemplateExpression(strVal) { + if pb.isTemplateExpression(strVal) { // A quoted string literal or any other expression // (`toString($Order/Total)`) — stored as written. param.Expression = strVal @@ -505,7 +505,7 @@ func (pb *pageBuilder) buildTextBoxV3(w *ast.WidgetV3) (*pages.TextBox, error) { } // Handle Attribute (attribute path) - if attr := w.GetAttribute(); attr != "" { + if attr := inputAttributeText(w); attr != "" { path, steps, sv, err := pb.resolveInputBinding(w, attr) if err != nil { return nil, err @@ -607,7 +607,7 @@ func (pb *pageBuilder) buildTextAreaV3(w *ast.WidgetV3) (*pages.TextArea, error) } // Handle Attribute - if attr := w.GetAttribute(); attr != "" { + if attr := inputAttributeText(w); attr != "" { path, steps, sv, err := pb.resolveInputBinding(w, attr) if err != nil { return nil, err @@ -694,7 +694,7 @@ func (pb *pageBuilder) buildDatePickerV3(w *ast.WidgetV3) (*pages.DatePicker, er } // Handle Attribute - if attr := w.GetAttribute(); attr != "" { + if attr := inputAttributeText(w); attr != "" { path, steps, sv, err := pb.resolveInputBinding(w, attr) if err != nil { return nil, err @@ -832,7 +832,7 @@ func (pb *pageBuilder) buildDropdownV3(w *ast.WidgetV3) (*pages.DropDown, error) } // Handle Attribute - if attr := w.GetAttribute(); attr != "" { + if attr := inputAttributeText(w); attr != "" { path, steps, sv, err := pb.resolveInputBinding(w, attr) if err != nil { return nil, err @@ -878,7 +878,7 @@ func (pb *pageBuilder) buildCheckBoxV3(w *ast.WidgetV3) (*pages.CheckBox, error) } // Handle Attribute - if attr := w.GetAttribute(); attr != "" { + if attr := inputAttributeText(w); attr != "" { path, steps, sv, err := pb.resolveInputBinding(w, attr) if err != nil { return nil, err @@ -966,7 +966,7 @@ func (pb *pageBuilder) buildRadioButtonsV3(w *ast.WidgetV3) (*pages.RadioButtons } // Get attribute path from Attribute property - if attr := w.GetAttribute(); attr != "" { + if attr := inputAttributeText(w); attr != "" { path, steps, sv, err := pb.resolveInputBinding(w, attr) if err != nil { return nil, err @@ -1174,7 +1174,7 @@ func (pb *pageBuilder) buildDynamicTextV3(w *ast.WidgetV3) (*pages.DynamicText, } // Check if it's an attribute reference or literal if strVal, ok := p.Value.(string); ok { - if isTemplateExpression(strVal) { + if pb.isTemplateExpression(strVal) { // A quoted string literal or any other expression // (`toString($Order/Total)`) - stored as written param.Expression = strVal @@ -1658,6 +1658,11 @@ func (pb *pageBuilder) buildSnippetCallParams(sc *pages.SnippetCallWidget, snipp // the snippet does not have — CE0115 (ako/mxcli#721 L3). // A page `Variables:` entry is named in LocalVariable (ako/mxcli#826). _, kind := pb.classifyFlowArgValue(argument) + if kind == "local" { + // A page variable is primitive and a snippet parameter an object: + // CE0115 whichever slot it is written to (ako/mxcli#833). + return mdlerrors.NewValidation(snippetCallPageVariableMessage(sc.Name, declared.Name, strings.TrimPrefix(argument, "$"))) + } sc.ParameterMappings = append(sc.ParameterMappings, pages.SnippetParamMapping{ ParamName: declared.Name, Argument: argument, diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 4cadc4a8a..2a3e24f21 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -1459,6 +1459,15 @@ func extractInputAttribute(ctx *ExecContext, w map[string]any) string { return parameterAttributeMDL(param, attr) } } + // An association path read from a named object — a parameter, or a data + // view through its Widget slot — is `$Root/Assoc/Attr`. Printed without + // the root it was refused at the snippet root (MDL-WIDGET34), or bound + // against the wrong context (ako/mxcli#833). + if root := firstNonEmpty(widget, parameterSourceName(w["SourceVariable"])); root != "" && attrRef != nil && attrRef["EntityRef"] != nil { + if path := columnAttributeFromRef(attrRef); strings.Contains(path, "/") { + return "$" + root + "/" + path + } + } if widget == "" || attrRef == nil { return extractAttributeRef(ctx, w) } diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 7d88c76f5..1f5ec5e4a 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -671,6 +671,12 @@ func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any // bare attribute re-executes against the widget's own entity (CE1365, // mendixlabs/mxcli#1494). if assocPath := associationTemplateParamPath(attrRef); assocPath != "" { + // Read from a named object (a parameter, a data view): the root + // is part of the binding. Without it the path re-executed against + // the context with no source, CE0402 (ako/mxcli#833). + if sourceVarName != "" { + assocPath = "$" + sourceVarName + "/" + assocPath + } result = append(result, assocPath) continue } diff --git a/mdl/executor/cmd_pages_input_binding_context.go b/mdl/executor/cmd_pages_input_binding_context.go index 1dc8460f9..daf351ec1 100644 --- a/mdl/executor/cmd_pages_input_binding_context.go +++ b/mdl/executor/cmd_pages_input_binding_context.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "sort" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -62,6 +63,12 @@ func inputBindingProblem(w *ast.WidgetV3, c pageArgContext, noEntity bool, isPag return "" } } + if _, _, ok := rootedAttributeValue(raw); ok { + // `$name/Assoc/Attr` names its object outright, like `$name.Attr`; + // whether the name is one is judged where the names are known + // (ako/mxcli#833). + return "" + } attr, isString := raw.(string) if !isString { if name, ok := bareVariableReference(raw); ok { @@ -264,6 +271,21 @@ func validatePageVariableBindings(widgets []*ast.WidgetV3, variables []ast.PageV if w == nil { continue } + // A page variable passed to a snippet parameter: page variables are + // primitive and a snippet parameter is an object, so the call can + // never match — mx check CE0115 "The arguments that are passed to + // snippet '…' do not match the expected parameters" (ako/mxcli#833). + for _, sp := range w.GetSnippetParams() { + if name := strings.TrimPrefix(sp.Variable, "$"); declared[name] { + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET34", + Severity: linter.SeverityError, + Message: locationPrefix + ": " + snippetCallPageVariableMessage(w.Name, + strings.TrimPrefix(sp.ParamName, "$"), name), + Suggestion: "Pass an object: a parameter of this document, an enclosing data view (`$dvName`), or leave the parameter to the enclosing data context.", + }) + } + } raw, _ := lookupPropCI(w, "Attribute") if _, ok := bareVariableReference(raw); ok { if msg := inputBindingProblem(w, pageArgContext{}, false, isDeclared); msg != "" { @@ -300,6 +322,24 @@ func validateNamedObjectBindings(widgets []*ast.WidgetV3, params []string, locat Suggestion: "`$Name.Attr` reads an attribute of the page or snippet parameter $Name; declare it in Params, or bind the attribute by name inside a data container.", }) } + // Every data view of the document, enclosing or not: a template parameter + // `$dv1.Attr` naming one that does not enclose the widget was written as + // SourceVariable { Widget: dv1 }, mx check CE7001 "Widget should be placed + // inside Data view 'dv1' to use it as a source widget." (ako/mxcli#833). + allDataViews := map[string]bool{} + var collect func(ws []*ast.WidgetV3) + collect = func(ws []*ast.WidgetV3) { + for _, w := range ws { + if w == nil { + continue + } + if strings.EqualFold(w.Type, "dataview") && w.Name != "" { + allDataViews[w.Name] = true + } + collect(w.Children) + } + } + collect(widgets) var walk func(ws []*ast.WidgetV3, dataViews map[string]bool) walk = func(ws []*ast.WidgetV3, dataViews map[string]bool) { for _, w := range ws { @@ -307,6 +347,33 @@ func validateNamedObjectBindings(widgets []*ast.WidgetV3, params []string, locat continue } kind := strings.ToLower(w.Type) + for _, key := range propertyKeys(w.Properties) { + params, ok := w.Properties[key].([]ast.ParamAssignmentV3) + if !ok { + continue + } + for _, p := range params { + v, _ := p.Value.(string) + name, _, ok := namedObjectBinding(v) + if !ok || declared[name] || !allDataViews[name] || dataViews[name] { + continue + } + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET34", + Severity: linter.SeverityError, + Message: fmt.Sprintf("%s: %s `%s`: `{%d} = %s` in %s reads data view `%s`, which does not enclose the "+ + "widget — a data view's name is a source only for the widgets inside it (mx check CE7001 "+ + "\"Widget should be placed inside Data view '%s' to use it as a source widget.\")", + locationPrefix, kind, w.Name, p.Index, v, key, name, name), + Suggestion: fmt.Sprintf("Move `%s` inside data view `%s`, or read the object it shows another way (its parameter, `$Param.Attr`).", w.Name, name), + }) + } + } + if root, _, ok := rootedAttributeValue(w.Properties["Attribute"]); ok && !declared[root] && + !(namedObjectBindingKinds[kind] && dataViews[root]) { + report(fmt.Sprintf("%s `%s`: `Attribute: $%s/…` — `$%s` is not a parameter of this document%s, "+ + "so the binding would read nothing", kind, w.Name, root, root, dataViewClause(kind))) + } if attr, ok := w.Properties["Attribute"].(string); ok { if name, _, ok := namedObjectBinding(attr); ok && !declared[name] && !(namedObjectBindingKinds[kind] && dataViews[name]) { @@ -334,3 +401,22 @@ func validateNamedObjectBindings(widgets []*ast.WidgetV3, params []string, locat walk(widgets, map[string]bool{}) return out } + +// propertyKeys is a widget's property keys in a stable order, so violations +// are reported deterministically. +func propertyKeys(props map[string]any) []string { + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// snippetCallPageVariableMessage is the refusal for a snippet call passing a +// page variable, shared by check and the builder. +func snippetCallPageVariableMessage(call, param, variable string) string { + return fmt.Sprintf("snippetcall `%s`: `%s = $%s` passes a page variable, but a snippet parameter is an object "+ + "and a page variable is not — mx check CE0115 \"The arguments that are passed to snippet … do not match the "+ + "expected parameters\"", call, param, variable) +} diff --git a/mdl/executor/cmd_pages_parameter_binding.go b/mdl/executor/cmd_pages_parameter_binding.go index a836ac2bb..dbf9de5ea 100644 --- a/mdl/executor/cmd_pages_parameter_binding.go +++ b/mdl/executor/cmd_pages_parameter_binding.go @@ -119,3 +119,51 @@ func parameterAttributeMDL(name, attrQN string) string { func isParameterAttributeText(text string) bool { return ast.IsParameterAttributeText(text) } + +// rootedAssociationPath splits `$Root/Assoc/…/Attr` — an association path read +// from a named object, Mendix's own expression spelling — into the root name +// and the context-relative path. At least one association hop is required: +// a direct attribute of a named object is spelled `$Root.Attr`. $currentObject +// is not a root; its path is the context-relative one. +// +// Studio Pro stores this as an AttributeRef with an IndirectEntityRef of steps +// beside a SourceVariable naming the object, e.g. a snippet parameter read +// outside every data container (TestApp WorkflowCommons.Snip_Workflow_Header, +// ako/mxcli#833). +func rootedAssociationPath(s string) (root, path string, ok bool) { + after, isVar := strings.CutPrefix(strings.TrimSpace(s), "$") + if !isVar { + return "", "", false + } + root, path, ok = strings.Cut(after, "/") + if !ok || root == "" || !strings.Contains(path, "/") || strings.EqualFold(root, "currentObject") || + strings.ContainsAny(root, ". ") || strings.ContainsAny(path, " ()=") { + return "", "", false + } + return root, path, true +} + +// rootedAttributeValue reads `Attribute: $Root/Assoc/Attr`, which the grammar +// parses as a data-source expression rather than an attribute path, back into +// its root and path. +func rootedAttributeValue(raw any) (root, path string, ok bool) { + ds, isDS := raw.(*ast.DataSourceV3) + if !isDS || ds.ContextVariable == "" || len(ds.Args) > 0 || ds.Where != "" || len(ds.OrderBy) > 0 { + return "", "", false + } + return rootedAssociationPath("$" + ds.ContextVariable + "/" + ds.Reference) +} + +// inputAttributeText is an input widget's `Attribute:` as the text the input +// builders resolve: the attribute path as written, or `$Root/Assoc/Attr` for a +// path read from a named object (ako/mxcli#833). +func inputAttributeText(w *ast.WidgetV3) string { + if attr := w.GetAttribute(); attr != "" { + return attr + } + raw, _ := lookupPropCI(w, "Attribute") + if root, path, ok := rootedAttributeValue(raw); ok { + return "$" + root + "/" + path + } + return "" +} diff --git a/mdl/executor/cmd_pages_rooted_assoc_path_833_test.go b/mdl/executor/cmd_pages_rooted_assoc_path_833_test.go new file mode 100644 index 000000000..c4285ffba --- /dev/null +++ b/mdl/executor/cmd_pages_rooted_assoc_path_833_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#833: an association path read from a snippet parameter, outside +// every data container, is what Studio Pro stores on TestApp's +// WorkflowCommons.Snip_Workflow_Header (`{1}` over System.Workflow_ParentWorkflow +// to Name) and Snip_WorkflowUserTaskView_Details (textBox9, Caption over +// WorkflowUserTaskView_WorkflowSubProcess): an AttributeRef with an +// IndirectEntityRef of steps, beside SourceVariable { SnippetParameter }. +// describe printed the path without its root and exec wrote it with no source: +// mx check CE0402 "No value specified." at Action button 'actionButton1', and +// check refused textBox9 (MDL-WIDGET34). `$Param/Assoc/Attr` — Mendix's own +// expression form, which the snippet's Visible condition already uses — is the +// spelling for the pair. +func rootedPathPB(t *testing.T) *pageBuilder { + t.Helper() + pb := ruleActionPB(t) + pb.entityContext = "" // outside every data container + pb.isSnippet = true + pb.paramScope = map[string]model.ID{"Action": "e-action"} + pb.paramEntityNames = map[string]string{"Action": "Rules.RuleAction"} + return pb +} + +func TestTemplateParamAssociationPathRootedAtParameter(t *testing.T) { + pb := rootedPathPB(t) + param := &pages.ClientTemplateParameter{} + pb.resolveTemplateAttributePathFull("$Action/RuleAction_BusinessRule/Name", param) + if param.AttributeRef != "Rules.BusinessRule.Name" || len(param.AttributeRefSteps) != 1 || + param.AttributeRefSteps[0].Association != "Rules.RuleAction_BusinessRule" { + t.Errorf("AttributeRef %q steps %+v, want Rules.BusinessRule.Name over Rules.RuleAction_BusinessRule", + param.AttributeRef, param.AttributeRefSteps) + } + if param.SourceVariable != "Action" || param.SourceVariableKind != "snippet" || param.SourceWidget != "" { + t.Errorf("SourceVariable %q kind %q widget %q, want the snippet parameter Action", + param.SourceVariable, param.SourceVariableKind, param.SourceWidget) + } +} + +func TestInputAssociationPathRootedAtParameter(t *testing.T) { + pb := rootedPathPB(t) + w := &ast.WidgetV3{Type: "TEXTBOX", Name: "textBox9", Properties: map[string]any{ + "Attribute": &ast.DataSourceV3{Type: "association", ContextVariable: "Action", Reference: "RuleAction_BusinessRule/Name"}, + }} + attr := inputAttributeText(w) + if attr != "$Action/RuleAction_BusinessRule/Name" { + t.Fatalf("inputAttributeText = %q", attr) + } + path, steps, sv, err := pb.resolveInputBinding(w, attr) + if err != nil { + t.Fatal(err) + } + if path != "Rules.BusinessRule.Name" || len(steps) != 1 || sv == nil || sv.Variable != "Action" || sv.Kind != "snippet" { + t.Errorf("binding = %q %+v %+v, want Rules.BusinessRule.Name, one step, the snippet parameter Action", path, steps, sv) + } + // The check no longer refuses it outside a data container. + if msg := inputBindingProblem(w, pageArgContext{known: true}, true, nil); msg != "" { + t.Errorf("inputBindingProblem: %s", msg) + } +} + +func TestDescribeAssociationPathRootedAtParameter(t *testing.T) { + ref := map[string]any{ + "Attribute": "System.Workflow.Name", + "EntityRef": map[string]any{"$Type": "DomainModels$IndirectEntityRef", "Steps": []any{int32(2), + map[string]any{"Association": "System.Workflow_ParentWorkflow", "DestinationEntity": "System.Workflow"}}}, + } + sv := map[string]any{"$Type": "Forms$PageVariable", "SnippetParameter": "Workflow", "PageParameter": "", "Widget": "", "LocalVariable": ""} + tpl := map[string]any{"Parameters": []any{int32(2), map[string]any{"AttributeRef": ref, "Expression": "", "SourceVariable": sv}}} + if got := extractTextTemplateParameters(nil, tpl); len(got) != 1 || got[0] != "$Workflow/System.Workflow_ParentWorkflow/Name" { + t.Errorf("template described %v, want [$Workflow/System.Workflow_ParentWorkflow/Name]", got) + } + // Control: the same path with no source variable stays context-relative. + tpl = map[string]any{"Parameters": []any{int32(2), map[string]any{"AttributeRef": ref, "Expression": ""}}} + if got := extractTextTemplateParameters(nil, tpl); len(got) != 1 || got[0] != "System.Workflow_ParentWorkflow/Name" { + t.Errorf("control described %v", got) + } +} + +// textBox9 of Snip_WorkflowUserTaskView_Details, as stored. +func TestDescribeInputAssociationPathRootedAtParameter(t *testing.T) { + w := map[string]any{ + "AttributeRef": map[string]any{ + "Attribute": "System.WorkflowSubProcess.Caption", + "EntityRef": map[string]any{"$Type": "DomainModels$IndirectEntityRef", "Steps": []any{int32(2), + map[string]any{"Association": "WorkflowCommons.WorkflowUserTaskView_WorkflowSubProcess", "DestinationEntity": "System.WorkflowSubProcess"}}}, + }, + "SourceVariable": map[string]any{"$Type": "Forms$PageVariable", "SnippetParameter": "WorkflowUserTaskView", "PageParameter": "", "Widget": "", "LocalVariable": ""}, + } + got := extractInputAttribute(nil, w) + if !strings.HasPrefix(got, "$WorkflowUserTaskView/") || !strings.HasSuffix(got, "WorkflowUserTaskView_WorkflowSubProcess/Caption") { + t.Errorf("described %q, want $WorkflowUserTaskView/…WorkflowUserTaskView_WorkflowSubProcess/Caption", got) + } +} + +// Through the template-parameter builder, not just the resolver: `$Var/…` is +// otherwise stored as an Expression (mendixlabs/mxcli#1406), and the +// expression form of this path is mx check CE0117 "Error(s) in expression." at +// Action button 'actionButton1'. A path that resolves from a parameter binds; +// one that does not stays the expression it was. +func TestTemplateParamsRootedPathBindsThroughTheBuilder(t *testing.T) { + pb := rootedPathPB(t) + got := pb.buildClientTemplateParams([]ast.ParamAssignmentV3{ + {Index: 1, Value: "$Action/RuleAction_BusinessRule/Name"}, + {Index: 2, Value: "$Customer/Name"}, // control: not a parameter here, an expression (#1406) + }) + if len(got) != 2 { + t.Fatalf("%d params", len(got)) + } + if got[0].Expression != "" || got[0].AttributeRef != "Rules.BusinessRule.Name" || got[0].SourceVariable != "Action" { + t.Errorf("rooted path: Expression %q AttributeRef %q SourceVariable %q, want the binding", + got[0].Expression, got[0].AttributeRef, got[0].SourceVariable) + } + if got[1].Expression != "$Customer/Name" { + t.Errorf("control: Expression %q, want $Customer/Name", got[1].Expression) + } +} diff --git a/mdl/executor/cmd_pages_template_attr.go b/mdl/executor/cmd_pages_template_attr.go index 6e7232eaf..36752ddcf 100644 --- a/mdl/executor/cmd_pages_template_attr.go +++ b/mdl/executor/cmd_pages_template_attr.go @@ -81,6 +81,21 @@ func isTemplateExpression(v string) bool { isVariableRootedTemplatePath(v) } +// isTemplateExpression is isTemplateExpression for the document being built: +// a `$Root/Assoc/…/Attr` path that resolves from one of its parameters or an +// enclosing data view is a binding, as `$currentObject/Assoc/Attr` is. Studio +// Pro stores it as the steps beside a SourceVariable naming the root +// (TestApp WorkflowCommons.Snip_Workflow_Header), and the expression form of +// the same path is mx check CE0117 "Error(s) in expression." (ako/mxcli#833). +// Anything else is judged as before, so `$Customer/Name` stays the expression +// Studio Pro stores (mendixlabs/mxcli#1406). +func (pb *pageBuilder) isTemplateExpression(v string) bool { + if _, _, _, ok := pb.resolveRootedAssociationPath(strings.TrimSpace(v)); ok { + return false + } + return isTemplateExpression(v) +} + // isVariableRootedTemplatePath reports a `$var/…` member path, which is Mendix // expression syntax rather than one of MDL's binding spellings: `$Customer/Name`, // `$currentObject/Name`. Studio Pro stores such a parameter as an Expression, and diff --git a/mdl/executor/cmd_pages_widget_scoped_variable_test.go b/mdl/executor/cmd_pages_widget_scoped_variable_test.go index 4dea2381a..b469dfa37 100644 --- a/mdl/executor/cmd_pages_widget_scoped_variable_test.go +++ b/mdl/executor/cmd_pages_widget_scoped_variable_test.go @@ -133,15 +133,17 @@ func TestTemplateParameterThroughDataView(t *testing.T) { } } -// A page `Variables:` entry passed to a snippet call fills LocalVariable, not -// PageParameter. +// A page `Variables:` entry passed to a snippet call is refused: page +// variables are primitive and a snippet parameter is an object, so the call is +// mx check CE0115 in either slot — measured with the LocalVariable slot this +// test used to pin (ako/mxcli#833). A page parameter is passed as before. func TestSnippetCallLocalVariableArgument(t *testing.T) { for _, tc := range []struct { - arg string - wantLocal, wantSnp bool + arg string + refused bool }{ - {"$Caption", true, false}, - {"$Task", false, false}, // control: a page parameter + {"$Caption", true}, + {"$Task", false}, // control: a page parameter } { pb := scopedPB(false) pb.localVariables = map[string]bool{"Caption": true} @@ -149,15 +151,18 @@ func TestSnippetCallLocalVariableArgument(t *testing.T) { return []*pages.Snippet{{Name: "Snip", Parameters: []*pages.SnippetParameter{{Name: "P"}}}}, nil }} sc := &pages.SnippetCallWidget{} - if err := pb.buildSnippetCallParams(sc, "M.Snip", []ast.SnippetCallParam{{ParamName: "P", Variable: tc.arg}}); err != nil { - t.Fatalf("%s: %v", tc.arg, err) + err := pb.buildSnippetCallParams(sc, "M.Snip", []ast.SnippetCallParam{{ParamName: "P", Variable: tc.arg}}) + if tc.refused { + if err == nil { + t.Errorf("%s: built %+v, want the CE0115 refusal", tc.arg, sc.ParameterMappings) + } + continue } - if len(sc.ParameterMappings) != 1 { - t.Fatalf("%s: %d mappings, want 1", tc.arg, len(sc.ParameterMappings)) + if err != nil { + t.Fatalf("%s: %v", tc.arg, err) } - m := sc.ParameterMappings[0] - if m.IsLocalVariable != tc.wantLocal || m.IsSnippetParameter != tc.wantSnp { - t.Errorf("%s: (local, snippet) = (%v, %v), want (%v, %v)", tc.arg, m.IsLocalVariable, m.IsSnippetParameter, tc.wantLocal, tc.wantSnp) + if len(sc.ParameterMappings) != 1 || sc.ParameterMappings[0].IsLocalVariable { + t.Errorf("%s: %+v, want one page-parameter mapping", tc.arg, sc.ParameterMappings) } } } diff --git a/mdl/executor/validate_template_param_dataview_833_test.go b/mdl/executor/validate_template_param_dataview_833_test.go new file mode 100644 index 000000000..e5ddd935d --- /dev/null +++ b/mdl/executor/validate_template_param_dataview_833_test.go @@ -0,0 +1,86 @@ +// 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/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#833: `{1} = $dv1.Name` naming a data view that does not enclose +// the widget passed check and exec, was written as SourceVariable +// { Widget: dv1 } and mx check reported CE7001 "Widget should be placed inside +// Data view 'dv1' to use it as a source widget." at Text 't2'. A data view's +// name is a source only for the widgets inside it. +func TestTemplateParamNamingNonEnclosingDataViewIsRefused(t *testing.T) { + text := func(name, value string) *ast.WidgetV3 { + return &ast.WidgetV3{Type: "dynamictext", Name: name, Properties: map[string]any{ + "Content": "{1}", + "ContentParams": []ast.ParamAssignmentV3{{Index: 1, Value: value}}, + }} + } + dv := func(name string, children ...*ast.WidgetV3) *ast.WidgetV3 { + return &ast.WidgetV3{Type: "dataview", Name: name, Children: children} + } + widgets := []*ast.WidgetV3{ + dv("dv1", text("inside", "$dv1.Name")), // control: encloses + dv("dv2", text("t2", "$dv1.Name")), // sibling: CE7001 + dv("dv3", dv("dv4", text("nested", "$dv3.Name"))), // control: an outer data view + } + got := validateNamedObjectBindings(widgets, []string{"Item"}, "page M.P") + if len(got) != 1 { + t.Fatalf("%d violations, want 1 (t2 only): %+v", len(got), got) + } + if got[0].RuleID != "MDL-WIDGET34" || !strings.Contains(got[0].Message, "`t2`") || !strings.Contains(got[0].Message, "CE7001") { + t.Errorf("violation = %+v", got[0]) + } +} + +// ako/mxcli#833: a page variable passed to a snippet parameter passed check and +// exec and was CE0115 "The arguments that are passed to snippet 'M833c.SN' do +// not match the expected parameters and need to be refreshed." Page variables +// are primitive, snippet parameters objects: it can never match. +func TestSnippetCallPassingPageVariableIsRefused(t *testing.T) { + call := func(arg string) *ast.WidgetV3 { + return &ast.WidgetV3{Type: "snippetcall", Name: "sc1", Properties: map[string]any{ + "Snippet": "M.SN", + "Params": []ast.SnippetCallParam{{ParamName: "Item", Variable: arg}}, + }} + } + vars := []ast.PageVariable{{Name: "Flag"}} + if got := validatePageVariableBindings([]*ast.WidgetV3{call("$Flag")}, vars, "page M.P"); len(got) != 1 || + !strings.Contains(got[0].Message, "CE0115") { + t.Errorf("$Flag: %+v, want one CE0115 refusal", got) + } + // Control: a parameter is passed as before. + if got := validatePageVariableBindings([]*ast.WidgetV3{call("$Item")}, vars, "page M.P"); len(got) != 0 { + t.Errorf("$Item: %+v, want none", got) + } + + // The builder refuses it too (exec --no-check). + mod := mkModule("Mod") + snp := mkSnippetWithParam(mod.ID, "SN", "Item") + h := mkHierarchy(mod) + withContainer(h, snp.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil }, + ListSnippetsFunc: func() ([]*pages.Snippet, error) { return []*pages.Snippet{snp}, nil }, + } + pb := newPageBuilder(mb, h, "Mod") + pb.localVariables = map[string]bool{"Flag": true} + _, err := pb.buildSnippetCallV3(&ast.WidgetV3{Type: "SNIPPETCALL", Name: "sc1", Properties: map[string]any{ + "Snippet": "Mod.SN", + "Params": []ast.SnippetCallParam{{ParamName: "Item", Variable: "$Flag"}}, + }}) + if err == nil || !strings.Contains(err.Error(), "CE0115") { + t.Errorf("builder: err = %v, want the CE0115 refusal", err) + } +} From cc7badad5046f699bdbce16cf0414cf759c5f931 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:49:54 +0000 Subject: [PATCH 15/26] test: add the mdl 1; header to the #2099 bug-test script The conformance gate refuses a complete script without the language header (TestConformanceGate, build-and-test on ako/mxcli#2142). Added with `mxcli fmt --upgrade --header`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- .../bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl | 1 + 1 file changed, 1 insertion(+) diff --git a/mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl b/mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl index 70382b28b..361709280 100644 --- a/mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl +++ b/mdl-examples/bug-tests/2099-access-rule-xpath-form-and-literal-types.mdl @@ -1,3 +1,4 @@ +mdl 1; -- Bug ako/mxcli#2099 (mendixlabs/mxcli#1803): an access rule's WHERE whose -- members all exist still failed the build, while `check -p` and exec passed it: -- From 89e03a203457a8e440b912e2657e375b99416606 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:51:59 +0000 Subject: [PATCH 16/26] fix: check reports a call argument whose type cannot match the parameter (CE0117) `check -p` passed a call microflow / call nanoflow argument whose type cannot match the called flow's parameter (`call microflow Lab.Target (Qty = '5')` for an Integer parameter), and the build then failed CE0117 "Error(s) in expression". The expectations table declared CallArgument.Value as resolved by "TargetParameter", but nothing resolved it and checkSlotKind skips every ResolveBy slot. Resolve it the way AttributeOf:Parent is resolved: the check adapter qualifies the slot with the called flow and parameter, and checkCallArgumentKind looks the parameter up in the catalog (plus the flows the script itself creates, overlaid by declareScriptTypes). The predicate is the 9x19 matrix measured on mxbuild 11.14.0: Integer/Long interchange and widen to Decimal (the same argAccepted the built-in argument check uses), an enumeration must be the same enumeration, an object/list parameter takes no primitive. Unknown flow, parameter or argument kind stays silent. E013 (bare word) now covers call arguments too. Refs ako/mxcli#2096, mendixlabs/mxcli#1797 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- ...all-argument-parameter-type-unchecked.json | 1 + .../2096-call-argument-parameter-type.mdl | 78 +++++++ mdl/executor/typecheck.go | 47 ++++ .../typecheck_call_argument_2096_test.go | 143 ++++++++++++ mdl/exprcatalog/exprcatalog.go | 94 +++++++- mdl/exprcatalog/exprcatalog_test.go | 39 ++++ mdl/exprcheck/adapters/check.go | 7 +- mdl/exprcheck/call_argument.go | 212 ++++++++++++++++++ mdl/exprcheck/interfaces.go | 11 + mdl/exprcheck/parser.go | 8 +- mdl/exprcheck/slot_resolver.go | 7 +- 11 files changed, 635 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-other/2026-11-11-call-argument-parameter-type-unchecked.json create mode 100644 mdl-examples/bug-tests/2096-call-argument-parameter-type.mdl create mode 100644 mdl/executor/typecheck_call_argument_2096_test.go create mode 100644 mdl/exprcheck/call_argument.go diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-call-argument-parameter-type-unchecked.json b/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-call-argument-parameter-type-unchecked.json new file mode 100644 index 000000000..681444891 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-11-11-call-argument-parameter-type-unchecked.json @@ -0,0 +1 @@ +{"area":"mdl/exprcheck","date":"2026-11-11","symptom":"ako/mxcli#2096 (mendixlabs/mxcli#1797): `call microflow Lab.Target (Qty = '5')` against an Integer parameter passes `mxcli check -p` and exec, then mx check 11.14.0 fails CE0117 \"Error(s) in expression\" at the call activity; likewise another enumeration, Boolean for DateTime, Decimal for Integer, a primitive for an object parameter, and a bare word","cause":"slot_resolver.go declared `CallArgument.Value` with ResolveBy \"TargetParameter\", but nothing resolved it and checkSlotKind skips every ResolveBy slot; E013 (bare word) was scoped to create/change members; and a flow the script itself creates had no parameter types in the catalog reader","file":"`mdl/exprcheck/call_argument.go` (CallArgumentSlot, checkCallArgumentKind, callArgAccepted), `mdl/exprcheck/adapters/check.go` (qualified slot), `mdl/exprcheck/parser.go` (E013 prefix), `mdl/exprcatalog/exprcatalog.go` (mfParamRef, DeclareFlow, KnowsEntity), `mdl/executor/typecheck.go` (scriptFlowParams), test `mdl/executor/typecheck_call_argument_2096_test.go`, `mdl-examples/bug-tests/2096-call-argument-parameter-type.mdl`","insight":"A ResolveBy entry in the expectations table is a TODO, not a rule: grep for who resolves it before trusting it (only AttributeOf:Parent had a resolver, by qualifying the slot path). Resolve TargetParameter the same way, qualifying the slot with flow(param), rather than writing a second resolver. Measure the whole matrix in ONE project with `mx check -j`: 9 parameter types x 19 argument shapes as one caller microflow each gives the predicate in a 17s run, and diffing it against `check -p` gave 133 caught / 0 false / 8 deliberate misses. The matrix surprised twice: `empty` into a BOOLEAN parameter is CE0117 (into every other type it builds), left unreported pending its own cross-version measurement; and [%CurrentDateTime%] must be typed DateTime (argKind), since inferKind calls every token a String and would refuse it for a DateTime parameter. Plausible wrong turn: trusting the kind scope for enumeration variables. In a microflow SIGNATURE `enum M.E` does not set ExplicitEnum (visitor_microflow.go), so the parameter sits in the entity scope and infers as Object; settle it by asking the catalog whether the name is an enumeration (document names are unique per module). Object-vs-list and entity identity stay silent: the entity scope cannot tell a list variable from an object. Still open: exec's preflight does not run TypeCheckProgram at all, so this rule, like every E0xx rule, fails `check -p` but does not stop `exec`.","refs":["ako/mxcli#2096","mendixlabs/mxcli#1797"],"ce":["CE0117"]} diff --git a/mdl-examples/bug-tests/2096-call-argument-parameter-type.mdl b/mdl-examples/bug-tests/2096-call-argument-parameter-type.mdl new file mode 100644 index 000000000..6a3a4a151 --- /dev/null +++ b/mdl-examples/bug-tests/2096-call-argument-parameter-type.mdl @@ -0,0 +1,78 @@ +mdl 1; +-- ============================================================================ +-- Regression: ako/mxcli#2096 (mendixlabs/mxcli#1797) - a call microflow / +-- call nanoflow argument whose type cannot match the called parameter +-- ============================================================================ +-- +-- Symptom: `mxcli check -p` passed `call microflow Lab.Target (Qty = '5')` +-- against an Integer parameter, exec wrote it, and mx check failed CE0117 +-- "Error(s) in expression" at the call activity. +-- +-- The rule needs the project (it reads the parameter types from the catalog +-- and from the flows this script creates), so without -p this whole file +-- passes. With a project, exactly ONE error is expected: E009 on +-- Tmp2096.Caller_Bad. Every call in Tmp2096.Caller_Ok builds at 0 errors on +-- mx check 11.14.0 and must stay silent. +-- +-- Usage: +-- mxcli check mdl-examples/bug-tests/2096-call-argument-parameter-type.mdl +-- Expect: Check passed. +-- mxcli check mdl-examples/bug-tests/2096-call-argument-parameter-type.mdl -p app.mpr +-- Expect: 1 error, E009 at Tmp2096.Caller_Bad (plus access warnings). +-- ============================================================================ + +create module Tmp2096; + +create enumeration Tmp2096.Color (Red 'Red', Blue 'Blue'); + +create persistent entity Tmp2096.Order ( + Qty: Integer, + Color: enumeration(Tmp2096.Color) +); + +create microflow Tmp2096.Target ($Qty: Integer) +begin +end; + +create microflow Tmp2096.T_Dec ($P: Decimal) +begin +end; + +create microflow Tmp2096.T_Color ($P: enum Tmp2096.Color) +begin +end; + +create microflow Tmp2096.T_Order ($P: Tmp2096.Order) +begin +end; + +create nanoflow Tmp2096.N_Int ($P: Integer) +begin +end; + +-- The reported case: a String for an Integer parameter (CE0117). +create microflow Tmp2096.Caller_Bad () +begin + call microflow Tmp2096.Target (Qty = '5'); +end; + +-- Controls: the coercions Mendix makes, empty, the same enumeration, and an +-- object of the parameter's entity. +create microflow Tmp2096.Caller_Ok ($L: Long, $O: Tmp2096.Order, $C: enum Tmp2096.Color) +begin + call microflow Tmp2096.Target (Qty = 5); + call microflow Tmp2096.Target (Qty = $L); + call microflow Tmp2096.Target (Qty = empty); + call microflow Tmp2096.T_Dec (P = 5); + call microflow Tmp2096.T_Dec (P = $O/Qty); + call microflow Tmp2096.T_Color (P = Tmp2096.Color.Red); + call microflow Tmp2096.T_Color (P = $C); + call microflow Tmp2096.T_Color (P = $O/Color); + call microflow Tmp2096.T_Order (P = $O); + call microflow Tmp2096.T_Order (P = empty); +end; + +create nanoflow Tmp2096.NCaller_Ok ($O: Tmp2096.Order) +begin + call nanoflow Tmp2096.N_Int (P = $O/Qty); +end; diff --git a/mdl/executor/typecheck.go b/mdl/executor/typecheck.go index 0f9e68816..516d2db98 100644 --- a/mdl/executor/typecheck.go +++ b/mdl/executor/typecheck.go @@ -101,6 +101,53 @@ func declareScriptTypes(reader *exprcatalog.Reader, prog *ast.Program) { } } } + // Flows second, so a parameter typed by an enumeration or entity the + // script creates resolves whichever order the statements come in. + for _, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.CreateMicroflowStmt: + reader.DeclareFlow(s.Name.String(), scriptFlowParams(reader, s.Parameters)) + case *ast.CreateNanoflowStmt: + reader.DeclareFlow(s.Name.String(), scriptFlowParams(reader, s.Parameters)) + } + } +} + +// scriptFlowParams types the parameters of a flow the script creates, so a +// call to it is checked against them (ako/mxcli#2096). A bare `Module.Name` +// type is ambiguous — the parser cannot tell an entity from an enumeration — +// and is settled only by what the reader knows; otherwise it is left out, and +// its arguments are not checked. +func scriptFlowParams(reader *exprcatalog.Reader, params []ast.MicroflowParam) []exprcatalog.FlowParam { + out := make([]exprcatalog.FlowParam, 0, len(params)) + for _, p := range params { + fp := exprcatalog.FlowParam{Name: p.Name} + dt := p.Type + switch { + case dt.Kind == ast.TypeListOf && dt.EntityRef != nil: + fp.Kind, fp.Ref = exprcheck.KindList, dt.EntityRef.String() + case dt.Kind == ast.TypeEntity && dt.EntityRef != nil: + fp.Kind, fp.Ref = exprcheck.KindObject, dt.EntityRef.String() + case dt.Kind == ast.TypeEnumeration && dt.EnumRef != nil: + qn := dt.EnumRef.String() + _, isEnum := reader.EnumCases(qn) + switch { + case dt.ExplicitEnum || isEnum: + fp.Kind, fp.Ref = exprcheck.KindEnumeration, qn + case reader.KnowsEntity(qn): + fp.Kind, fp.Ref = exprcheck.KindObject, qn + } + case dt.Kind == ast.TypeEnumeration: + // No reference to compare: the kind alone. + fp.Kind = exprcheck.KindEnumeration + default: + if k, ok := adapters.DataTypeKind(dt.Kind); ok { + fp.Kind = k + } + } + out = append(out, fp) + } + return out } func declareScriptAttribute(reader *exprcatalog.Reader, entityQN, attr string, dt ast.DataType) { diff --git a/mdl/executor/typecheck_call_argument_2096_test.go b/mdl/executor/typecheck_call_argument_2096_test.go new file mode 100644 index 000000000..343d1bf02 --- /dev/null +++ b/mdl/executor/typecheck_call_argument_2096_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// The targets every case below calls, one parameter each. They are created in +// the SAME script as the caller, which is the shape the issue reports: the +// catalog only knows the stored project, so a flow the script creates must be +// overlaid onto it or every argument it takes stays unchecked. +const callArgTargets2096 = ` +CREATE ENUMERATION MyFirstModule.Color (Red 'Red', Blue 'Blue'); +CREATE ENUMERATION MyFirstModule.Size (Small 'Small', Large 'Large'); +CREATE PERSISTENT ENTITY MyFirstModule.Order (Qty: Integer, Color: Enumeration(MyFirstModule.Color)); +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Int ($P: Integer) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Long ($P: Long) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Dec ($P: Decimal) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Str ($P: String) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Bool ($P: Boolean) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Date ($P: DateTime) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Color ($P: enum MyFirstModule.Color) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Order ($P: MyFirstModule.Order) BEGIN END; +CREATE OR REPLACE MICROFLOW MyFirstModule.T_Orders ($P: List of MyFirstModule.Order) BEGIN END; +CREATE OR REPLACE NANOFLOW MyFirstModule.N_Int ($P: Integer) BEGIN END; +` + +// callArgCaller wraps one call in a caller whose parameters supply a value of +// every kind the matrix varies. +func callArgCaller2096(call string) string { + return ` +CREATE OR REPLACE MICROFLOW MyFirstModule.Caller ( + $L: Long, $I: Integer, $Dv: Decimal, $O: MyFirstModule.Order, + $Os: List of MyFirstModule.Order, $Sz: enum MyFirstModule.Size, $C: enum MyFirstModule.Color) +BEGIN + ` + call + ` +END; +` +} + +// TestTypeCheckCallArgumentMismatch2096 is ako/mxcli#2096 (mendixlabs/mxcli#1797): +// `check -p` passed a call argument whose type cannot match the called flow's +// parameter, and the build then failed CE0117 "Error(s) in expression" at the +// call activity. Every case here was measured CE0117 on mxbuild 11.14.0. +func TestTypeCheckCallArgumentMismatch2096(t *testing.T) { + cases := []struct{ name, call string }{ + {"string to Integer (the reported case)", `call microflow MyFirstModule.T_Int (P = '5');`}, + {"Decimal to Integer", `call microflow MyFirstModule.T_Int (P = $Dv);`}, + {"Decimal literal to Long", `call microflow MyFirstModule.T_Long (P = 5.5);`}, + {"Boolean to DateTime", `call microflow MyFirstModule.T_Date (P = true);`}, + {"Integer to String", `call microflow MyFirstModule.T_Str (P = $I);`}, + {"DateTime token to String", `call microflow MyFirstModule.T_Str (P = [%CurrentDateTime%]);`}, + {"string to Boolean", `call microflow MyFirstModule.T_Bool (P = 'true');`}, + {"value of another enumeration", `call microflow MyFirstModule.T_Color (P = MyFirstModule.Size.Small);`}, + {"variable of another enumeration", `call microflow MyFirstModule.T_Color (P = $Sz);`}, + {"string to enumeration", `call microflow MyFirstModule.T_Color (P = 'Red');`}, + {"enumeration to Integer", `call microflow MyFirstModule.T_Int (P = $C);`}, + {"primitive to object", `call microflow MyFirstModule.T_Order (P = 5);`}, + {"primitive to list", `call microflow MyFirstModule.T_Orders (P = 'x');`}, + {"bare word", `call microflow MyFirstModule.T_Dec (P = Five);`}, + {"nanoflow argument", `call nanoflow MyFirstModule.N_Int (P = '5');`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exec := typeCheckFixture(t) + got := typeCheck(t, exec, callArgTargets2096+callArgCaller2096(tc.call)) + var hit bool + for _, v := range got { + if v.Location.DocumentName == "MyFirstModule.Caller" || strings.Contains(v.Message, "Caller") { + hit = true + } + } + if !hit { + t.Fatalf("check passed a call argument mxbuild reports as CE0117: %s\nviolations: %+v", tc.call, got) + } + }) + } +} + +// TestTypeCheckCallArgumentLegal2096 is the control: the coercions Mendix +// makes, `empty`, and everything the checker cannot type must stay silent. +// Each case was measured 0 errors on mxbuild 11.14.0 (or is unresolvable, where +// a refusal would block exec on a guess). +func TestTypeCheckCallArgumentLegal2096(t *testing.T) { + cases := []struct{ name, call string }{ + {"Integer to Integer", `call microflow MyFirstModule.T_Int (P = 5);`}, + {"Long to Integer", `call microflow MyFirstModule.T_Int (P = $L);`}, + {"Integer to Long", `call microflow MyFirstModule.T_Long (P = $I);`}, + {"Integer to Decimal", `call microflow MyFirstModule.T_Dec (P = 5);`}, + {"Long to Decimal", `call microflow MyFirstModule.T_Dec (P = $L);`}, + {"attribute to Decimal", `call microflow MyFirstModule.T_Dec (P = $O/Qty);`}, + {"string to String", `call microflow MyFirstModule.T_Str (P = '5');`}, + {"DateTime token to DateTime", `call microflow MyFirstModule.T_Date (P = [%CurrentDateTime%]);`}, + {"matching enumeration value", `call microflow MyFirstModule.T_Color (P = MyFirstModule.Color.Red);`}, + {"matching enumeration variable", `call microflow MyFirstModule.T_Color (P = $C);`}, + {"matching enumeration attribute", `call microflow MyFirstModule.T_Color (P = $O/Color);`}, + {"object of the right entity", `call microflow MyFirstModule.T_Order (P = $O);`}, + {"list of the right entity", `call microflow MyFirstModule.T_Orders (P = $Os);`}, + {"empty to Integer", `call microflow MyFirstModule.T_Int (P = empty);`}, + {"empty to enumeration", `call microflow MyFirstModule.T_Color (P = empty);`}, + {"empty to object", `call microflow MyFirstModule.T_Order (P = empty);`}, + {"empty to list", `call microflow MyFirstModule.T_Orders (P = empty);`}, + {"empty to Boolean", `call microflow MyFirstModule.T_Bool (P = empty);`}, + {"unknown variable", `call microflow MyFirstModule.T_Int (P = $Nope);`}, + {"unknown flow", `call microflow MyFirstModule.NotThere (P = '5');`}, + {"unknown parameter", `call microflow MyFirstModule.T_Int (Other = '5');`}, + {"nanoflow argument", `call nanoflow MyFirstModule.N_Int (P = 5);`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exec := typeCheckFixture(t) + got := typeCheck(t, exec, callArgTargets2096+callArgCaller2096(tc.call)) + if len(got) != 0 { + t.Fatalf("a call argument that builds was refused: %s\nviolations: %+v", tc.call, got) + } + }) + } +} + +// TestTypeCheckCallArgumentStoredTarget2096 covers a target that is already in +// the project rather than created by the script: its parameter type comes from +// the catalog, with no overlay. +func TestTypeCheckCallArgumentStoredTarget2096(t *testing.T) { + exec := typeCheckFixture(t) + run(t, exec, `CREATE MICROFLOW MyFirstModule.Stored ($Qty: Integer) BEGIN END;`) + + bad := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.Caller () BEGIN + call microflow MyFirstModule.Stored (Qty = '5'); +END;`) + if len(bad) == 0 { + t.Fatalf("a String argument to a stored flow's Integer parameter passed check") + } + good := typeCheck(t, exec, ` +CREATE OR REPLACE MICROFLOW MyFirstModule.Caller () BEGIN + call microflow MyFirstModule.Stored (Qty = 5); +END;`) + if len(good) != 0 { + t.Fatalf("an Integer argument to an Integer parameter was refused: %+v", good) + } +} diff --git a/mdl/exprcatalog/exprcatalog.go b/mdl/exprcatalog/exprcatalog.go index b0214acba..4cd56ad6f 100644 --- a/mdl/exprcatalog/exprcatalog.go +++ b/mdl/exprcatalog/exprcatalog.go @@ -57,6 +57,11 @@ type Reader struct { enumCase map[string][]string mfReturn map[string]exprcheck.TypeKind mfParam map[string]exprcheck.TypeKind + // mfParamRef is the element a parameter's type names — the enumeration of + // an Enumeration parameter, the entity of an Object or List one — keyed + // like mfParam. A call argument of the wrong enumeration is only + // detectable with it (ako/mxcli#2096). + mfParamRef map[string]string // assoc maps an association's qualified name to its two ends, FROM first. assoc map[string][2]string } @@ -74,12 +79,13 @@ func Load(db Querier) (*Reader, error) { return nil, fmt.Errorf("exprcatalog: nil catalog") } r := &Reader{ - attrKind: map[string]exprcheck.TypeKind{}, - attrEnum: map[string]string{}, - enumCase: map[string][]string{}, - mfReturn: map[string]exprcheck.TypeKind{}, - mfParam: map[string]exprcheck.TypeKind{}, - assoc: map[string][2]string{}, + attrKind: map[string]exprcheck.TypeKind{}, + attrEnum: map[string]string{}, + enumCase: map[string][]string{}, + mfReturn: map[string]exprcheck.TypeKind{}, + mfParam: map[string]exprcheck.TypeKind{}, + mfParamRef: map[string]string{}, + assoc: map[string][2]string{}, } for _, load := range []func(Querier) error{ r.loadAttributes, r.loadEnumValues, r.loadMicroflows, r.loadParameters, @@ -175,7 +181,11 @@ func (r *Reader) loadParameters(db Querier) error { continue } if k, ok := flowKind(paramType.String); ok { - r.mfParam[qn.String+"("+name.String] = k + key := qn.String + "(" + name.String + r.mfParam[key] = k + if _, ref, ok := strings.Cut(paramType.String, ":"); ok && ref != "" { + r.mfParamRef[key] = ref + } } } return rows.Err() @@ -234,6 +244,68 @@ func (r *Reader) DeclareAttribute(entityQN, attrName string, kind exprcheck.Type } } +// FlowParam is one parameter of a flow a script creates, as DeclareFlow +// records it. Ref names the enumeration or entity the type refers to, when it +// has one. +type FlowParam struct { + Name string + Kind exprcheck.TypeKind + Ref string +} + +// DeclareFlow records the parameters of a microflow or nanoflow a script +// creates, replacing whatever the catalog held for that name — a CREATE OR +// REPLACE can change the parameter list, and a parameter that is gone must not +// keep its stored type. +// +// Without it a call to a flow created in the same script had no parameter to +// compare its arguments with, and the issue's own reproduction — target and +// caller in one script — checked clean (ako/mxcli#2096). A parameter whose +// kind could not be established is left out, so its arguments stay unchecked. +func (r *Reader) DeclareFlow(qn string, params []FlowParam) { + if qn == "" { + return + } + prefix := qn + "(" + for k := range r.mfParam { + if strings.HasPrefix(k, prefix) { + delete(r.mfParam, k) + } + } + for k := range r.mfParamRef { + if strings.HasPrefix(k, prefix) { + delete(r.mfParamRef, k) + } + } + for _, p := range params { + name := strings.TrimPrefix(p.Name, "$") + if name == "" || p.Kind == exprcheck.KindUnknown { + continue + } + r.mfParam[prefix+name] = p.Kind + if p.Ref != "" { + r.mfParamRef[prefix+name] = p.Ref + } + } +} + +// KnowsEntity reports whether any attribute of entityQN is indexed. It is how +// a script's bare `Module.Name` parameter type — which the parser cannot tell +// from an enumeration — is settled as an entity; an entity with no attributes +// stays unsettled, and its arguments unchecked. +func (r *Reader) KnowsEntity(entityQN string) bool { + if entityQN == "" { + return false + } + prefix := entityQN + "." + for k := range r.attrKind { + if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") { + return true + } + } + return false +} + // AssociationTarget returns the entity at the other end of an association. // // Both directions resolve: a Mendix expression follows an association from its @@ -292,6 +364,14 @@ func (r *Reader) MicroflowParam(qn, paramName string) (exprcheck.TypeKind, bool) return k, ok } +// MicroflowParamRef returns the enumeration or entity a parameter's type +// names: "Mod.Color" for an Enumeration(Mod.Color) parameter. It is the +// optional exprcheck.FlowParamRefReader half of the reader. +func (r *Reader) MicroflowParamRef(qn, paramName string) (string, bool) { + ref, ok := r.mfParamRef[qn+"("+strings.TrimPrefix(paramName, "$")] + return ref, ok && ref != "" +} + // attributeKind maps a domain-model attribute's stored type name. // // The names come from domainmodel's GetTypeName, which is the bare kind — an diff --git a/mdl/exprcatalog/exprcatalog_test.go b/mdl/exprcatalog/exprcatalog_test.go index 5d1f00c05..30ef6dd16 100644 --- a/mdl/exprcatalog/exprcatalog_test.go +++ b/mdl/exprcatalog/exprcatalog_test.go @@ -238,3 +238,42 @@ func TestAssociationTarget(t *testing.T) { } } } + +// TestMicroflowParamRef pins the element half of a parameter's type, which is +// what tells one enumeration from another for a call argument (ako/mxcli#2096). +func TestMicroflowParamRef(t *testing.T) { + r := seeded(t) + var _ exprcheck.FlowParamRefReader = r + if got, ok := r.MicroflowParamRef("Shop.ACT_Total", "$Order"); !ok || got != "Shop.Order" { + t.Errorf("got (%q, %v), want Shop.Order", got, ok) + } + if got, ok := r.MicroflowParamRef("Shop.ACT_Total", "Discount"); ok { + t.Errorf("a primitive parameter named an element: %q", got) + } +} + +// TestDeclareFlowReplacesStoredParameters: a script's CREATE OR REPLACE can +// change a flow's parameters, so a declared flow must not keep a stored +// parameter it no longer has, and must not leak into another flow. +func TestDeclareFlowReplacesStoredParameters(t *testing.T) { + r := seeded(t) + r.DeclareFlow("Shop.ACT_Total", []FlowParam{ + {Name: "$Status", Kind: exprcheck.KindEnumeration, Ref: "Shop.OrderStatus"}, + {Name: "Unknown", Kind: exprcheck.KindUnknown}, + }) + if _, ok := r.MicroflowParam("Shop.ACT_Total", "Discount"); ok { + t.Error("a stored parameter survived the script's redefinition") + } + if got, ok := r.MicroflowParam("Shop.ACT_Total", "Status"); !ok || got != exprcheck.KindEnumeration { + t.Errorf("got (%v, %v), want the declared Enumeration", got, ok) + } + if got, _ := r.MicroflowParamRef("Shop.ACT_Total", "Status"); got != "Shop.OrderStatus" { + t.Errorf("ref is %q, want Shop.OrderStatus", got) + } + if _, ok := r.MicroflowParam("Shop.ACT_Total", "Unknown"); ok { + t.Error("a parameter of unknown kind was recorded; it must stay unchecked") + } + if !r.KnowsEntity("Shop.Order") || r.KnowsEntity("Shop") || r.KnowsEntity("Shop.Nope") { + t.Error("KnowsEntity must answer only for an entity with an indexed attribute") + } +} diff --git a/mdl/exprcheck/adapters/check.go b/mdl/exprcheck/adapters/check.go index 7b335bd35..5174db1a1 100644 --- a/mdl/exprcheck/adapters/check.go +++ b/mdl/exprcheck/adapters/check.go @@ -151,12 +151,15 @@ func (c *CheckAdapter) walkBodyWithScope(body []ast.MicroflowStatement, mf strin c.checkExpr(ci.Value, slot, mf, r) } case *ast.CallMicroflowStmt: + // The slot names the parameter the argument is passed to, so the + // checker can resolve its type ("TargetParameter") the way a + // CHANGE member names its attribute (ako/mxcli#2096). for _, a := range n.Arguments { - c.checkExpr(a.Value, "CallArgument.Value", mf, r) + c.checkExpr(a.Value, exprcheck.CallArgumentSlot(n.MicroflowName.String(), a.Name), mf, r) } case *ast.CallNanoflowStmt: for _, a := range n.Arguments { - c.checkExpr(a.Value, "CallArgument.Value", mf, r) + c.checkExpr(a.Value, exprcheck.CallArgumentSlot(n.NanoflowName.String(), a.Name), mf, r) } case *ast.SendEmailStmt: c.checkSendEmail(n, mf, r) diff --git a/mdl/exprcheck/call_argument.go b/mdl/exprcheck/call_argument.go new file mode 100644 index 000000000..256c1446d --- /dev/null +++ b/mdl/exprcheck/call_argument.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: Apache-2.0 + +package exprcheck + +import ( + "strings" + + "github.com/mendixlabs/mxcli/mdl/exprcheck/hints" +) + +// CallArgumentSlot is the slot path of one call microflow / call nanoflow +// argument, qualified with the parameter it is passed to: +// "CallArgument.Value:Mod.Flow(Param)". It is the TargetParameter resolution the +// expectations table declares, in the same shape the adapter already uses for +// AttributeOf:Parent ("ChangeItem.Value:Mod.Entity.Attr"). +func CallArgumentSlot(flowQN, param string) string { + param = strings.TrimPrefix(param, "$") + if flowQN == "" || param == "" { + return "CallArgument.Value" + } + return "CallArgument.Value:" + flowQN + "(" + param + ")" +} + +// splitCallArgumentSlot reverses CallArgumentSlot. +func splitCallArgumentSlot(slotPath string) (flowQN, param string, ok bool) { + base, qual := splitSlotQual(slotPath) + if base != "CallArgument.Value" || !strings.HasSuffix(qual, ")") { + return "", "", false + } + flowQN, param, ok = strings.Cut(strings.TrimSuffix(qual, ")"), "(") + if !ok || flowQN == "" || param == "" { + return "", "", false + } + return flowQN, param, true +} + +// checkCallArgumentKind compares a call microflow / call nanoflow argument with +// the type of the parameter it is passed to (ako/mxcli#2096, +// mendixlabs/mxcli#1797). +// +// The expectations table had declared `CallArgument.Value` as resolved by +// "TargetParameter" all along, but nothing resolved it and checkSlotKind skips +// every slot with a ResolveBy — so `call microflow M.F (Qty = '5')` against an +// Integer parameter passed check and exec, and failed the build with CE0117 +// "Error(s) in expression" at the call activity. +// +// The predicate is the matrix measured on mxbuild 11.14.0 (9 parameter types x +// 19 argument shapes): Integer and Long pass into each other and widen into +// Decimal, every other primitive mismatch is CE0117, an enumeration must be the +// SAME enumeration, and an object or list parameter takes no primitive. Three +// answers, as everywhere in this checker: anything it cannot establish — +// a flow or parameter the catalog does not know, an argument of unknown kind — +// is silence, not an error, because exec refuses what check reports. +// +// Two measured CE0117s are deliberately NOT reported: +// - `empty` for a Boolean parameter fails the build, but `empty` is accepted +// for every other type and is Mendix's null; refusing it needs its own +// measurement across versions rather than a corner of this rule. +// - an object of another entity, and a list where an object is wanted: the +// entity scope cannot tell an object variable from a list variable, and +// specialisation makes entity identity a hierarchy question. Silence. +func checkCallArgumentKind(expr RobustExpr, ctx Context) []Hint { + if ctx.Catalog == nil { + return nil + } + flowQN, param, ok := splitCallArgumentSlot(ctx.SlotPath) + if !ok { + return nil + } + want, ok := ctx.Catalog.MicroflowParam(flowQN, param) + if !ok || want == KindUnknown || want == KindAny { + return nil + } + wantRef := "" + if rr, ok := ctx.Catalog.(FlowParamRefReader); ok { + wantRef, _ = rr.MicroflowParamRef(flowQN, param) + } + got := argKind(expr, ctx) + gotEnum := exprEnumQN(expr, ctx) + if gotEnum != "" { + // Settles an enumeration variable the scope could only call Object. + got = KindEnumeration + } + if callArgAccepted(want, wantRef, got, gotEnum) { + return nil + } + + wantName := callArgTypeName(want, wantRef) + gotName := callArgTypeName(got, gotEnum) + where := hintsLocation(ctx, expr.Pos()) + where.Context = "argument $" + param + " of the call to " + flowQN + return []Hint{{ + Code: "E009", + Slug: "slot-type-mismatch", + Severity: hints.SeverityError, + Where: where, + YouWrote: "$" + param + " = <" + gotName + ">", + Problem: "Parameter $" + param + " of " + flowQN + " is " + withArticle(wantName) + + ", but the argument passed to it is " + withArticle(gotName) + ". Mendix converts a call " + + "argument only from Integer or Long to Decimal (and between Integer and Long); anything else " + + "is CE0117 \"Error(s) in expression\" at the call activity.", + Fix: callArgFix(want, wantRef, got), + }} +} + +// callArgAccepted reports whether an argument may be passed to a parameter. +// It refuses only what the 11.14.0 matrix showed to be CE0117. +func callArgAccepted(want TypeKind, wantRef string, got TypeKind, gotRef string) bool { + switch got { + case KindUnknown, KindEmpty, KindAny: + return true + } + primitive := func(k TypeKind) bool { + switch k { + case KindBoolean, KindString, KindInteger, KindLong, KindDecimal, KindDateTime: + return true + } + return false + } + switch { + case primitive(want): + switch { + case primitive(got): + // The same numeric widening the built-in argument check measured + // (mendixlabs/mxcli#1515) — one rule, so the two cannot drift. + return argAccepted([]TypeKind{want}, got) + case got == KindEnumeration, got == KindObject: + // Measured CE0117 for every primitive parameter. An Object here may + // really be a bare-named enumeration variable, which fails too. + return false + } + return true + case want == KindEnumeration: + switch { + case primitive(got): + return false + case got == KindEnumeration: + // Only two KNOWN, different enumerations are a mismatch. + return wantRef == "" || gotRef == "" || wantRef == gotRef + } + // An Object may be a bare-named enumeration variable: cannot tell. + return true + case want == KindObject || want == KindList: + return !primitive(got) && got != KindEnumeration + } + return true +} + +// exprEnumQN returns the enumeration an argument's value belongs to, when the +// argument names one outright: a value literal (Mod.Enum.Value), an +// enumeration variable, or an attribute path landing on an enumeration. +func exprEnumQN(e RobustExpr, ctx Context) string { + switch n := unwrapParens(e).(type) { + case *QNameExpr: + if n.Sub == "" || ctx.Catalog == nil { + return "" + } + qn := n.Module + "." + n.Name + if _, ok := ctx.Catalog.EnumCases(qn); ok { + return qn + } + case *VariableExpr: + // The entity scope records the type name an object OR enumeration + // variable was declared with — in a microflow signature `enum M.E` + // does not set ExplicitEnum, so the kind scope does not say which. + // Document names are unique within a module, so a name the catalog + // knows as an enumeration is one. + if ctx.Entities == nil || ctx.Catalog == nil { + return "" + } + if qn, ok := ctx.Entities.VariableEntity(n.Name); ok { + if _, isEnum := ctx.Catalog.EnumCases(qn); isEnum { + return qn + } + } + case *AttributePathExpr: + if qn, ok := attributePathEnumQN(n, ctx); ok { + return qn + } + } + return "" +} + +func callArgTypeName(k TypeKind, ref string) string { + name := typeKindName(k) + if ref == "" { + return name + } + switch k { + case KindEnumeration, KindObject: + return name + " " + ref + case KindList: + return "List of " + ref + } + return name +} + +func callArgFix(want TypeKind, wantRef string, got TypeKind) string { + switch { + case want == KindEnumeration && wantRef != "": + return "Pass a value of " + wantRef + " (" + wantRef + ".), a variable or attribute of that enumeration, or empty." + case want == KindString: + return "Convert the value, e.g. with toString(...)." + case (want == KindInteger || want == KindLong) && got == KindDecimal: + return "Round the value (round/floor/ceil), or change the parameter to Decimal." + case (want == KindInteger || want == KindLong || want == KindDecimal) && got == KindString: + return "Pass a number, or convert the text with parseInteger(...) / parseDecimal(...)." + case want == KindObject || want == KindList: + return "Pass an object variable (or list) of the parameter's entity, or empty." + } + return "Pass an expression of type " + callArgTypeName(want, wantRef) + "." +} diff --git a/mdl/exprcheck/interfaces.go b/mdl/exprcheck/interfaces.go index c727cdd62..08648399e 100644 --- a/mdl/exprcheck/interfaces.go +++ b/mdl/exprcheck/interfaces.go @@ -71,6 +71,17 @@ type CatalogReader interface { MicroflowParam(qn, paramName string) (TypeKind, bool) } +// FlowParamRefReader is the optional half of a CatalogReader that names what a +// flow parameter's type refers to — the enumeration of an Enumeration +// parameter, the entity of an Object or List one. MicroflowParam answers only +// the kind, which cannot tell one enumeration from another; a call argument of +// the wrong enumeration needs this (ako/mxcli#2096). It is optional so that +// CatalogReader's shape stays as it was: a reader without it leaves +// enumeration identity unchecked. +type FlowParamRefReader interface { + MicroflowParamRef(qn, paramName string) (string, bool) +} + type Scope interface { Lookup(name string) (TypeKind, bool) } diff --git a/mdl/exprcheck/parser.go b/mdl/exprcheck/parser.go index 564ed18aa..8efab0c52 100644 --- a/mdl/exprcheck/parser.go +++ b/mdl/exprcheck/parser.go @@ -63,6 +63,7 @@ func (p *parserImpl) Parse(src string, ctx Context) (RobustExpr, []Hint) { } hs = append(hs, checkValueLiterals(expr, ctx)...) hs = append(hs, checkSlotKind(expr, ctx)...) + hs = append(hs, checkCallArgumentKind(expr, ctx)...) hs = append(hs, checkBareIdentifierValue(expr, ctx)...) hs = append(hs, checkStringFuncsComparedWithEmpty(expr, nil, ctx)...) return expr, hs @@ -128,6 +129,10 @@ func checkSlotKind(expr RobustExpr, ctx Context) []Hint { // Unknown. Unknown is tolerated everywhere by design, which is exactly why this // slipped through check and exec to arrive as CE0117 (mendixlabs/mxcli#1044). // +// A call microflow / call nanoflow argument is the same position: the whole +// expression is the value handed to a parameter, and a bare word there was +// measured CE0117 for every parameter type on 11.14.0 (ako/mxcli#2096). +// // Scoped to the WHOLE expression of a create/change member, and that scoping is // the load-bearing part rather than caution for its own sake: a bare name NESTED // inside a list-operation predicate is legal MDL — `FILTER($L, Status = 'Open')` @@ -137,7 +142,8 @@ func checkSlotKind(expr RobustExpr, ctx Context) []Hint { // mistake. func checkBareIdentifierValue(expr RobustExpr, ctx Context) []Hint { if !strings.HasPrefix(ctx.SlotPath, "ChangeItem.Value") && - !strings.HasPrefix(ctx.SlotPath, "CreateItem.Value") { + !strings.HasPrefix(ctx.SlotPath, "CreateItem.Value") && + !strings.HasPrefix(ctx.SlotPath, "CallArgument.Value") { return nil } v, ok := expr.(*VariableExpr) diff --git a/mdl/exprcheck/slot_resolver.go b/mdl/exprcheck/slot_resolver.go index dceaec532..901500ee9 100644 --- a/mdl/exprcheck/slot_resolver.go +++ b/mdl/exprcheck/slot_resolver.go @@ -17,8 +17,11 @@ var staticExpectations = map[string]SlotConstraint{ "ChangeItem.Value": {Kind: KindUnknown, ResolveBy: "AttributeOf:Parent"}, "CreateItem.Value": {Kind: KindUnknown, ResolveBy: "AttributeOf:Parent"}, "ReturnStmt.Value": {Kind: KindUnknown, ResolveBy: "MicroflowReturn"}, - "CallArgument.Value": {Kind: KindUnknown, ResolveBy: "TargetParameter"}, - "LogStmt.Message": {Kind: KindString}, + // Resolved per call site: the adapter qualifies the slot with the called + // flow and parameter (CallArgumentSlot) and checkCallArgumentKind looks up + // the parameter's type (ako/mxcli#2096). Nothing resolved it before. + "CallArgument.Value": {Kind: KindUnknown, ResolveBy: "TargetParameter"}, + "LogStmt.Message": {Kind: KindString}, // A log message's template parameter must be a String — Mendix does not // coerce here, and a non-String one is CE0117 "Error(s) in expression" on // the activity. Measured on 11.13.0: an Integer attribute, an integer From d88e9e35099a698c1f2867e076451dcbcc010627 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:00:03 +0000 Subject: [PATCH 17/26] fix: check reports a menu item that calls a flow or page needing a parameter (CE1571) A navigation-profile or menu-document item that calls a microflow or nanoflow with a parameter, or shows a page with a required parameter, passed `check -p` and exec, and mx check 11.14.0 then reported CE1571 "No argument has been selected for parameter 'Thing' and no default is available" at Menu item. The CE1571 rule only walked widget trees. validateMenuItemArguments judges every menu item of create navigation, create menu and alter navigation/menu (via menuAlterView) with argDiff in an empty data context. Measured on mxbuild 11.14.0: object/String flow parameters and required page parameters are CE1571 (6 of 6 items); a parameterless flow or page, an optional page parameter and a literal argument build at 0 errors and stay silent. Script-declared flows and pages count; an unresolved target stays silent. Refs ako/mxcli#2098, mendixlabs/mxcli#1799 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- ...-menu-item-flow-page-parameter-ce1571.json | 1 + .../2098-menu-item-flow-page-parameter.mdl | 57 ++++ mdl/executor/validate.go | 9 + mdl/executor/validate_menu_item_args.go | 247 ++++++++++++++++++ mdl/executor/validate_menu_item_args_test.go | 182 +++++++++++++ 5 files changed, 496 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-menu-item-flow-page-parameter-ce1571.json create mode 100644 mdl-examples/bug-tests/2098-menu-item-flow-page-parameter.mdl create mode 100644 mdl/executor/validate_menu_item_args.go create mode 100644 mdl/executor/validate_menu_item_args_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-menu-item-flow-page-parameter-ce1571.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-menu-item-flow-page-parameter-ce1571.json new file mode 100644 index 000000000..1a14b5486 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-menu-item-flow-page-parameter-ce1571.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"A navigation or menu-document menu item calling a microflow/nanoflow with a parameter, or showing a page with a required parameter, passed check -p and exec; mx check 11.14.0 then reported CE1571 \"No argument has been selected for parameter 'Thing' and no default is available\" at Menu item","cause":"The CE1571 rule (validateFlowArguments/argDiff) only walked widget trees; menu items are not widgets, so no menu statement ever reached argDiff","file":"mdl/executor/validate_menu_item_args.go","insight":"A rule keyed on a construct's CONTAINER (widgets) misses every other container of the same call: grep for every AST that holds an ActionV3 (menu items, alter navigation set/insert fragments via menuAlterView) when a CE is 'any call whose parameters are not filled'. A menu item is argDiff in an EMPTY dataContext. Measured 11.14.0, one project per variant: object/String flow params, required object/String page params, menu document and alter-navigation insert -> 6 CE1571 (one per item); parameterless flow/page, an optional page parameter (11.5+) and a literal argument `(Text = 'hello')` -> 0 errors, so an optional page parameter and any stated argument are not reported. `create object E then show page P` passes the new object, so only the item's own action is judged; an unresolved target stays silent (validateNavigationTargets owns not-found). Script-declared pages needed their whole ast.PageParameter (Optional) recorded — sc.pageParamNames keeps names only.","refs":["ako/mxcli#2098","mendixlabs/mxcli#1799"],"ce":["CE1571"]} diff --git a/mdl-examples/bug-tests/2098-menu-item-flow-page-parameter.mdl b/mdl-examples/bug-tests/2098-menu-item-flow-page-parameter.mdl new file mode 100644 index 000000000..81147272a --- /dev/null +++ b/mdl-examples/bug-tests/2098-menu-item-flow-page-parameter.mdl @@ -0,0 +1,57 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#2098 (mendixlabs/mxcli#1799) — a menu item that calls a flow with +-- a parameter, or shows a page with a required one, passed `check -p` and +-- exec, and mx check 11.14.0 then reported: +-- +-- [error] [CE1571] "No argument has been selected for parameter 'Thing' and +-- no default is available. Please select an argument manually." at Menu item +-- +-- A menu has no context object, so nothing fills the parameter in. +-- `check -p` now reports such an item in a navigation profile, a menu document +-- and an alter of either, e.g. +-- +-- menu item 'Open' ( OnClick: call microflow Issue2098.ACT_Thing ) +-- menu item 'View' ( OnClick: show page Issue2098.Thing_View ) +-- +-- This script holds the controls, measured at 0 errors on mxbuild 11.14.0: +-- a parameterless flow and page, an optional page parameter (11.5+), and a +-- literal argument for a primitive flow parameter. Without a project the rule +-- has no signatures to judge, so the corpus run is syntax only. +-- +-- Verify: +-- mxcli exec 2098-menu-item-flow-page-parameter.mdl -p app.mpr +-- mxcli docker check -p app.mpr -- 0 errors +-- (add `call microflow Issue2098.ACT_Text` without an argument to the menu: +-- check -p refuses it; forced through with --no-check, mx check is CE1571) +-- ============================================================================ + +create module Issue2098; + +create persistent entity Issue2098.Thing ( Name: String(100) ); + +create microflow Issue2098.ACT_Plain () +begin +end; + +create microflow Issue2098.ACT_Text ($Text: String) +begin +end; + +create page Issue2098.Plain_View (Title: 'Plain', Layout: Atlas_Core.Atlas_Default) { + dynamictext txtPlain (Content: 'Plain') +}; + +create page Issue2098.Maybe_View ( + Params: ( $Thing: Issue2098.Thing optional ), + Title: 'Maybe', Layout: Atlas_Core.Atlas_Default +) { + dynamictext txtMaybe (Content: 'Maybe') +}; + +create menu Issue2098.Side_Menu { + menu item 'Plain' ( OnClick: call microflow Issue2098.ACT_Plain ) + menu item 'PlainPage' ( OnClick: show page Issue2098.Plain_View ) + menu item 'Maybe' ( OnClick: show page Issue2098.Maybe_View ) + menu item 'Literal' ( OnClick: call microflow Issue2098.ACT_Text(Text = 'hello') ) +}; diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index f7a482970..339caf959 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -113,6 +113,10 @@ type scriptContext struct { // ordinarily one script. pageParams map[string][]string // Module.Page (lower-cased) -> parameter entities pageParamNames map[string][]string // Module.Page (lower-cased) -> parameter names, for show page arguments + // pageParamDecls keeps a script-declared page's parameters whole, for the + // menu item CE1571 check, which needs to know which are optional + // (ako/mxcli#2098). + pageParamDecls map[string][]ast.PageParameter // pageParamEdits is each page's add/drop parameter operations from alter // page statements, in script order, applied over pageParamNames or the // stored list (ako/mxcli#1940). @@ -189,6 +193,7 @@ func newScriptContext() *scriptContext { pageParams: make(map[string][]string), pageParamNames: make(map[string][]string), + pageParamDecls: make(map[string][]ast.PageParameter), pageParamEdits: make(map[string][]ast.AlterPageOperation), indirectDocs: make(map[string]bool), renamedModules: make(map[string]bool), @@ -365,6 +370,7 @@ func (sc *scriptContext) collectSingle(stmt ast.Statement) { sc.pages[s.Name.String()] = true sc.recordPageParams(s.Name.String(), s.Parameters) sc.recordPageParamNames(s.Name.String(), s.Parameters) + sc.pageParamDecls[strings.ToLower(s.Name.String())] = s.Parameters } case *ast.CreateSnippetStmtV3: if s.Name.Module != "" { @@ -547,6 +553,9 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // unchecked, so a typo was CE1613 at build time, after the whole menu had // been rewritten. errors = append(errors, validateNavigationTargets(ctx, prog, sc)...) + // CE1571 on a menu item: a menu has no context object, so a flow or page + // parameter nothing fills is a build error (ako/mxcli#2098). + errors = append(errors, validateMenuItemArguments(ctx, prog, sc)...) // Resolve what a GRANT / REVOKE, a user role and a demo user name: the // entity, document or member, and the module and user roles. exec refuses // the grants after the statements before them are written, and writes the diff --git a/mdl/executor/validate_menu_item_args.go b/mdl/executor/validate_menu_item_args.go new file mode 100644 index 000000000..95394c947 --- /dev/null +++ b/mdl/executor/validate_menu_item_args.go @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 + +// CE1571 on a menu item (ako/mxcli#2098, mendixlabs/mxcli#1799). +// +// A navigation profile's or menu document's item that calls a microflow or +// nanoflow with a parameter, or shows a page with a required parameter, passed +// `check -p` and exec, and mx check then reported +// +// [CE1571] "No argument has been selected for parameter 'Thing' and no +// default is available. Please select an argument manually." at Menu item +// +// validateFlowArguments judges the same fault for a widget's data source and +// actions, but walks widgets only; a menu item is not one. The rule here is the +// same argDiff, judged in an EMPTY data context: a menu has no context object +// to fill anything in. +// +// Measured on mxbuild 11.14.0, one project per variant, one `mx check` each: +// +// navigation item, call microflow M.F($Thing: M.Thing) → CE1571 +// navigation item in a group, call nanoflow N($Text: String) → CE1571 +// navigation item, show page P($Thing) required → CE1571 +// navigation item, show page P($Text: String) required → CE1571 +// menu document item, call microflow M.F($Thing) → CE1571 +// alter navigation insert, call microflow M.F($Text: String) → CE1571 +// (6 errors, one per item) +// +// parameterless microflow / page → 0 errors +// show page P($Thing optional), Mendix 11.5+ → 0 errors +// call microflow M.F(Text = 'hello') — a literal argument → 0 errors +// +// So an optional page parameter is not reported, and an argument the item +// states fills its parameter whatever the expression is. +// +// A `create object E then show page P` item hands the new object to the page, +// so the chain is not judged. A target that does not resolve is the not-found +// check's to report (validateNavigationTargets); here it stays silent rather +// than guess at a signature. +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// menuItemCall is one menu item's flow call or show page, as written. +type menuItemCall struct { + where string // `menu item 'Group' > 'Item'` + kind string // "microflow", "nanoflow" or "page" + target string // qualified name as written + args []ast.FlowArgV3 +} + +// menuItemCalls walks a menu's items at any depth, with menuItemTargets' +// wording for where each sits. +func menuItemCalls(items []ast.NavMenuItemDef, path string) []menuItemCall { + var out []menuItemCall + for _, it := range items { + where := "menu item " + path + "'" + it.Caption + "'" + a := it.Action + if a == nil { + // The old clause spelling, or a hand-built AST. + switch { + case it.Page != nil: + a = &ast.ActionV3{Type: "showPage", Target: it.Page.String()} + case it.Microflow != nil: + a = &ast.ActionV3{Type: "microflow", Target: it.Microflow.String()} + } + } + // Only the item's own action: a chained show page follows a create + // object, which passes the new object to it. + if a != nil { + name := unquoteQualifiedName(strings.TrimSpace(a.Target)) + switch a.Type { + case "microflow", "nanoflow": + out = append(out, menuItemCall{where: where, kind: a.Type, target: name, args: a.Args}) + case "showPage": + out = append(out, menuItemCall{where: where, kind: "page", target: name, args: a.Args}) + } + } + out = append(out, menuItemCalls(it.Items, path+"'"+it.Caption+"' > ")...) + } + return out +} + +// menuStatementCalls returns the label and calls of a navigation, menu document +// or an alter of either; ok is false for any other statement. +func menuStatementCalls(stmt ast.Statement) (label string, calls []menuItemCall, ok bool) { + if v := menuAlterView(stmt); v != nil { + stmt = v + } + switch s := stmt.(type) { + case *ast.CreateNavigationStmt: + return "navigation " + s.ProfileName, menuItemCalls(s.MenuItems, ""), true + case *ast.CreateMenuStmt: + return "menu " + s.Name.String(), menuItemCalls(s.Items, ""), true + } + return "", nil, false +} + +// validateMenuItemArguments reports every menu item whose flow or page has a +// parameter nothing fills — CE1571, since a menu has no context object. +func validateMenuItemArguments(ctx *ExecContext, prog *ast.Program, sc *scriptContext) []error { + if prog == nil || !ctx.Connected() { + return nil + } + var flows map[string]*flowSignature + var pageSigs map[string]*flowSignature + var errs []error + for i, stmt := range prog.Statements { + label, calls, ok := menuStatementCalls(stmt) + if !ok || len(calls) == 0 { + continue + } + if flows == nil { + flows = buildFlowSignatures(ctx) + if sc != nil { + for name, sig := range sc.flowParams { + flows[name] = sig + } + } + pageSigs = menuPageSignatures(ctx, sc) + } + var msgs []string + for _, c := range calls { + sig := flows[strings.ToLower(c.target)] + if c.kind == "page" { + sig = pageSigs[strings.ToLower(c.target)] + } + if sig == nil { + continue // unresolved: the not-found check reports it + } + missing, _, _ := argDiff(c.args, sig, dataContext{}, nil) + if len(missing) == 0 { + continue + } + msgs = append(msgs, menuItemArgMessage(c, missing, sig)) + } + if len(msgs) == 0 { + continue + } + if m, isMenu := stmt.(*ast.CreateMenuStmt); isMenu && storedMenuExcluded(ctx, m.Name) { + sc.warnExcluded("menu", m.Name.String(), msgs) + continue + } + errs = append(errs, fmt.Errorf("statement %d: %w", i+1, + mdlerrors.NewValidationf("%s has argument errors:\n - %s", label, strings.Join(msgs, "\n - ")))) + } + return errs +} + +// menuItemArgMessage words one item's report, with a remedy that was measured +// to build: a literal argument for a primitive flow parameter, an optional page +// parameter (11.5+), or a parameterless microflow that finds the object. +func menuItemArgMessage(c menuItemCall, missing []string, sig *flowSignature) string { + msg := fmt.Sprintf("%s: %s %s has no argument for %s %s, and a menu item has no context object to fill it in — "+ + "Mendix rejects this with CE1571", + c.where, c.kind, c.target, plural(len(missing), "parameter", "parameters"), quoteJoin(missing)) + if c.kind == "page" { + return msg + fmt.Sprintf(". Open the page from a parameterless microflow that supplies the argument, "+ + "or declare the parameter optional (`$%s: … optional`, Mendix 11.5+)", missing[0]) + } + object := false + for _, p := range sig.Params { + if strings.EqualFold(p.Name, missing[0]) { + object = p.Object + } + } + if object { + return msg + ". Call a parameterless microflow that retrieves the object and calls this one" + } + return msg + fmt.Sprintf(". Pass a value: `call %s %s(%s = )`", c.kind, c.target, missing[0]) +} + +// menuPageSignatures maps each page (lower-cased qualified name) to its REQUIRED +// parameters as a signature argDiff can judge: the stored pages, overlaid with +// what the script declares and with its alter page add/drop parameter edits. +func menuPageSignatures(ctx *ExecContext, sc *scriptContext) map[string]*flowSignature { + type param struct { + name string + required bool + } + decls := map[string][]param{} + if h, err := getHierarchy(ctx); err == nil && h != nil { + if all, err := ctx.Backend.ListPages(); err == nil { + for _, p := range liveFirst(all, pageExcluded) { + if p == nil { + continue + } + key := strings.ToLower(h.GetQualifiedName(p.ContainerID, p.Name)) + if _, seen := decls[key]; seen { + continue + } + ps := []param{} + for _, pp := range p.Parameters { + if pp != nil { + ps = append(ps, param{pp.Name, pp.IsRequired}) + } + } + decls[key] = ps + } + } + } + if sc != nil { + for key, params := range sc.pageParamDecls { + ps := []param{} + for _, p := range params { + ps = append(ps, param{p.Name, !p.Optional}) + } + decls[key] = ps + } + for key, edits := range sc.pageParamEdits { + ps, ok := decls[key] + if !ok { + continue + } + for _, op := range edits { + switch o := op.(type) { + case *ast.AddParameterOp: + ps = append(ps, param{o.Parameter.Name, !o.Parameter.Optional}) + case *ast.DropParameterOp: + kept := ps[:0:0] + for _, p := range ps { + if !strings.EqualFold(p.name, o.ParameterName) { + kept = append(kept, p) + } + } + ps = kept + } + } + decls[key] = ps + } + } + out := make(map[string]*flowSignature, len(decls)) + for key, ps := range decls { + sig := &flowSignature{} + for _, p := range ps { + if p.required { + sig.Params = append(sig.Params, flowParam{Name: p.name}) + } + } + out[key] = sig + } + return out +} diff --git a/mdl/executor/validate_menu_item_args_test.go b/mdl/executor/validate_menu_item_args_test.go new file mode 100644 index 000000000..a5a1baefa --- /dev/null +++ b/mdl/executor/validate_menu_item_args_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#2098 (mendixlabs/mxcli#1799): a menu item that calls a flow with a +// parameter, or shows a page with a required one, passed `check -p` and exec, +// and mx check 11.14.0 then reported +// +// [error] [CE1571] "No argument has been selected for parameter 'Thing' and +// no default is available. Please select an argument manually." at Menu item +// +// A menu has no context object, so nothing fills the parameter in. + +// newMenuArgFixture is the navigation fixture with parameterised documents: +// Shop.ACT_Thing($Thing: Shop.Thing), Shop.NF_Text($Text: String), +// Shop.Thing_View($Thing required), Shop.Maybe_View($Thing optional), and the +// parameterless Shop.ACT_Open / Shop.Home. +func newMenuArgFixture(t *testing.T) *navFixture { + t.Helper() + f := newNavFixture(t) + shopID := model.ID("") + if mods, _ := f.mb.ListModulesFunc(); len(mods) > 0 { + shopID = mods[0].ID + } + thing := µflows.MicroflowParameter{Name: "Thing", Type: µflows.ObjectType{EntityQualifiedName: "Shop.Thing"}} + text := µflows.MicroflowParameter{Name: "Text", Type: µflows.StringType{}} + f.mb.ListMicroflowsFunc = func() ([]*microflows.Microflow, error) { + withThing := mkMicroflow(shopID, "ACT_Thing") + withThing.Parameters = []*microflows.MicroflowParameter{thing} + withText := mkMicroflow(shopID, "ACT_Text") + withText.Parameters = []*microflows.MicroflowParameter{text} + return []*microflows.Microflow{mkMicroflow(shopID, "ACT_Open"), withThing, withText}, nil + } + f.mb.ListNanoflowsFunc = func() ([]*microflows.Nanoflow, error) { + nf := mkNanoflow(shopID, "NF_Text") + nf.Parameters = []*microflows.MicroflowParameter{text} + return []*microflows.Nanoflow{mkNanoflow(shopID, "NF_Go"), nf}, nil + } + f.mb.ListPagesFunc = func() ([]*pages.Page, error) { + required := mkPage(shopID, "Thing_View") + required.Parameters = []*pages.PageParameter{{Name: "Thing", EntityName: "Shop.Thing", IsRequired: true}} + optional := mkPage(shopID, "Maybe_View") + optional.Parameters = []*pages.PageParameter{{Name: "Thing", EntityName: "Shop.Thing", IsRequired: false}} + return []*pages.Page{mkPage(shopID, "Home"), required, optional}, nil + } + return f +} + +func TestMenuItemArgs_ReportsParameterNothingFills(t *testing.T) { + f := newMenuArgFixture(t) + got, _ := f.check(t, ` +create or modify navigation Responsive + home page Shop.Home +{ + menu item 'MfObject' ( OnClick: call microflow Shop.ACT_Thing ) + menu 'Group' { + menu item 'NfText' ( OnClick: call nanoflow Shop.NF_Text ) + } + menu item 'PageObject' ( OnClick: show page Shop.Thing_View ) +}; +create menu Shop.Side_Menu { + menu item 'DocMf' ( OnClick: call microflow Shop.ACT_Thing ) +}; +alter navigation Responsive { + insert { menu item 'AlterMf' ( OnClick: call microflow Shop.ACT_Text ) } +}; +`) + for _, want := range []string{ + "menu item 'MfObject': microflow Shop.ACT_Thing has no argument for parameter 'Thing'", + "menu item 'Group' > 'NfText': nanoflow Shop.NF_Text has no argument for parameter 'Text'", + "menu item 'PageObject': page Shop.Thing_View has no argument for parameter 'Thing'", + "menu item 'DocMf': microflow Shop.ACT_Thing has no argument for parameter 'Thing'", + "menu item 'AlterMf': microflow Shop.ACT_Text has no argument for parameter 'Text'", + } { + if !strings.Contains(got, want) { + t.Errorf("check -p does not report %q\ngot:\n%s", want, got) + } + } + if n := strings.Count(got, "CE1571"); n != 5 { + t.Errorf("want 5 CE1571 reports, one per measured mx check error, got %d:\n%s", n, got) + } +} + +// The controls mx check 11.14.0 builds at 0 errors: a parameterless flow and +// page, an optional page parameter, and a flow given a literal argument. +func TestMenuItemArgs_LegalItemsStaySilent(t *testing.T) { + f := newMenuArgFixture(t) + got, _ := f.check(t, ` +create or modify navigation Responsive + home page Shop.Home +{ + menu item 'Plain' ( OnClick: call microflow Shop.ACT_Open ) + menu item 'PlainNf' ( OnClick: call nanoflow Shop.NF_Go ) + menu item 'PlainPage' ( OnClick: show page Shop.Home ) + menu item 'Maybe' ( OnClick: show page Shop.Maybe_View ) + menu item 'Literal' ( OnClick: call microflow Shop.ACT_Text(Text = 'hello') ) +}; +create menu Shop.Side_Menu { + menu item 'DocPlain' ( OnClick: call microflow Shop.ACT_Open ) +}; +`) + if got != "" { + t.Errorf("legal menu items reported:\n%s", got) + } +} + +// A target that does not resolve is the not-found check's to report; this rule +// stays silent rather than guess at a signature. +func TestMenuItemArgs_UnresolvedTargetSilent(t *testing.T) { + f := newMenuArgFixture(t) + got, _ := f.check(t, ` +create or modify navigation Responsive + home page Shop.Home +{ + menu item 'Ghost' ( OnClick: call microflow Shop.NoSuchFlow ) + menu item 'GhostPage' ( OnClick: show page Shop.NoSuchPage ) +}; +`) + if strings.Contains(got, "CE1571") { + t.Errorf("an unresolved target was judged for arguments:\n%s", got) + } +} + +// Flows and pages the script itself creates are judged by their declaration. +func TestMenuItemArgs_ScriptCreatedTargets(t *testing.T) { + f := newMenuArgFixture(t) + got, _ := f.check(t, ` +create microflow Shop.ACT_New ($Count: Integer) +begin +end; +/ +create page Shop.New_View (Params: ( $Count: Integer ), Title: 'New', Layout: Atlas_Core.Atlas_Default) { + dynamictext txt (Content: 'New') +}; +create page Shop.Opt_View (Params: ( $Count: Integer optional ), Title: 'Opt', Layout: Atlas_Core.Atlas_Default) { + dynamictext txt (Content: 'Opt') +}; +create or modify navigation Responsive + home page Shop.Home +{ + menu item 'NewMf' ( OnClick: call microflow Shop.ACT_New ) + menu item 'NewPage' ( OnClick: show page Shop.New_View ) + menu item 'NewOptional' ( OnClick: show page Shop.Opt_View ) +}; +`) + for _, want := range []string{ + "menu item 'NewMf': microflow Shop.ACT_New has no argument for parameter 'Count'", + "menu item 'NewPage': page Shop.New_View has no argument for parameter 'Count'", + } { + if !strings.Contains(got, want) { + t.Errorf("check -p does not report %q\ngot:\n%s", want, got) + } + } + if strings.Contains(got, "Opt_View") { + t.Errorf("an optional page parameter was reported:\n%s", got) + } +} + +// `create object E then show page P` hands the new object to the page: the +// page's parameter is filled, so the item is not judged. +func TestMenuItemArgs_CreateObjectThenShowPageSilent(t *testing.T) { + f := newMenuArgFixture(t) + got, _ := f.check(t, ` +create or modify navigation Responsive + home page Shop.Home +{ + menu item 'New' ( OnClick: create object Shop.Thing then show page Shop.Thing_View ) +}; +`) + if strings.Contains(got, "CE1571") { + t.Errorf("create object then show page was judged:\n%s", got) + } +} From 65417786f3d63897e6aea3022aab64f259eedbb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 15:39:16 +0000 Subject: [PATCH 18/26] fix: write a widget attribute path through a specialization as a step (CE1613) A DataGrid 2 column bound to System.Session_User/Module.Account/FullName passed check and exec, then mxbuild failed CE1613 "The selected attribute 'System.Session_User/Module.Account/FullName' no longer exists." resolveAssociationAttributePath read every segment before the attribute as an association; the entity segment failed the lookup and every caller fell back to writing the whole path as one flat attribute name. An entity right after a hop now sets that hop's DestinationEntity when it is the hop's end or a specialization of it - the downcast Studio Pro stores, and what data source paths (resolveAssociationHops) already accept. mxbuild 11.14.0 accepts the result at 0 errors (A/B: base 3x CE1613, fixed 0). The shared resolver covers DataGrid 2 columns, input widgets and pluggable attributes. Refs ako/mxcli#2127, mendixlabs/mxcli#1808 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- ...-path-specialization-step-flat-ce1613.json | 1 + ...-attribute-path-through-specialization.mdl | 51 +++++++ ...s_builder_specialization_step_2127_test.go | 134 ++++++++++++++++++ mdl/executor/cmd_pages_builder_v3.go | 18 ++- 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-attribute-path-specialization-step-flat-ce1613.json create mode 100644 mdl-examples/bug-tests/2127-attribute-path-through-specialization.mdl create mode 100644 mdl/executor/cmd_pages_builder_specialization_step_2127_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-attribute-path-specialization-step-flat-ce1613.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-attribute-path-specialization-step-flat-ce1613.json new file mode 100644 index 000000000..d7a01c554 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-attribute-path-specialization-step-flat-ce1613.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"A DataGrid 2 column (or text box / pluggable attribute) bound to `System.Session_User/Administration.Account/FullName` — a path that steps down to a specialization of the hop's destination — passed check and exec, then mxbuild failed CE1613 \"The selected attribute 'System.Session_User/Administration.Account/FullName' no longer exists.\" Naming the end itself (`System.Session_User/System.User/Name`) failed the same way","cause":"`resolveAssociationAttributePath` read every segment before the attribute as an association; an entity segment failed the lookup, ok=false, and every caller fell back to writing the whole `/`-path as one flat attribute name","file":"`mdl/executor/cmd_pages_builder_v3.go` (`resolveAssociationAttributePath`)","insight":"The brief leaned towards a check refusal, and the measurement reversed it: mxbuild 11.14.0 accepts an AttributeRef whose EntityRefStep has DestinationEntity = the specialization (0 errors), and Studio Pro stores exactly that downcast on data sources (#2055) — the reporter's DESCRIBE note says Studio Pro authored such a column. Refusing would have been a false refusal of a Studio Pro construct. The cheap way to settle 'is there a writable shape' was a throwaway patch to the resolver plus one `docker check`, not reading BSON. Measure the 'legal twin' before trusting it: `Assoc/System.User/Name` was assumed legal and failed CE1613 too — only `Assoc/Name` was. Data source paths (`resolveAssociationHops`) already accepted `Assoc/Module.Entity`; the attribute-path resolver was the duplicate that lagged. One resolver serves columns, input widgets and the pluggable engine, so one fix covered all three (A/B on 11.14.0: base 3x CE1613, fixed 0). Still open: an entity off the hop falls back flat (CE1613, check silent), and DESCRIBE prints `Session_User/FullName` without the step. Tests `cmd_pages_builder_specialization_step_2127_test.go`; bug-test `mdl-examples/bug-tests/2127-attribute-path-through-specialization.mdl`","refs":["ako/mxcli#2127","mendixlabs/mxcli#1808"],"ce":["CE1613"]} diff --git a/mdl-examples/bug-tests/2127-attribute-path-through-specialization.mdl b/mdl-examples/bug-tests/2127-attribute-path-through-specialization.mdl new file mode 100644 index 000000000..d64b3b08f --- /dev/null +++ b/mdl-examples/bug-tests/2127-attribute-path-through-specialization.mdl @@ -0,0 +1,51 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#2127 (mendixlabs/mxcli#1808): a widget attribute path that steps +-- down to a specialization was written as one flat attribute name +-- ============================================================================ +-- +-- Symptom: `check` and `exec` passed, then mxbuild failed +-- CE1613 The selected attribute 'System.Session_User/Administration.Account/FullName' no longer exists. +-- +-- Cause: the attribute-path resolver read every segment before the attribute +-- as an association. `MyFirstModule.Account` is an entity, the lookup failed, +-- and the column fell back to the whole path as a flat attribute name. Naming +-- the association's own end (`System.Session_User/System.User/Name`) failed the +-- same way. +-- +-- Fix: an entity right after an association hop is that hop's destination — +-- its own end, or a specialization of it — and is stored as the step's +-- DestinationEntity, as data source paths already store it. mxbuild 11.14.0 +-- accepts the resulting AttributeRef at 0 errors. The same resolver serves +-- DataGrid 2 columns, input widgets and pluggable widget attributes. +-- +-- Verify (A/B, Mendix 11.14.0): +-- mxcli new Lab --version 11.14.0 --theme none --layout none --skip-init +-- mxcli exec 2127-attribute-path-through-specialization.mdl -p Lab/Lab.mpr +-- mxcli docker check -p Lab/Lab.mpr # fixed: 0 errors; before: 3 x CE1613 +-- +-- Not covered here: DESCRIBE still prints the column as `Session_User/FullName` +-- (the specialization step is dropped), which does not round-trip. +-- ============================================================================ + +create persistent entity MyFirstModule.Account extends System.User ( + FullName: string(200) +); + +create or modify snippet MyFirstModule.SNIPPET_Sessions { + datagrid gridSessions (DataSource: database from System.Session) { + -- The report: an attribute of a specialization of System.User. + column (Attribute: System.Session_User/MyFirstModule.Account/FullName, Caption: 'User') + -- The step may also name the association's own end. + column (Attribute: System.Session_User/System.User/Name, Caption: 'Name') + -- Control: no entity step. + column (Attribute: System.Session_User/Name, Caption: 'Login') + } +}; + +-- Input widgets share the resolver. +create or modify snippet MyFirstModule.SNIPPET_SessionEdit (Params: ( $Session: System.Session )) { + dataview dvSession (DataSource: $Session) { + textbox txtName (Label: 'User', Attribute: System.Session_User/MyFirstModule.Account/FullName) + } +}; diff --git a/mdl/executor/cmd_pages_builder_specialization_step_2127_test.go b/mdl/executor/cmd_pages_builder_specialization_step_2127_test.go new file mode 100644 index 000000000..5077d0154 --- /dev/null +++ b/mdl/executor/cmd_pages_builder_specialization_step_2127_test.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// ako/mxcli#2127 (mendixlabs/mxcli#1808): a data grid column bound to +// `System.Session_User/Administration.Account/FullName` passed check and exec, +// and mxbuild then failed +// +// CE1613 The selected attribute 'System.Session_User/Administration.Account/FullName' no longer exists. +// +// The path resolver read every non-final segment as an association, so the +// specialization step (`…/Administration.Account/…`) failed the lookup and the +// caller fell back to writing the whole path as one flat attribute name. The +// same happened to a step naming the association's own end +// (`System.Session_User/System.User/Name`). +// +// Studio Pro stores such a step as an EntityRefStep whose DestinationEntity is +// the specialization (measured for data sources, ako/mxcli#2055), and mxbuild +// 11.14.0 accepts it on a column's AttributeRef at 0 errors — so the writer +// emits that step, as `resolveAssociationHops` already does for data sources. +func specializationStepPB(entityContext string) *pageBuilder { + const ( + sysID = model.ID("mod-system") + myID = model.ID("mod-my") + sessionID = model.ID("e-session") + userID = model.ID("e-user") + accountID = model.ID("e-account") + otherID = model.ID("e-other") + ) + dms := []*domainmodel.DomainModel{ + { + ContainerID: sysID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: sessionID}, Name: "Session"}, + {BaseElement: model.BaseElement{ID: userID}, Name: "User", Attributes: []*domainmodel.Attribute{{Name: "Name"}}}, + }, + Associations: []*domainmodel.Association{ + {Name: "Session_User", ParentID: sessionID, ChildID: userID, Type: domainmodel.AssociationTypeReference}, + }, + }, + { + ContainerID: myID, + Entities: []*domainmodel.Entity{ + { + BaseElement: model.BaseElement{ID: accountID}, + Name: "Account", + GeneralizationRef: "System.User", + Attributes: []*domainmodel.Attribute{{Name: "FullName"}}, + }, + {BaseElement: model.BaseElement{ID: otherID}, Name: "Other", Attributes: []*domainmodel.Attribute{{Name: "FullName"}}}, + }, + }, + } + return &pageBuilder{ + entityContext: entityContext, + backend: &mock.MockBackend{ + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return dms, nil }, + }, + execCache: &executorCache{ + hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{sysID: "System", myID: "MyFirstModule"}}, + }, + } +} + +func TestResolveAssociationAttributePath_SpecializationStep_2127(t *testing.T) { + tests := []struct { + name string + path string + wantFinal string + wantDest string + }{ + {"specialization step (the report)", "System.Session_User/MyFirstModule.Account/FullName", "MyFirstModule.Account.FullName", "MyFirstModule.Account"}, + {"inherited attribute through the specialization", "System.Session_User/MyFirstModule.Account/Name", "System.User.Name", "MyFirstModule.Account"}, + {"step naming the association's own end", "System.Session_User/System.User/Name", "System.User.Name", "System.User"}, + {"no step (control)", "System.Session_User/Name", "System.User.Name", "System.User"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + finalQN, steps, ok := specializationStepPB("System.Session").resolveAssociationAttributePath(tc.path) + if !ok { + t.Fatalf("path %q was dropped (ok=false) — the column falls back to the flat attribute %q and mxbuild fails CE1613", tc.path, tc.path) + } + if finalQN != tc.wantFinal { + t.Errorf("finalQN = %q, want %q", finalQN, tc.wantFinal) + } + if len(steps) != 1 || steps[0].Association != "System.Session_User" || steps[0].DestinationEntity != tc.wantDest { + t.Errorf("steps = %+v, want one {Association: System.Session_User, DestinationEntity: %s}", steps, tc.wantDest) + } + }) + } + + // An entity the hop cannot arrive at — neither an end of the association nor + // a specialization of one — is not a step the runtime can follow. It stays + // unresolved rather than being written as a step to the wrong entity. + t.Run("entity off the association", func(t *testing.T) { + if _, steps, ok := specializationStepPB("System.Session").resolveAssociationAttributePath("System.Session_User/MyFirstModule.Other/FullName"); ok { + t.Errorf("resolved %+v for an entity that is not an end or specialization of System.Session_User", steps) + } + }) +} + +// The resolver is shared: a DataGrid 2 column and an input widget's +// `Attribute:` both go through it, so both get the step. +func TestSpecializationStep_ColumnAndInput_2127(t *testing.T) { + const path = "System.Session_User/MyFirstModule.Account/FullName" + + col, err := specializationStepPB("System.Session").buildColumnSpecFromAST(&ast.WidgetV3{ + Type: "column", + Properties: map[string]any{"Attribute": path, "Caption": "User"}, + }) + if err != nil { + t.Fatalf("column: %v", err) + } + if col.Attribute != "MyFirstModule.Account.FullName" { + t.Errorf("column Attribute = %q, want MyFirstModule.Account.FullName — the flat path is what mxbuild reports as CE1613", col.Attribute) + } + if len(col.AttributeRefSteps) != 1 || col.AttributeRefSteps[0].DestinationEntity != "MyFirstModule.Account" { + t.Errorf("column steps = %+v, want one hop arriving at MyFirstModule.Account", col.AttributeRefSteps) + } + + attr, steps := specializationStepPB("System.Session").resolveInputAttribute(path) + if attr != "MyFirstModule.Account.FullName" || len(steps) != 1 || steps[0].DestinationEntity != "MyFirstModule.Account" { + t.Errorf("input attribute = %q, steps %+v; want MyFirstModule.Account.FullName over one hop to MyFirstModule.Account", attr, steps) + } +} diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index ebfc599e7..f0e80c965 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -2546,6 +2546,7 @@ func (pb *pageBuilder) resolveAssociationAttributePath(attrRef string) (finalQN } steps = make([]pages.AttributeRefStep, 0, len(segs)-1) + afterAssociation := false for _, seg := range segs[:len(segs)-1] { // Against the entity THIS hop starts from — qualifying every hop with // the path's start named a later hop into the wrong module. (#662) @@ -2554,12 +2555,27 @@ func (pb *pageBuilder) resolveAssociationAttributePath(attrRef string) (finalQN if !ok { // Not a modelled association — it may be System.changedBy / // System.owner, which no domain model lists. (#1338) - if assocQN, dest, ok = pb.systemMemberAssociation(seg, current); !ok { + assocQN, dest, ok = pb.systemMemberAssociation(seg, current) + } + if !ok { + // `Assoc/Module.Entity/Attr`: an entity right after a hop names the + // entity the hop arrives at, or a specialization of it — Studio + // Pro's downcast, stored as that step's DestinationEntity, exactly + // as a data source path stores it (resolveAssociationHops). Read as + // an association it failed the lookup and the caller wrote the + // whole path as one flat attribute: CE1613 (ako/mxcli#2127). An + // entity the hop cannot arrive at stays unresolved. + if !afterAssociation || !strings.Contains(seg, ".") || !pb.isSpecializationOf(seg, current) { return "", nil, false } + steps[len(steps)-1].DestinationEntity = seg + current = seg + afterAssociation = false + continue } steps = append(steps, pages.AttributeRefStep{Association: assocQN, DestinationEntity: dest}) current = dest + afterAssociation = true } // The final attribute is qualified with the entity that DECLARES it, which From 75b2a3a91f392fe24eafb2668cc17eda1a6f26fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:00:25 +0000 Subject: [PATCH 19/26] fix: alter styling warns on a design property the widget's own type does not declare (CE6083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALTER STYLING's MDL-WIDGET11 check (#509) only asked whether ANY widget type in the theme declares a key, so a key declared for some other type passed every mxcli gate: `alter styling … widget dt set 'Row size' = 'Small'` on a Forms$DynamicText was "Check passed!" and then CE6083 at build. The reference pass now opens the stored document (pageProbe, which gains the WidgetStorageType accessor the BSON mutator already had) and judges each key the any-type pass accepted against the stored widget's own theme group, via the existing $Type -> key routing helper (designPropsKeyOfStored, shared with ALTER PAGE SET) and GetPropertiesForWidget, so `Widget`-group keys are inherited. No new resolver. Still a warning; the message names the widget's type and its valid keys. Without a project, or where the stored type cannot be established (widget not stored, type without a theme group, document created or grown by the script), the #509 any-type check is the only answer. It lives in the reference pass because the validators' read-only connection refuses OpenPageForMutation — invisible to mock backends, found by running against a real 11.14.0 project. Refs ako/mxcli#613 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- ...er-styling-key-of-another-widget-type.json | 1 + ...ter-styling-key-of-another-widget-type.mdl | 55 ++++ ...09-511-alter-styling-design-properties.mdl | 3 + mdl/executor/design_property_routing.go | 21 +- mdl/executor/validate.go | 5 + mdl/executor/validate_alter_set.go | 12 +- mdl/executor/validate_alter_styling.go | 52 ++-- mdl/executor/validate_alter_styling_stored.go | 178 ++++++++++++ .../validate_alter_styling_stored_test.go | 270 ++++++++++++++++++ mdl/executor/validate_program.go | 6 +- 10 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-styling-key-of-another-widget-type.json create mode 100644 mdl-examples/bug-tests/613-alter-styling-key-of-another-widget-type.mdl create mode 100644 mdl/executor/validate_alter_styling_stored.go create mode 100644 mdl/executor/validate_alter_styling_stored_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-styling-key-of-another-widget-type.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-styling-key-of-another-widget-type.json new file mode 100644 index 000000000..c3f24890c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-alter-styling-key-of-another-widget-type.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-11", "refs": ["ako/mxcli#613", "ako/mxcli#509", "ako/mxcli#515", "ako/mxcli#511"], "ce": ["CE6083"], "rules": ["MDL-WIDGET11"], "symptom": "alter styling on page MyFirstModule.HideOn widget dt set 'Row size' = 'Small' (a ListView key on a Forms$DynamicText) -> mxcli check -p --references: Check passed! (no MDL-WIDGET11); mxcli docker check: [CE6083] \"Design property Row size is not supported by your theme.\" at Text 'dt'. The same key on the ListView lvH: 0 errors.", "cause": "validateAlterStylingDesignProps runs in ValidateProgram with no document, so it asked only whether ANY widget type declares the key; the widget's $Type lives in the stored page.", "file": "mdl/executor/validate_alter_styling_stored.go (validateAlterStylingStoredTypes, reference pass, wired in validate.go); validate_alter_set.go (pageProbe now embeds widgetStorageTyper); design_property_routing.go (designPropsKeyOfStored, documentThemeOf). Tests: validate_alter_styling_stored_test.go", "insight": "**The first attempt passed every mock-backend test and did nothing on a real project:** it opened the page from ValidateProgram's lazy project, and openProjectForValidation's read-only modelsdk connection refuses OpenPageForMutation (\"not connected for writing\") — MockBackend's OpenPageForMutationFunc does not, so only `check -p` against a real 11.14.0 app showed the precise path silently falling back. Anything that opens a document for a check belongs in the reference pass (validateProgramWithWarnings, implied by -p and run by exec's preflight), which validateAlterSetProperties and validateStoredSnippetCallAppearance already use. Split by KEY to avoid double reports: the any-type pass keeps keys no type declares, the stored pass judges only keys the first accepted, with validateDesignPropsAs's exact condition (findThemeProp, rename, declaresGroup guard) so ALTER STYLING and CREATE PAGE cannot disagree. No new resolver: designPropsKeyOfStored is storedWidgetDesignPropsKey's body, shared. Controls: stubbing resolve() fails TestAlterStyling613_KeyOfAnotherWidgetTypeIsReported with 'got 0'; real run warns on dt and is silent on lvH; mx check 1 error vs 0."} diff --git a/mdl-examples/bug-tests/613-alter-styling-key-of-another-widget-type.mdl b/mdl-examples/bug-tests/613-alter-styling-key-of-another-widget-type.mdl new file mode 100644 index 000000000..722b851c2 --- /dev/null +++ b/mdl-examples/bug-tests/613-alter-styling-key-of-another-widget-type.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- ako/mxcli#613 — the residual half of #509. ALTER STYLING's key check asked +-- whether ANY widget type in the theme declares a key, so a key declared for +-- some OTHER type passed every mxcli gate and failed the build. `Row size` is a +-- ListView design property; on a DynamicText it is CE6083. +-- +-- Measured on a blank Mendix 11.14.0 project (Atlas), dt and lvH as below, the +-- page created by an earlier script so ALTER STYLING meets STORED widgets: +-- +-- alter styling on page MyFirstModule.HideOn widget dt +-- set ( 'Row size': 'Small' ); +-- check -p (before) -> Check passed! (no MDL-WIDGET11) +-- check -p (after) -> ⚠ sets design property "Row size" on "dt", a +-- DynamicText, which the theme declares only for +-- other widget types — mxbuild reports this as +-- CE6083 [MDL-WIDGET11] — Valid design properties +-- for a DynamicText: Align self, Alignment, Color, +-- Color shade, Hide on, Letter case, Size, Spacing, +-- Weight, Wrap options +-- docker check -> [CE6083] "Design property Row size is not +-- supported by your theme." at Text 'dt' 1 error +-- +-- Control: the same key on lvH (a ListView) — check -p silent, 0 errors. +-- +-- The widget's type lives only in the stored document, so the precise check +-- runs in the reference pass (which `-p` implies) and judges keys against the +-- stored widget's own theme group, `Widget`-group keys included. Without a +-- project it degrades to #509's any-type question, so the failing shape below +-- is silent under `make check-mdl` either way — and it is also silent with -p +-- in THIS script, because the page is created here: the stored tree is not the +-- one exec will style, so the check does not guess. + +create or modify entity MyFirstModule.Thing ( Name: string(200) ); + +create or modify page MyFirstModule.HideOn ( + title: 'h', layout: 'Atlas_Core.Atlas_Default' +) { + listview lvH (DataSource: database from MyFirstModule.Thing) { + dynamictext dt (Content: 'x') + } +}; + +-- Control: 'Row size' on the widget whose type declares it, and an inherited +-- `Widget`-group key on the DynamicText. Both build at 0 errors. +alter styling on page MyFirstModule.HideOn widget lvH + set ( 'Row size': 'Small' ); +alter styling on page MyFirstModule.HideOn widget dt + set ( 'Align self': 'Right' ); + +-- The failing shape, kept commented so a run of this file builds green: +-- +-- alter styling on page MyFirstModule.HideOn widget dt +-- set ( 'Row size': 'Small' ); +-- -> (run against a project already holding the page) MDL-WIDGET11 warning, +-- names DynamicText and its valid keys; mxbuild: CE6083. diff --git a/mdl-examples/bug-tests/styling-509-511-alter-styling-design-properties.mdl b/mdl-examples/bug-tests/styling-509-511-alter-styling-design-properties.mdl index 01c0aae13..a87fa1a52 100644 --- a/mdl-examples/bug-tests/styling-509-511-alter-styling-design-properties.mdl +++ b/mdl-examples/bug-tests/styling-509-511-alter-styling-design-properties.mdl @@ -16,6 +16,9 @@ mdl 1; -- It therefore asks whether ANY widget type in the theme declares the key — a key -- declared nowhere cannot be right here either. A key declared for a DIFFERENT -- widget type is accepted. Under-reporting, never over-reporting. +-- (ako/mxcli#613 closes that half: with a project, the reference pass opens the +-- document and judges the key against the stored widget's own type — see +-- 613-alter-styling-key-of-another-widget-type.mdl.) -- -- #511: a design property declared `"multiSelect": true` (Atlas: 'Hide on') is -- stored as a COMPOUND, one entry per selected option. Decoded from a Studio diff --git a/mdl/executor/design_property_routing.go b/mdl/executor/design_property_routing.go index cfd108249..110124c48 100644 --- a/mdl/executor/design_property_routing.go +++ b/mdl/executor/design_property_routing.go @@ -61,7 +61,18 @@ type widgetStorageTyper interface { // that decides where a value gets written. func storedWidgetDesignPropsKey(mutator backend.PageMutator, widgetRef string) string { typer, ok := mutator.(widgetStorageTyper) - if !ok || widgetRef == "" { + if !ok { + return "" + } + return designPropsKeyOfStored(typer, widgetRef) +} + +// designPropsKeyOfStored is storedWidgetDesignPropsKey for a caller that already +// holds the capability — the pageProbe a check pass opened (ako/mxcli#613). One +// derivation, two entry points: ALTER STYLING's check and ALTER PAGE SET's +// routing resolve a stored widget to the same theme group by construction. +func designPropsKeyOfStored(typer widgetStorageTyper, widgetRef string) string { + if typer == nil || widgetRef == "" { return "" } bsonType, widgetID := typer.WidgetStorageType(widgetRef) @@ -85,7 +96,13 @@ type documentPlatformReporter interface { // or layout, the web ones otherwise (ako/mxcli#1679). A page is native when its // layout is. func documentTheme(ctx *ExecContext, mutator backend.PageMutator) *ThemeRegistry { - reg := ctx.GetThemeRegistry() + return documentThemeOf(ctx, ctx.GetThemeRegistry(), mutator) +} + +// documentThemeOf is documentTheme for a registry the caller already holds and +// a document opened as a pageProbe rather than a backend.PageMutator — ALTER +// STYLING's stored-type check (ako/mxcli#613). +func documentThemeOf(ctx *ExecContext, reg *ThemeRegistry, mutator any) *ThemeRegistry { r, ok := mutator.(documentPlatformReporter) if reg == nil || !ok { return reg diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 339caf959..948bb9e2b 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -594,6 +594,11 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // useless — a snippet call renders no element (mendixlabs/mxcli#1511). // What the target IS lives only in the document. Warns, never blocks. validateStoredSnippetCallAppearance(ctx, prog, sc) + // An ALTER STYLING design-property key some widget type declares, judged + // against the STORED widget's own type: a ListView's `Row size` on a + // DynamicText passed the no-project any-type check and was CE6083 at build + // (ako/mxcli#613). Warns, never blocks. + validateAlterStylingStoredTypes(ctx, prog, sc) // The entity an ALTER's INSERT / REPLACE binds against is in the stored // document, not the statement. Where it has none (a missing flow source, or // no data container at all) a bare binding is written bare and the project diff --git a/mdl/executor/validate_alter_set.go b/mdl/executor/validate_alter_set.go index 7e7d841d6..0e214f83c 100644 --- a/mdl/executor/validate_alter_set.go +++ b/mdl/executor/validate_alter_set.go @@ -45,8 +45,10 @@ import ( // original, which is the one thing that is cheap to guarantee. // pageProbe is the optional capability the shared BSON page mutator offers: a -// discardable copy to dry-run an operation against, and the property names the -// stored widget declares. +// discardable copy to dry-run an operation against, the property names the +// stored widget declares, and what the stored widget IS — its $Type and, for a +// pluggable widget, its widget id. The last is what lets ALTER STYLING's check +// judge a design property against the widget's own type (ako/mxcli#613). // // It is an assertion rather than an addition to backend.PageMutator on purpose. // The MCP mutator has neither — and no pluggable path at all — so asserting is @@ -56,6 +58,7 @@ type pageProbe interface { Probe() (backend.PageMutator, error) ResolvesTarget(widgetRef, columnRef string) bool WidgetPropertyKeys(widgetRef, columnRef string) []string + widgetStorageTyper } // validateAlterSetProperties dry-runs every ALTER … SET against the stored @@ -252,11 +255,10 @@ func checkGalleryClickSet(ctx *ExecContext, p pageProbe, label string, op *ast.S // a build later. Only for the stored widget types whose key that is // (pages.ImageReferenceProperty), so a pluggable widget's `image` is untouched. func setImageRefErrors(ctx *ExecContext, p pageProbe, target ast.WidgetRef, name string, value any, sc *scriptContext) []string { - typer, ok := p.(widgetStorageTyper) - if !ok || target.Widget == "" || columnRefOf(target) != "" { + if p == nil || target.Widget == "" || columnRefOf(target) != "" { return nil } - bsonType, _ := typer.WidgetStorageType(target.Widget) + bsonType, _ := p.WidgetStorageType(target.Widget) key := pages.ImageReferenceProperty(bsonType) img, isString := value.(string) if key == "" || !strings.EqualFold(key, name) || !isString || strings.TrimSpace(img) == "" { diff --git a/mdl/executor/validate_alter_styling.go b/mdl/executor/validate_alter_styling.go index 8682149a2..6bfd8f40b 100644 --- a/mdl/executor/validate_alter_styling.go +++ b/mdl/executor/validate_alter_styling.go @@ -28,29 +28,44 @@ import ( // Measured on a blank 11.12.2 project; the same statement with 'Row size' = // 'Small' is 0 errors (ako/mxcli#509). // -// # Why this resolves less than the authoring paths do +// # Two passes: any type without the document, the widget's own type with it // // The other three statements CARRY the widget, so the validator reads its type // off the AST and checks the key against that type's properties. // AlterStylingStmt carries only a widget NAME (mdl/ast/ast_styling.go) — it -// names a widget already stored, whose $Type lives only in the document, which -// this pass has no backend to open. +// names a widget already stored, whose $Type lives only in the document. // -// So the question asked here is the one that IS answerable without the document: -// does ANY widget type in the project's theme declare this key? A key no theme -// declares anywhere cannot be right for this widget either, and the reported -// case is exactly that. A key declared for some OTHER widget type is accepted — -// under-reporting, never over-reporting, which is the only safe direction for a -// check that cannot see what it is judging. +// This pass (validateAlterStylingDesignProps) runs with no project at all — +// `mxcli check script.mdl`, the corpus default — so it asks the question that +// IS answerable without the document: does ANY widget type in the theme declare +// this key? A key no type declares cannot be right for this widget either +// (#509's reported case). // -// Deliberately NOT done: mapping the stored $Type to a registry key to make this -// precise. That would be a third resolver for one concept — mdlKeywordStorageType -// maps MDL keyword → $Type, and an unused bsonTypeToDesignPropsKey maps $Type → -// key — and duplicate resolvers drifting apart is the failure this area keeps -// producing (mendixlabs/mxcli#1069's buildPropKeyMap was two copies of one -// derivation, and one copy was the bug). The precise variant needs a widget-type -// accessor on the pageProbe capability; it is tracked in ako/mxcli#509 and is -// strictly additive to this. +// The reference pass then opens the document (validateAlterStylingStoredTypes, +// the pageProbe capability, opened as validateAlterSetProperties opens it) and +// judges every key the first pass let through against the widget's OWN type: +// the stored $Type → theme group mapping is the one ALTER PAGE SET's routing +// already uses (designPropsKeyOfStored), and the group's keys come from +// GetPropertiesForWidget, so the `Widget` group's inherited keys are included +// rather than re-derived. No third resolver (ako/mxcli#613). That is what +// catches a key declared for some OTHER type — `Row size`, a ListView key, on a +// DynamicText is CE6083, and the any-type question passed it. +// +// It has to be the reference pass, not a project opened from this one: the +// validators' own read-only connection (openProjectForValidation) refuses +// OpenPageForMutation — "not connected for writing" — which a mock backend +// does not, so only a run against a real project shows it. Splitting by key +// (the second pass reports only keys the first accepted) is what keeps one key +// from being reported twice. +// +// Where the stored type cannot be established — the widget is not stored, its +// type has no theme group, or the script itself creates or grows the document +// — the second pass is silent and the first pass's answer stands. +// Under-reporting, never over-reporting, is the only safe direction for a check +// that cannot see what it is judging. +// +// mdlKeywordToDesignPropsKey and bsonTypeToDesignPropsKey remain two tables; +// this consumes the second and unifies neither (out of scope for #613). // validateAlterStylingDesignProps reports (MDL-WIDGET11) an ALTER STYLING design // property key that no widget type in the project's theme declares. @@ -77,8 +92,7 @@ func validateAlterStylingDesignProps(prog *ast.Program, reg *ThemeRegistry) []li if !ok { continue } - label := fmt.Sprintf("alter styling on %s %s", - strings.ToLower(s.ContainerType), s.ContainerName.String()) + label := alterStylingLabel(s) for _, a := range s.Assignments { // CLASS / STYLE / DYNAMICCLASSES are appearance fields, not design // properties — the visitor marks them, so this does not have to diff --git a/mdl/executor/validate_alter_styling_stored.go b/mdl/executor/validate_alter_styling_stored.go new file mode 100644 index 000000000..6fbf4d88c --- /dev/null +++ b/mdl/executor/validate_alter_styling_stored.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// The second half of ALTER STYLING's design-property check (ako/mxcli#613): a +// key the theme declares for SOME widget type, judged against the type of the +// widget the statement actually names. The file comment of +// validate_alter_styling.go has the reasoning; this is the reference pass's +// half, because only that pass holds a connection that can open the document. +// +// Measured on blank 11.12.2 and 11.14.0 projects (Forms$DynamicText dt and +// Forms$ListView lvH on one page): +// +// alter styling on page MyFirstModule.HideOn widget dt set 'Row size' = 'Small'; +// check -p --references -> Check passed! (before this pass) +// docker check -> [CE6083] "Design property Row size is not +// supported by your theme." at Text 'dt' +// +// and the same key on lvH, the widget whose type declares it, is 0 errors. + +func alterStylingLabel(s *ast.AlterStylingStmt) string { + return fmt.Sprintf("alter styling on %s %s", strings.ToLower(s.ContainerType), s.ContainerName.String()) +} + +// validateAlterStylingStoredTypes adds a warning (MDL-WIDGET11) for each ALTER +// STYLING design-property key that some widget type in the theme declares but +// the stored widget's own type does not. Warns, never blocks: a newer theme may +// add keys the snapshot lacks (#509's scope note). +// +// Only keys the any-type pass accepted are judged here — a key no type declares +// is that pass's finding, and reporting it twice reads as two problems. Silent +// with no connection, with no theme metadata, and wherever the stored type +// cannot be established. +func validateAlterStylingStoredTypes(ctx *ExecContext, prog *ast.Program, sc *scriptContext) { + if prog == nil || sc == nil || !ctx.Connected() { + return + } + reg := ctx.GetThemeRegistry() + if reg == nil || len(reg.WidgetProperties) == 0 { + return + } + declared := allDeclaredDesignPropertyNames(reg) + stored := newStoredStylingTypes(ctx, reg, prog, sc) + for _, stmt := range prog.Statements { + s, ok := stmt.(*ast.AlterStylingStmt) + if !ok || !hasDesignPropertyAssignment(s) { + continue + } + own := stored.resolve(s) + if own == nil { + continue + } + for _, a := range s.Assignments { + if a.IsCSS || a.Property == "" || !declaredHas(declared, a.Property) { + continue + } + if msg := own.judge(alterStylingLabel(s), s.WidgetName, a); msg != "" { + sc.warnings = append(sc.warnings, msg) + } + } + } +} + +func hasDesignPropertyAssignment(s *ast.AlterStylingStmt) bool { + for _, a := range s.Assignments { + if !a.IsCSS && a.Property != "" { + return true + } + } + return false +} + +// storedStylingType is what an ALTER STYLING target IS in the stored document: +// its theme group, and the keys that group declares with the `Widget` group's +// inherited ones (GetPropertiesForWidget). +type storedStylingType struct { + group string + props []ThemeProperty +} + +// judge applies the authoring paths' per-key condition (validateDesignPropsAs: +// an exact match, else a rename, else undeclared) to the stored widget's own +// type, so ALTER STYLING and CREATE PAGE cannot disagree about one key on one +// widget type. "" when the key is the type's own. +// +// A multi-select key the type declares (`Hide on`) is known, not unknown: the +// any-type pass already reports it as MDL-WIDGET12, the writer limit of #511. +func (t *storedStylingType) judge(label, widget string, a ast.StylingAssignment) string { + if findThemeProp(t.props, a.Property) != nil { + return "" + } + if r := findRenamedThemeProp(t.props, a.Property, a.Value); r != nil { + return fmt.Sprintf("%s: sets design property %q on %q (a %s), which the theme renamed to %q "+ + "— mxbuild reports the old name as CE6087 [MDL-WIDGET11] — %s", + label, a.Property, widget, t.group, r.NewKey, renamedStylingSuggestion(r, a.Value)) + } + return fmt.Sprintf("%s: sets design property %q on %q, a %s, which the theme declares only for "+ + "other widget types — mxbuild reports this as CE6083 [MDL-WIDGET11] — %s", + label, a.Property, widget, t.group, + strings.Replace(designPropKeySuggestion(t.props, a.Property), "this widget", "a "+t.group, 1)) +} + +// storedStylingTypes resolves ALTER STYLING targets against the stored +// documents, opening each at most once. +type storedStylingTypes struct { + ctx *ExecContext + reg *ThemeRegistry + h *ContainerHierarchy + sc *scriptContext + grows map[string]bool + opened map[string]pageProbe +} + +func newStoredStylingTypes(ctx *ExecContext, reg *ThemeRegistry, prog *ast.Program, sc *scriptContext) *storedStylingTypes { + h, _ := getHierarchy(ctx) + return &storedStylingTypes{ + ctx: ctx, + reg: reg, + h: h, + sc: sc, + grows: documentsTheScriptAddsWidgetsTo(prog), + opened: map[string]pageProbe{}, + } +} + +// resolve returns the stored widget's own type, or nil when it cannot be +// established: a document the script creates or grows (its stored tree is not +// the one exec will style), a widget that is not stored (exec reports that), a +// type with no theme group (its full key set is unknown — the same guard +// validateDesignPropsAs applies). nil is silence, never a finding. +func (r *storedStylingTypes) resolve(s *ast.AlterStylingStmt) *storedStylingType { + if r.h == nil || s.WidgetName == "" { + return nil + } + qn := s.ContainerName.String() + kind := strings.ToLower(s.ContainerType) + if r.grows[qn] || r.sc.modules[s.ContainerName.Module] || + (kind == "page" && r.sc.pages[qn]) || (kind == "snippet" && r.sc.snippets[qn]) { + return nil + } + probe, seen := r.opened[kind+" "+qn] + if !seen { + probe = r.open(kind, s.ContainerName) + r.opened[kind+" "+qn] = probe + } + if probe == nil || !probe.ResolvesTarget(s.WidgetName, "") { + return nil + } + group := designPropsKeyOfStored(probe, s.WidgetName) + theme := documentThemeOf(r.ctx, r.reg, probe) + if group == "" || theme == nil || !theme.declaresGroup(group) { + return nil + } + return &storedStylingType{group: group, props: theme.GetPropertiesForWidget(group)} +} + +// open finds the page or snippet as execAlterStyling does and opens it as +// validateAlterSetProperties does, or returns nil. +func (r *storedStylingTypes) open(kind string, name ast.QualifiedName) pageProbe { + switch kind { + case "page": + if pg, err := findPageByName(r.ctx, name, r.h); err == nil { + return openPageProbe(r.ctx, pg.ID) + } + case "snippet": + if snp, _, err := findSnippetByName(r.ctx, name, r.h); err == nil { + return openPageProbe(r.ctx, snp.ID) + } + } + return nil +} diff --git a/mdl/executor/validate_alter_styling_stored_test.go b/mdl/executor/validate_alter_styling_stored_test.go new file mode 100644 index 000000000..53dbfa1c3 --- /dev/null +++ b/mdl/executor/validate_alter_styling_stored_test.go @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/backend/pagemutator" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#613, the residual half of #509. A key the theme declares for SOME +// OTHER widget type passed every mxcli gate and failed the build. Measured on a +// blank 11.12.2 project (Forms$DynamicText dt and Forms$ListView lvH on one page): +// +// alter styling on page MyFirstModule.HideOn widget dt set 'Row size' = 'Small'; +// mxcli check --references -> Check passed! (no MDL-WIDGET11) +// mxcli docker check -> [CE6083] "Design property Row size is not +// supported by your theme." at Text 'dt' +// +// Control: the same key on lvH (a ListView) is 0 errors. +// +// The stored widget's $Type is what decides, so the reference pass opens the +// document (the pageProbe capability) and resolves the key against the widget's +// OWN type via the routing helper ALTER PAGE SET already uses. These tests use a +// mock backend, which — unlike the validators' read-only modelsdk connection — +// does open a page for mutation; that difference is why the judgement lives in +// the reference pass, and only a real project run shows it. + +// The pageProbe capability now carries the widget-type accessor, and the +// concrete BSON mutator is what satisfies it. +var _ pageProbe = (*pagemutator.Mutator)(nil) + +func storedTextAndListPage() bson.D { + appearance := func() bson.D { + return bson.D{ + {Key: "$Type", Value: "Forms$Appearance"}, + {Key: "Class", Value: ""}, + {Key: "DesignProperties", Value: bson.A{int32(3)}}, + {Key: "DynamicClasses", Value: ""}, + {Key: "Style", Value: ""}, + } + } + dt := bson.D{ + {Key: "$Type", Value: "Forms$DynamicText"}, + {Key: "Name", Value: "dt"}, + {Key: "Appearance", Value: appearance()}, + } + lv := bson.D{ + {Key: "$Type", Value: "Forms$ListView"}, + {Key: "Name", Value: "lvH"}, + {Key: "Appearance", Value: appearance()}, + } + tb := bson.D{ + {Key: "$Type", Value: "Forms$TextBox"}, + {Key: "Name", Value: "tb"}, + {Key: "Appearance", Value: appearance()}, + } + return bson.D{ + {Key: "$Type", Value: "Forms$Page"}, + {Key: "FormCall", Value: bson.D{ + {Key: "Arguments", Value: bson.A{ + int32(2), + bson.D{{Key: "Widgets", Value: bson.A{int32(2), dt, lv, tb}}}, + }}, + }}, + } +} + +// atlas613Theme is the slice of Atlas (11.12.2 / 11.14.0) the issue turns on: +// the three `Widget` keys every widget inherits, the ListView group that +// declares `Row size`, and the DynamicText group, which does not. Atlas has no +// TextBox group. +func atlas613Theme() *ThemeRegistry { + return &ThemeRegistry{WidgetProperties: map[string][]ThemeProperty{ + "Widget": { + {Name: "Spacing", Type: "Spacing"}, + {Name: "Align self", Type: "ToggleButtonGroup", Options: []ThemeOption{{Name: "Left"}, {Name: "Right"}}}, + {Name: "Hide on", Type: "ToggleButtonGroup", MultiSelect: true, + Options: []ThemeOption{{Name: "Phone"}, {Name: "Tablet"}}}, + }, + "ListView": { + {Name: "Style", Type: "ToggleButtonGroup", Options: []ThemeOption{{Name: "Lined"}, {Name: "Striped"}}}, + {Name: "Hover style", Type: "Toggle", Class: "listview-hover"}, + {Name: "Row size", Type: "ToggleButtonGroup", Options: []ThemeOption{{Name: "Small"}, {Name: "Large"}}}, + }, + "DynamicText": { + {Name: "Size", Type: "Dropdown", Options: []ThemeOption{{Name: "H1"}, {Name: "Small"}}}, + {Name: "Weight", Type: "Dropdown", Options: []ThemeOption{{Name: "Bold"}}}, + }, + }} +} + +// storedStylingCtx is a connected context over one module M holding page P. +func storedStylingCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("M") + pg := mkPage(mod.ID, "P") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return []*pages.Page{pg}, nil }, + OpenPageForMutationFunc: func(unitID model.ID) (backend.PageMutator, error) { + return pagemutator.New(storedTextAndListPage(), unitID, &countingDeps{}), nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + return ctx +} + +// stylingFindings runs ALTER STYLING's design-property check the way `check -p` +// does: the no-project any-type pass (ValidateProgram), then — when ctx is not +// nil — the reference pass that opens the stored document. Each finding is one +// string carrying its rule id, so a key reported by both passes shows twice. +func stylingFindings(t *testing.T, ctx *ExecContext, reg *ThemeRegistry, src string) []string { + t.Helper() + prog := parseMDL(t, src) + var out []string + for _, v := range validateAlterStylingDesignProps(prog, reg) { + sev := "warning" + if v.Severity != linter.SeverityWarning { + sev = "NOT-A-WARNING" + } + out = append(out, fmt.Sprintf("%s: %s | %s [%s]", sev, v.Message, v.Suggestion, v.RuleID)) + } + if ctx != nil { + ctx.ThemeRegistry = reg + sc := newScriptContext() + sc.collectDefinitions(prog) + validateAlterStylingStoredTypes(ctx, prog, sc) + for _, w := range sc.warnings { + // sc.warnings is the reference pass's warning channel: it never + // blocks, which is what keeps this a warning. + out = append(out, "warning: "+w) + } + } + return out +} + +// The reported case: `Row size` on a DynamicText. +func TestAlterStyling613_KeyOfAnotherWidgetTypeIsReported(t *testing.T) { + got := stylingFindings(t, storedStylingCtx(t), atlas613Theme(), + `alter styling on page M.P widget dt set 'Row size' = 'Small';`) + if len(got) != 1 { + t.Fatalf("want one MDL-WIDGET11 for a ListView key on a DynamicText, got %d: %q", len(got), got) + } + f := got[0] + // Still a warning: a newer theme may add keys the snapshot lacks (#509). + for _, w := range []string{"warning: ", "MDL-WIDGET11", `"dt"`, `"Row size"`, "DynamicText", "CE6083"} { + if !strings.Contains(f, w) { + t.Errorf("lacks %s:\n%s", w, f) + } + } + // The keys valid for it, the three inherited from `Widget` included... + _, valid, _ := strings.Cut(f, "Valid design properties for a DynamicText: ") + for _, w := range []string{"Spacing", "Align self", "Hide on", "Size", "Weight"} { + if !strings.Contains(valid, w) { + t.Errorf("does not list valid key %q:\n%s", w, f) + } + } + // ...and never the key just refused, nor another type's. + for _, w := range []string{"Row size", "Hover style"} { + if strings.Contains(valid, w) { + t.Errorf("lists %q as valid on a DynamicText:\n%s", w, f) + } + } +} + +// CONTROL: the same key on the widget that declares it — 0 errors in mx check. +func TestAlterStyling613_KeyOfTheWidgetsOwnTypeIsSilent(t *testing.T) { + for _, stmt := range []string{ + `alter styling on page M.P widget lvH set 'Row size' = 'Small';`, + // Inherited from `Widget` — resolving only the type's own group is the + // mistake this area keeps producing. + `alter styling on page M.P widget dt set 'Align self' = 'Right';`, + `alter styling on page M.P widget dt set 'Size' = 'H1';`, + } { + if got := stylingFindings(t, storedStylingCtx(t), atlas613Theme(), stmt); len(got) != 0 { + t.Errorf("%s\n reported a key the widget's own type declares: %q", stmt, got) + } + } +} + +// `Hide on` is a KNOWN key with a writer problem (#511), never an unknown one. +func TestAlterStyling613_HideOnIsKnownNotUnknown(t *testing.T) { + got := stylingFindings(t, storedStylingCtx(t), atlas613Theme(), + `alter styling on page M.P widget dt set 'Hide on' = 'Phone';`) + if len(got) != 1 || !strings.Contains(got[0], "MDL-WIDGET12") { + t.Fatalf("want exactly one MDL-WIDGET12 (multi-select), got %q", got) + } + if strings.Contains(got[0], "CE6083") { + t.Errorf("Hide on was reported as unknown: %q", got[0]) + } +} + +// A key no type declares is the any-type pass's finding, reported once — the +// reference pass must not say it again in other words. +func TestAlterStyling613_UndeclaredAnywhereIsReportedOnce(t *testing.T) { + got := stylingFindings(t, storedStylingCtx(t), atlas613Theme(), + `alter styling on page M.P widget dt set 'Remove empty text' = on;`) + if len(got) != 1 || !strings.Contains(got[0], "no widget type") { + t.Errorf("want the any-type finding exactly once, got %q", got) + } +} + +// CONTROL: with no project the check still runs, and still asks the any-type +// question of #509 — silent on a key some type declares, loud on one none does. +func TestAlterStyling613_NoProjectDegradesToAnyTypeCheck(t *testing.T) { + if got := stylingFindings(t, nil, atlas613Theme(), + `alter styling on page M.P widget dt set 'Row size' = 'Small';`); len(got) != 0 { + t.Errorf("without a project the widget's type is unknowable, so a key some type declares must pass: %q", got) + } + got := stylingFindings(t, nil, atlas613Theme(), + `alter styling on page M.P widget dt set 'Remove empty text' = on;`) + if len(got) != 1 || !strings.Contains(got[0], "MDL-WIDGET11") || !strings.Contains(got[0], "no widget type") { + t.Errorf("the #509 any-type case is no longer reported without a project: %q", got) + } + // A context that is not connected is the same as none. + notConnected, _ := newMockCtx(t, withBackend(&mock.MockBackend{IsConnectedFunc: func() bool { return false }})) + if got := stylingFindings(t, notConnected, atlas613Theme(), + `alter styling on page M.P widget dt set 'Row size' = 'Small';`); len(got) != 0 { + t.Errorf("an unconnected context must degrade, not judge: %q", got) + } +} + +// CONTROL: a project with no themesource metadata reports nothing. +func TestAlterStyling613_NoThemeReportsNothing(t *testing.T) { + for _, reg := range []*ThemeRegistry{nil, {WidgetProperties: map[string][]ThemeProperty{}}} { + if got := stylingFindings(t, storedStylingCtx(t), reg, + `alter styling on page M.P widget dt set 'Row size' = 'Small';`); len(got) != 0 { + t.Errorf("claimed a key is undeclared with no theme to judge against: %q", got) + } + } +} + +// Where the stored type cannot be known, the reference pass is silent rather +// than judging against a type it guessed. +func TestAlterStyling613_UnknowableStoredTypeIsSilent(t *testing.T) { + cases := []struct{ name, src string }{ + // No such widget in the stored document: exec reports that itself. + {"widget not stored", `alter styling on page M.P widget nope set 'Row size' = 'Small';`}, + // A type the theme declares no group for: its full key set is unknown. + {"type without a theme group", `alter styling on page M.P widget tb set 'Row size' = 'Small';`}, + // A widget the same script puts on the page: the stored tree is not the + // one exec will style — here `dt` becomes a list view. + {"page the script grows", `alter page M.P { replace dt with { listview dt (datasource: database M.E) {} } }; +alter styling on page M.P widget dt set 'Row size' = 'Small';`}, + {"page the script creates", `create or replace page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { dynamictext dt (content: 'x') }; +alter styling on page M.P widget dt set 'Row size' = 'Small';`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + for _, f := range stylingFindings(t, storedStylingCtx(t), atlas613Theme(), c.src) { + if strings.Contains(f, `"Row size"`) && strings.Contains(f, "MDL-WIDGET11") { + t.Errorf("judged against a stored type that is not the one exec will style: %s", f) + } + } + }) + } +} diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 0641e54a9..df62443c2 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -223,8 +223,10 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // The same question for ALTER STYLING, which that pass never looked at — the // one statement whose entire job is writing design properties, and the one // where an unsupported key was silent until mxbuild said CE6083 - // (ako/mxcli#509). It resolves less, because the statement names a STORED - // widget whose $Type this pass cannot read; see the file comment. + // (ako/mxcli#509). Here it asks whether ANY widget type declares the key; + // the reference pass, which can open the document, then judges a key some + // OTHER type declares against the stored widget's own type + // (validateAlterStylingStoredTypes, ako/mxcli#613). See the file comment. violations = append(violations, validateAlterStylingDesignProps( prog, LoadThemeRegistryForProject(projectPath))...) From 3c5c22d4c2cfabca5a7e6307fea611253c3048bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:02:06 +0000 Subject: [PATCH 20/26] fix(check): refuse message definition collections on 11.15+ at check time (#2139) check -p passed create/alter/drop/describe message definition collection on an 11.15+ project, which exec then refused: the refusal was a ceiling (Mendix 11.15 removed the collection) raised inline in the handlers, so the version gate pass that check -p and exec's pre-flight run never saw it. removedStatementError is now the first thing checkVersionGates applies, and the handlers go through checkVersionGates, so check reports MDL-VERSION01 with exec's exact text and hint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sLdsu6ePfRkrDVEMJ --- ...eck-passes-removed-message-collection.json | 1 + ...39-message-collection-refused-at-check.mdl | 25 +++++++++++ mdl/executor/cmd_messagedefinitions.go | 12 ++--- mdl/executor/version_gates.go | 35 +++++++++++++++ mdl/executor/version_gates_test.go | 45 +++++++++++++++++++ 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-check-passes-removed-message-collection.json create mode 100644 mdl-examples/bug-tests/2139-message-collection-refused-at-check.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-check-passes-removed-message-collection.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-check-passes-removed-message-collection.json new file mode 100644 index 000000000..a4b781d78 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-check-passes-removed-message-collection.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"On a Mendix 11.16.0 project `mxcli check script.mdl -p app.mpr --references` printed \"Check passed!\" for `create or modify message definition collection …`; `mxcli exec` then failed with \"create message definition collection: Mendix 11.15 removed message definition collections (project is 11.16.0) — each definition is its own document now\" (also alter/drop/describe … collection)","cause":"#1434 moved every version FLOOR into statementVersionGates so check -p sees it, but the collection refusal is a CEILING (Mendix 11.15 removed the type) and stayed inline in four handlers as `if projectStoresMessageDocuments(ctx) { return messageCollectionsRemovedError(…) }` — not a checkFeature call, so TestCheckFeatureIsOnlyCalledFromTheGateTable could not flag it","file":"`mdl/executor/version_gates.go` (removedStatementError, called first by checkVersionGates); `mdl/executor/cmd_messagedefinitions.go` create/alter/drop handlers now call checkVersionGates","insight":"The #1434 guard only detects gates spelled checkFeature(…); a refusal built from a version predicate plus a custom error is the same check/exec gap in a form the guard cannot see. When check passes what exec refuses for the version, grep the handler for ProjectVersion/IsAtLeast/projectStores*-style predicates, not only checkFeature. Removal belongs in the gate pass too, with exec's own error text so the two are byte-identical. Exec now refuses before writing the statements ahead of it (pre-flight), where it used to write them and fail part-way.","refs":["ako/mxcli#2139","mendixlabs/mxcli#1434","ako/mxcli#987"]} diff --git a/mdl-examples/bug-tests/2139-message-collection-refused-at-check.mdl b/mdl-examples/bug-tests/2139-message-collection-refused-at-check.mdl new file mode 100644 index 000000000..a8ba17870 --- /dev/null +++ b/mdl-examples/bug-tests/2139-message-collection-refused-at-check.mdl @@ -0,0 +1,25 @@ +mdl 1; +-- ako/mxcli#2139: on a Mendix 11.15+ project `mxcli check -p` reported +-- "Check passed!" for a message definition COLLECTION, which `mxcli exec` then +-- refused — Mendix 11.15 removed the collection. The refusal was raised only +-- inside the handler; it is now a version gate, so check -p and exec's +-- pre-flight report it before anything is written. +-- +-- On an 11.15+ project: +-- mxcli check mdl-examples/bug-tests/2139-message-collection-refused-at-check.mdl -p App.mpr +-- Expected: MDL-VERSION01 on statement 3, "create message definition +-- collection: Mendix 11.15 removed message definition collections (project is +-- 11.x) …", with the hint `create message definition Module.Name for +-- Module.Entity { … }`. `mxcli exec` refuses with the same error and writes +-- nothing (not even the module and entity before it). +-- +-- On a project below 11.15 the script checks and executes cleanly. + +create module Bug2139; +create or modify non-persistent entity Bug2139.ApiTicket ( Subject: String(200) ); +create or modify message definition collection Bug2139.ApiMessages folder 'Api' +{ + definition TicketIn for Bug2139.ApiTicket { + Subject example 'Parcel arrived damaged' + } +}; diff --git a/mdl/executor/cmd_messagedefinitions.go b/mdl/executor/cmd_messagedefinitions.go index c925caf30..4a054db93 100644 --- a/mdl/executor/cmd_messagedefinitions.go +++ b/mdl/executor/cmd_messagedefinitions.go @@ -33,8 +33,8 @@ func execCreateMessageDefinitionCollection(ctx *ExecContext, s *ast.CreateMessag if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } - if projectStoresMessageDocuments(ctx) { - return messageCollectionsRemovedError(ctx, "create message definition collection") + if err := checkVersionGates(ctx, s); err != nil { + return err } existing := findMessageCollection(ctx, s.Name.Module, s.Name.Name) if existing != nil && !s.CreateOrModify { @@ -448,8 +448,8 @@ func execDropMessageDefinitionCollection(ctx *ExecContext, s *ast.DropMessageDef if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } - if projectStoresMessageDocuments(ctx) { - return messageCollectionsRemovedError(ctx, "drop message definition collection") + if err := checkVersionGates(ctx, s); err != nil { + return err } c := findMessageCollection(ctx, s.Name.Module, s.Name.Name) if c == nil { @@ -695,8 +695,8 @@ func execAlterMessageDefinitionCollection(ctx *ExecContext, s *ast.AlterMessageD if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } - if projectStoresMessageDocuments(ctx) { - return messageCollectionsRemovedError(ctx, "alter message definition collection") + if err := checkVersionGates(ctx, s); err != nil { + return err } c := findMessageCollection(ctx, s.Name.Module, s.Name.Name) if c == nil { diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go index b5d80b594..886b6dc48 100644 --- a/mdl/executor/version_gates.go +++ b/mdl/executor/version_gates.go @@ -629,6 +629,38 @@ func messageDefinitionDocumentGate(statement string) versionGate { "create message definition collection Module.Name { definition Name for Module.Entity { … } };"} } +// removedStatementError refuses a statement whose document type the project's +// Mendix version no longer has — a ceiling, where statementVersionGates are +// floors. Mendix 11.15 removed the message definition collection (each +// definition is its own document), so every collection statement is refused on +// an 11.15+ project with a hint naming the document form. It lives here, not in +// the handlers, so `check -p` and exec's pre-flight report it too: as a handler +// check it let check pass a script exec then refused (ako/mxcli#2139). +// +// describe is refused as well. Its handler still describes a collection it +// finds before refusing, but an 11.15+ project cannot hold one — `mx convert` +// turns each into a folder of documents — so the two agree on every project +// Studio Pro has opened. +func removedStatementError(ctx *ExecContext, stmt ast.Statement) error { + var statement string + switch s := stmt.(type) { + case *ast.CreateMessageDefinitionCollectionStmt: + statement = "create message definition collection" + case *ast.AlterMessageDefinitionCollectionStmt: + statement = "alter message definition collection" + case *ast.DropMessageDefinitionCollectionStmt: + statement = "drop message definition collection" + case *ast.DescribeStmt: + if s.ObjectType == ast.DescribeMessageDefinitionCollection { + statement = "describe message definition collection" + } + } + if statement == "" || !projectStoresMessageDocuments(ctx) { + return nil + } + return messageCollectionsRemovedError(ctx, statement) +} + // requireMessageDefinitionDocuments refuses the per-document form below 11.15. func requireMessageDefinitionDocuments(ctx *ExecContext, statement string) error { return checkGate(ctx, messageDefinitionDocumentGate(statement)) @@ -642,6 +674,9 @@ func checkGate(ctx *ExecContext, g versionGate) error { // feature it needs: the first of statementVersionGates that fails. Nil when not // connected, as checkFeature is. func checkVersionGates(ctx *ExecContext, stmt ast.Statement) error { + if err := removedStatementError(ctx, stmt); err != nil { + return err + } for _, g := range statementVersionGates(stmt) { if err := checkGate(ctx, g); err != nil { return err diff --git a/mdl/executor/version_gates_test.go b/mdl/executor/version_gates_test.go index 7d780e4bb..35d42d9e8 100644 --- a/mdl/executor/version_gates_test.go +++ b/mdl/executor/version_gates_test.go @@ -170,3 +170,48 @@ func TestCheckVersionGates_ViewQueryWithoutCommentPasses(t *testing.T) { t.Errorf("a view query without a comment was gated on 10.24.25: %+v", vs) } } + +// ako/mxcli#2139: Mendix 11.15 removed message definition collections, and exec +// refused every collection statement on an 11.15+ project — but only inside the +// handler, so `check -p` passed the script ("Check passed!") that exec then +// failed with "Mendix 11.15 removed message definition collections". The +// refusal is a CEILING rather than a floor, and it is reported by the same pass +// as the floors, with exec's own text and hint. +func TestCheckVersionGates_MessageCollectionsRemovedFrom1115(t *testing.T) { + name := ast.QualifiedName{Module: "Shop", Name: "ApiMessages"} + cases := []struct { + stmt ast.Statement + want string + }{ + {&ast.CreateMessageDefinitionCollectionStmt{Name: name}, "create message definition collection"}, + {&ast.CreateMessageDefinitionCollectionStmt{Name: name, CreateOrModify: true}, "create message definition collection"}, + {&ast.AlterMessageDefinitionCollectionStmt{Name: name, Op: "DROP", Target: "TicketIn"}, "alter message definition collection"}, + {&ast.DropMessageDefinitionCollectionStmt{Name: name}, "drop message definition collection"}, + {&ast.DescribeStmt{ObjectType: ast.DescribeMessageDefinitionCollection, Name: name}, "describe message definition collection"}, + } + for _, c := range cases { + t.Run(c.want, func(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{&ast.CreateModuleStmt{Name: "Shop"}, c.stmt}} + vs := versionGateViolations(versionCtx(t, 11, 16, 0), prog) + if len(vs) != 1 { + t.Fatalf("want one MDL-VERSION01 on 11.16.0, got %d: %+v", len(vs), vs) + } + v := vs[0] + if v.RuleID != "MDL-VERSION01" || !strings.HasPrefix(v.Message, "statement 2: ") || + !strings.Contains(v.Message, c.want) || + !strings.Contains(v.Message, "Mendix 11.15 removed message definition collections") || + !strings.Contains(v.Suggestion, "create message definition Module.Name for Module.Entity") { + t.Errorf("violation = %+v, want statement 2 refusing %q with the document-form hint", v, c.want) + } + // exec's refusal is the same text. + if err := checkVersionGates(versionCtx(t, 11, 16, 0), c.stmt); err == nil || + !strings.Contains(v.Message, strings.SplitN(err.Error(), "\n", 2)[0]) { + t.Errorf("exec's refusal %v differs from check's %q", err, v.Message) + } + // Control: 11.14 still stores collections. + if vs := versionGateViolations(versionCtx(t, 11, 14, 0), prog); len(vs) != 0 { + t.Errorf("control: a collection statement was refused on 11.14: %+v", vs) + } + }) + } +} From ebe29e6439c1871fcec4b5779f6790b7510b9f3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:02:06 +0000 Subject: [PATCH 21/26] fix(versions): keep a feature's max_version when YAML files share its floor mendix-10.yaml and mendix-11.yaml both list message_definition_collection from 10.0.0; only the 11 file closes it at 11.14.99. dedup kept the first entry, dropping the ceiling, so IsAvailable reported the collection on 11.16. The entry that states a max_version now wins (ako/mxcli#2140). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sLdsu6ePfRkrDVEMJ --- sdk/versions/registry.go | 12 +++++++++++- sdk/versions/registry_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/sdk/versions/registry.go b/sdk/versions/registry.go index 296299727..df24561ee 100644 --- a/sdk/versions/registry.go +++ b/sdk/versions/registry.go @@ -191,7 +191,11 @@ func toEntry(area, name string, f Feature) (FeatureEntry, error) { } // dedup removes duplicate entries (same area+name+min_version), keeping the -// entry with the most information (longest MDL or notes). +// entry with the most information: a stated max_version first — the file that +// records a removal knows more than one written before it (mendix-10.yaml and +// mendix-11.yaml both list message_definition_collection from 10.0.0, and only +// the 11 file closes it at 11.14.99, ako/mxcli#2140) — then the longest MDL or +// notes. func dedup(entries []FeatureEntry) []FeatureEntry { seen := make(map[string]int) // min_version string -> index in result var result []FeatureEntry @@ -200,6 +204,12 @@ func dedup(entries []FeatureEntry) []FeatureEntry { if idx, ok := seen[key]; ok { // Keep the entry with more detail. existing := result[idx] + if (e.MaxVersion != nil) != (existing.MaxVersion != nil) { + if e.MaxVersion != nil { + result[idx] = e + } + continue + } if len(e.MDL) > len(existing.MDL) || len(e.Notes) > len(existing.Notes) { result[idx] = e } diff --git a/sdk/versions/registry_test.go b/sdk/versions/registry_test.go index e90ca882c..df5ec9a0c 100644 --- a/sdk/versions/registry_test.go +++ b/sdk/versions/registry_test.go @@ -369,3 +369,32 @@ func TestStringAggIsOnTwoReleaseLines(t *testing.T) { t.Errorf("added since 10.24.0 = %q, want 10.24.22", got) } } + +// ako/mxcli#2140: mendix-10.yaml and mendix-11.yaml both list +// message_definition_collection from 10.0.0, and only the 11 file closes it at +// 11.14.99 (Mendix 11.15 removed the collection). dedup kept the first of the +// two — the one without the ceiling — so the registry said a collection is +// available on 11.16, and `mxcli syntax message-definition` printed "10.0.0+". +func TestMessageDefinitionCollectionEndsAt1114(t *testing.T) { + reg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + for _, c := range []struct { + v SemVer + available bool + }{ + {SemVer{10, 24, 0}, true}, + {SemVer{11, 14, 0}, true}, + {SemVer{11, 15, 0}, false}, + {SemVer{11, 16, 0}, false}, + } { + if got := reg.IsAvailable("integration", "message_definition_collection", c.v); got != c.available { + t.Errorf("IsAvailable(%s) = %v, want %v", c.v, got, c.available) + } + } + e, ok := reg.Lookup("integration", "message_definition_collection") + if !ok || e.MaxVersion == nil || e.MaxVersion.String() != "11.14.99" { + t.Errorf("Lookup = %+v, want the 11.14.99 ceiling", e) + } +} From 9c6bb19ee250f27c3a6786a426fd3bb43df64811 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:02:06 +0000 Subject: [PATCH 22/26] docs(syntax): lead message-definition with the 11.15+ document form (#2140) mxcli syntax message-definition documented only the collection form, which 11.15+ refuses. It now shows the document form first (list, describe, create with FOLDER before FOR, drop, alter, EXAMPLE), marks the collection form as Mendix 11.14 and earlier, and the Requires block shows a removed feature's range (10.0.0 to 11.14.99) instead of 10.0.0+. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sLdsu6ePfRkrDVEMJ --- ...help-shows-removed-message-collection.json | 1 + cmd/mxcli/cmd_syntax_test.go | 33 +++++++++++++++ cmd/mxcli/syntax/features_integration.go | 42 +++++++++++++++---- cmd/mxcli/syntax/format.go | 17 +++++++- cmd/mxcli/syntax/registry.go | 5 ++- cmd/mxcli/syntax/versions.go | 6 ++- ...140-message-definition-document-folder.mdl | 32 ++++++++++++++ 7 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-syntax-help-shows-removed-message-collection.json create mode 100644 mdl-examples/bug-tests/2140-message-definition-document-folder.mdl diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-syntax-help-shows-removed-message-collection.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-syntax-help-shows-removed-message-collection.json new file mode 100644 index 000000000..e328566d5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-11-syntax-help-shows-removed-message-collection.json @@ -0,0 +1 @@ +{"area":"cmd/mxcli/syntax","date":"2026-11-11","symptom":"`mxcli syntax message-definition` documented only `CREATE … MESSAGE DEFINITION COLLECTION`, which an 11.15+ project refuses, and its Requires block said `Mendix 10.0.0+` for the collection; the 11.15 document form appeared only in an exec hint, and a user wrote its folder clause after FOR (`… for Shop.ApiTicket folder 'Api' { … }` -> \"mismatched input 'folder' expecting {AS, '(', '{'}\") and concluded it had none","cause":"The syntax entry was written for the collection and never updated when #987 added the document form. Separately, sdk/versions dedup() merged mendix-10.yaml's and mendix-11.yaml's message_definition_collection entries (same 10.0.0 floor) keeping the FIRST — the one without max_version: 11.14.99 — so IsAvailable said the collection exists on 11.16 and the syntax page could not show the ceiling","file":"`cmd/mxcli/syntax/features_integration.go` (document form first, collection marked 11.14-and-earlier); `cmd/mxcli/syntax/format.go`/`versions.go` (Requirement.Until rendered as `10.0.0 to 11.14.99`); `sdk/versions/registry.go` dedup prefers the entry that states a max_version","insight":"The FOLDER clause existed all along (name, FOLDER, then FOR — the order of every document statement, and what describe prints); before adding a second clause position for a 'missing' clause, try it where describe puts it. A max_version in one per-major YAML is silently lost when another file lists the same floor without one: any ceiling added to a feature that an older file also lists needs a registry test, as TestMessageDefinitionCollectionEndsAt1114 now is.","refs":["ako/mxcli#2140","ako/mxcli#987"]} diff --git a/cmd/mxcli/cmd_syntax_test.go b/cmd/mxcli/cmd_syntax_test.go index 0a3eb6ef4..166208660 100644 --- a/cmd/mxcli/cmd_syntax_test.go +++ b/cmd/mxcli/cmd_syntax_test.go @@ -177,3 +177,36 @@ func TestSyntaxQualifiedTopicFromIssue1389(t *testing.T) { t.Errorf("mxcli syntax integration message-definition does not show the page:\n%s", firstLines(out, 6)) } } + +// ako/mxcli#2140: `mxcli syntax message-definition` documented only the +// collection form, which an 11.15+ project refuses; the per-document form it +// stores instead — and its FOLDER clause, which goes before FOR — appeared only +// in an exec error's hint. The page leads with the document form and marks the +// collection form as pre-11.15. +func TestSyntaxMessageDefinitionLeadsWithDocumentForm(t *testing.T) { + out := runSyntax(t, "message-definition") + doc := strings.Index(out, "CREATE [OR MODIFY] MESSAGE DEFINITION Module.Name [FOLDER 'path']") + if doc >= 0 && !strings.HasPrefix(strings.TrimSpace(strings.SplitN(out[doc:], "\n", 3)[1]), "FOR Module.Entity") { + doc = -1 + } + if doc < 0 { + t.Fatalf("the 11.15+ document form (with FOLDER before FOR) is not shown:\n%s", out) + } + if coll := strings.Index(out, "MESSAGE DEFINITION COLLECTION"); coll >= 0 && coll < doc { + t.Errorf("the collection form is shown before the 11.15+ document form:\n%s", out) + } + for _, want := range []string{ + "DESCRIBE MESSAGE DEFINITION Module.Name;", + "DROP MESSAGE DEFINITION [IF EXISTS] Module.Name;", + "LIST MESSAGE DEFINITIONS [IN Module];", + "ALTER MESSAGE DEFINITION Module.Name", + "[EXAMPLE 'text']", + "Mendix 11.14 and earlier", + // The Requires block states the ceiling, not "10.0.0+". + "Mendix 10.0.0 to 11.14.99 integration/message_definition_collection", + } { + if !strings.Contains(out, want) { + t.Errorf("mxcli syntax message-definition does not show %q", want) + } + } +} diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index cc2b43005..d4b996ab6 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -726,20 +726,45 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, Register(SyntaxFeature{ Path: "message-definition", Features: []string{"integration/message_definition_collection", "integration/message_definition_document"}, - Summary: "Message definition collections — a mapping source built from the domain model", + Summary: "Message definitions — a mapping source built from the domain model", Keywords: []string{ "message definition", "message definition collection", "create message definition", "exposed entity", "exposed attribute", "exposed association", }, - Syntax: "LIST MESSAGE DEFINITION COLLECTIONS [IN Module];\nDESCRIBE MESSAGE DEFINITION COLLECTION Module.Name;\nCREATE [OR MODIFY] MESSAGE DEFINITION COLLECTION Module.Name [FOLDER 'path']\n{\n DEFINITION Name FOR Module.Entity [AS 'Exposed'] {\n AttributeName [AS 'Exposed'] [EXAMPLE 'text'],\n Module.Assoc/Module.TargetEntity [AS 'Exposed'] { ... }\n }\n};\nDROP MESSAGE DEFINITION COLLECTION Module.Name;\n\n" + - "ALTER MESSAGE DEFINITION COLLECTION Module.Name\n" + - " ADD DEFINITION [IF NOT EXISTS] Name FOR Module.Entity [AS 'X'] { ... }\n" + - " | DROP DEFINITION [IF EXISTS] Name\n" + - " | RENAME DEFINITION Old TO New;\n\n" + - "ALTER MESSAGE DEFINITION Module.Collection.Definition\n" + + Syntax: "-- Mendix 11.15+: each message definition is its own document.\n" + + "LIST MESSAGE DEFINITIONS [IN Module];\n" + + "DESCRIBE MESSAGE DEFINITION Module.Name;\n" + + "CREATE [OR MODIFY] MESSAGE DEFINITION Module.Name [FOLDER 'path']\n" + + " FOR Module.Entity [AS 'Exposed'] {\n" + + " AttributeName [AS 'Exposed'] [EXAMPLE 'text'],\n" + + " Module.Assoc/Module.TargetEntity [AS 'Exposed'] { ... }\n" + + " };\n" + + "DROP MESSAGE DEFINITION [IF EXISTS] Module.Name;\n\n" + + "ALTER MESSAGE DEFINITION Module.Name\n" + " ADD MEMBER [IF NOT EXISTS] [IN path]\n" + " | DROP MEMBER [IF EXISTS] Name [IN path]\n" + " | SET MEMBER Name [IN path] AS 'Exposed';\n\n" + + "FOLDER goes after the name, before FOR, as in every document statement.\n" + + "A mapping names the definition in two parts: WITH MESSAGE DEFINITION\n" + + "Module.Name. EXAMPLE sets an element's sample text; a published REST\n" + + "operation whose import mapping uses the definition shows it in its OpenAPI\n" + + "request schema.\n\n" + + "-- Mendix 11.14 and earlier: definitions are entries of a collection.\n" + + "-- Refused on 11.15+ (Studio Pro's converter turns a collection into a\n" + + "-- folder of that name holding one document per definition).\n" + + "LIST MESSAGE DEFINITION COLLECTIONS [IN Module];\n" + + "DESCRIBE MESSAGE DEFINITION COLLECTION Module.Name;\n" + + "CREATE [OR MODIFY] MESSAGE DEFINITION COLLECTION Module.Name [FOLDER 'path']\n" + + "{\n DEFINITION Name FOR Module.Entity [AS 'Exposed'] { ...members as above... }\n};\n" + + "DROP MESSAGE DEFINITION COLLECTION Module.Name;\n" + + "ALTER MESSAGE DEFINITION COLLECTION Module.Name\n" + + " ADD DEFINITION [IF NOT EXISTS] Name FOR Module.Entity [AS 'X'] { ... }\n" + + " | DROP DEFINITION [IF EXISTS] Name\n" + + " | RENAME DEFINITION Old TO New;\n" + + "ALTER MESSAGE DEFINITION Module.Collection.Definition ;\n" + + "A mapping names a collection's definition in three parts\n" + + "(Module.Collection.Definition); on a converted 11.15+ project that\n" + + "reference resolves to the document in the folder named after the collection.\n\n" + "A message definition is a SELECTION OVER THE DOMAIN MODEL — every element\n" + "names an entity, an attribute or an association — which is why it is\n" + "authorable where an XML schema or a WSDL is not. It is the source for 74 of\n" + @@ -758,7 +783,8 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, "exposed name — it is not a model rename. Authoring is modelsdk-only.\n\n" + "Definitions and member trees are children, so they are in { } (R2); the\n" + "parenthesised form is the deprecated spelling MDL-DEPR073.", - Example: "mdl 1;\nCREATE MESSAGE DEFINITION COLLECTION Sales.MD_Order\n{\n DEFINITION OrderMessage FOR Sales.Order AS 'Orders' {\n OrderId,\n Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' { Sku, Quantity },\n Sales.Order_Customer/Sales.Customer { FirstName }\n }\n};\n\nALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage ADD MEMBER LastName IN Customer;\n\nCREATE IMPORT MAPPING Sales.IMM_Order\n WITH MESSAGE DEFINITION Sales.MD_Order.OrderMessage\n{ create Sales.Order { OrderId = OrderId } };", + Example: "mdl 1;\n-- Mendix 11.15+\nCREATE OR MODIFY MESSAGE DEFINITION Sales.OrderMessage\n FOLDER 'Messages'\n FOR Sales.Order AS 'Orders' {\n OrderId EXAMPLE 'ORD-1001',\n Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' { Sku, Quantity },\n Sales.Order_Customer/Sales.Customer { FirstName }\n };\n\nALTER MESSAGE DEFINITION Sales.OrderMessage ADD MEMBER LastName IN Customer;\n\nCREATE IMPORT MAPPING Sales.IMM_Order\n WITH MESSAGE DEFINITION Sales.OrderMessage\n{ create Sales.Order { OrderId = OrderId } };\n\n" + + "mdl 1;\n-- Mendix 11.14 and earlier\nCREATE MESSAGE DEFINITION COLLECTION Sales.MD_Order\n{\n DEFINITION OrderMessage FOR Sales.Order AS 'Orders' {\n OrderId,\n Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' { Sku, Quantity }\n }\n};\n\nCREATE IMPORT MAPPING Sales.IMM_Order\n WITH MESSAGE DEFINITION Sales.MD_Order.OrderMessage\n{ create Sales.Order { OrderId = OrderId } };", }) Register(SyntaxFeature{ diff --git a/cmd/mxcli/syntax/format.go b/cmd/mxcli/syntax/format.go index 6385d4057..041bbd1b0 100644 --- a/cmd/mxcli/syntax/format.go +++ b/cmd/mxcli/syntax/format.go @@ -52,11 +52,15 @@ func writeFeatureDetail(w io.Writer, f SyntaxFeature) { switch { case len(f.Requires) == 1: - fmt.Fprintf(w, "Requires: Mendix %s+\n\n", f.Requires[0].Since) + fmt.Fprintf(w, "Requires: Mendix %s\n\n", f.Requires[0].versionRange()) case len(f.Requires) > 1: fmt.Fprintln(w, "Requires:") + width := 9 for _, r := range f.Requires { - fmt.Fprintf(w, " Mendix %-9s %s\n", r.Since+"+", r.Feature) + width = max(width, len(r.versionRange())) + } + for _, r := range f.Requires { + fmt.Fprintf(w, " Mendix %-*s %s\n", width, r.versionRange(), r.Feature) } fmt.Fprintln(w) } @@ -118,3 +122,12 @@ func groupByDomain(features []SyntaxFeature) []featureGroup { } return groups } + +// versionRange is "10.0.0+" for a feature Mendix still has, and +// "10.0.0 to 11.14.99" for one it removed. +func (r Requirement) versionRange() string { + if r.Until != "" { + return r.Since + " to " + r.Until + } + return r.Since + "+" +} diff --git a/cmd/mxcli/syntax/registry.go b/cmd/mxcli/syntax/registry.go index 29d8dfc3f..258103948 100644 --- a/cmd/mxcli/syntax/registry.go +++ b/cmd/mxcli/syntax/registry.go @@ -38,7 +38,10 @@ type SyntaxFeature struct { type Requirement struct { Feature string `json:"feature"` // "area/name" Since string `json:"since"` - MDL string `json:"mdl,omitempty"` + // Until is the last version that has the feature, for one Mendix removed + // (a message definition collection: 10.0.0 to 11.14, ako/mxcli#2140). + Until string `json:"until,omitempty"` + MDL string `json:"mdl,omitempty"` } var registry []SyntaxFeature diff --git a/cmd/mxcli/syntax/versions.go b/cmd/mxcli/syntax/versions.go index 22f513a12..396a99921 100644 --- a/cmd/mxcli/syntax/versions.go +++ b/cmd/mxcli/syntax/versions.go @@ -31,7 +31,11 @@ func resolveVersions() { if !found { continue } - f.Requires = append(f.Requires, Requirement{Feature: key, Since: e.MinVersion.String(), MDL: e.MDL}) + r := Requirement{Feature: key, Since: e.MinVersion.String(), MDL: e.MDL} + if e.MaxVersion != nil { + r.Until = e.MaxVersion.String() + } + f.Requires = append(f.Requires, r) if primary == nil { v := e.MinVersion primary = &v diff --git a/mdl-examples/bug-tests/2140-message-definition-document-folder.mdl b/mdl-examples/bug-tests/2140-message-definition-document-folder.mdl new file mode 100644 index 000000000..cc81c8ff0 --- /dev/null +++ b/mdl-examples/bug-tests/2140-message-definition-document-folder.mdl @@ -0,0 +1,32 @@ +mdl 1; +-- ako/mxcli#2140: `mxcli syntax message-definition` showed only the pre-11.15 +-- collection form, so the 11.15+ document form — and where its FOLDER clause +-- goes — was found only in an exec error's hint. FOLDER follows the name and +-- precedes FOR, as in every document statement; after FOR it is a parse error +-- ("mismatched input 'folder' expecting {AS, '(', '{'}"). +-- +-- On an 11.15+ project: +-- mxcli exec mdl-examples/bug-tests/2140-message-definition-document-folder.mdl -p App.mpr +-- mxcli -p App.mpr -c "describe message definition Bug2140.TicketIn" +-- Expected: the definition is created in the folder 'Api' (`list message +-- definitions in Bug2140` shows it), describe prints `folder 'Api'` before +-- `for Bug2140.ApiTicket` with both examples, and `mxcli docker check` reports +-- 0 errors (measured on 11.16.0). Below 11.15 the statement is refused in +-- favour of the collection form. + +create module Bug2140; +create or modify non-persistent entity Bug2140.ApiTicket ( Subject: String(200), Priority: Integer ); +create or modify message definition Bug2140.TicketIn folder 'Api' + for Bug2140.ApiTicket +{ + Subject example 'Parcel arrived damaged', + Priority example '2' +}; +create or modify import mapping Bug2140.IMM_TicketIn + with message definition Bug2140.TicketIn +{ + create Bug2140.ApiTicket { + Subject = Subject, + Priority = Priority + } +}; From 97858ecaf790eb40559cfde614d01b7cfc393d4f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:07:02 +0000 Subject: [PATCH 23/26] pages: keep the specialization step of an association path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio Pro stores an association step whose DestinationEntity is a specialization of the association's end — TestApp WorkflowCommons Snip_WorkflowUserTaskView_Details goes over System.WorkflowUserTaskOutcome_User to Administration.Account for FullName. describe printed `System.WorkflowUserTaskOutcome_User/FullName`, exec bound System.User.FullName, and mx check reported CE1613 "The selected attribute 'System.User.FullName' no longer exists." at text11 and text16. - describe prints the cast as `Assoc/Module.Entity/Attr`, Mendix's expression spelling, when a step's destination is neither end of its association. It asks the builder's own associationEndpoints, so describe and exec agree on what an association connects. - The path resolver takes an entity segment after an association as that step's DestinationEntity when it specializes the step's target. Measured on TestApp (Mendix 11.14.0): describe -> exec of Snip_WorkflowUserTaskView_Details reports it unchanged, mx check 0 errors (was 2x CE1613). Of the 7 TestApp documents whose describe output changed in this branch, 6 come back unchanged. The seventh (Snip_WorkflowActivityRecord_Header, CE0402 x2 before, CE0117 x1 now) needs System.WorkflowSubProcess, which the System module metadata lacks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- ...-association-path-specialization-step.json | 1 + .../association-path-specialization-step.mdl | 37 +++++++++ ...md_pages_assoc_path_specialization_test.go | 78 +++++++++++++++++++ mdl/executor/cmd_pages_builder_v3.go | 20 +++++ mdl/executor/cmd_pages_describe_assoc_test.go | 2 +- .../cmd_pages_describe_column_assoc_test.go | 2 +- .../cmd_pages_describe_legacy_grid.go | 2 +- mdl/executor/cmd_pages_describe_output.go | 59 +++++++++++--- mdl/executor/cmd_pages_describe_parse.go | 6 +- mdl/executor/cmd_pages_describe_pluggable.go | 30 ++----- 10 files changed, 197 insertions(+), 40 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-specialization-step.json create mode 100644 mdl-examples/bug-tests/association-path-specialization-step.mdl create mode 100644 mdl/executor/cmd_pages_assoc_path_specialization_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-specialization-step.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-specialization-step.json new file mode 100644 index 000000000..4c4960c74 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-11-association-path-specialization-step.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-11-11","symptom":"describe -> exec of TestApp WorkflowCommons.Snip_WorkflowUserTaskView_Details: mx check CE1613 'The selected attribute System.User.FullName no longer exists.' at Text 'text11' and 'text16'","cause":"Studio Pro stores an association step whose DestinationEntity is a specialization of the association's end (System.WorkflowUserTaskOutcome_User -> Administration.Account, attribute Administration.Account.FullName). The describe readers (associationTemplateParamPath, columnAttributeFromRef) printed only the associations, and resolveAssociationPathFrom had no spelling for a cast, so the step landed on System.User","file":"mdl/executor/cmd_pages_describe_output.go (associationPathMDL, describeAssociationEnds), cmd_pages_describe_pluggable.go (columnAttributeFromRef), cmd_pages_builder_v3.go (resolveAssociationPathFrom, associationHasEnd)","insight":"A step's DestinationEntity is a cast exactly when it is neither end of its association; that test needs no direction, and describe answers it with the builder's own associationEndpoints through a bare pageBuilder, so the two halves cannot disagree about an association. Spell the cast Assoc/Module.Entity/Attr (Mendix's expression form). Before shipping a describe change, diff old vs new describe over every page and snippet of the corpora (TestApp, PedApp) and round-trip every changed document through mx check: here that turned up DataGrid2 columns printing the cast too (they came back Unchanged), and one snippet still failing because System.WorkflowSubProcess is missing from modelsdk/meta/system_module.go, which was broken before as well (CE0402 old, CE0117 now).","refs":["#833"],"ce":["CE1613"]} diff --git a/mdl-examples/bug-tests/association-path-specialization-step.mdl b/mdl-examples/bug-tests/association-path-specialization-step.mdl new file mode 100644 index 000000000..370e5bd77 --- /dev/null +++ b/mdl-examples/bug-tests/association-path-specialization-step.mdl @@ -0,0 +1,37 @@ +mdl 1; +-- ============================================================================ +-- Bug: describe dropped the specialization step of an association path +-- ============================================================================ +-- +-- Symptom (before fix): Studio Pro stores a step whose DestinationEntity is a +-- specialization of the association's end (TestApp WorkflowCommons +-- Snip_WorkflowUserTaskView_Details, text11: System.WorkflowUserTaskOutcome_User +-- to Administration.Account, attribute FullName). describe printed +-- `System.WorkflowUserTaskOutcome_User/FullName`, exec bound System.User.FullName, +-- and mx check reported +-- [CE1613] "The selected attribute 'System.User.FullName' no longer exists." at Text 'text11' +-- +-- After fix: the cast is spelled `Assoc/Module.Entity/Attr` (Mendix's expression +-- form), describe prints it when the destination is not an end of the +-- association, and the builder stores it as that step's DestinationEntity. +-- describe -> exec of that snippet reports it unchanged; this script is +-- mx check 0 errors on Mendix 11.14.0. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/association-path-specialization-step.mdl -p app.mpr +-- mxcli docker check -p app.mpr -- expect 0 errors +-- ============================================================================ + +create module BugTestSpec; + +create persistent entity BugTestSpec.Party (Name: String(100)); +create persistent entity BugTestSpec.Company extends BugTestSpec.Party (VatNumber: String(20)); +create persistent entity BugTestSpec.Invoice (Number: String(20)); +create association BugTestSpec.Invoice_Party from BugTestSpec.Invoice to BugTestSpec.Party; + +create page BugTestSpec.Invoice_View (Title: 'Invoice', Layout: Atlas_Core.Atlas_Default, Params: ($Invoice: BugTestSpec.Invoice)) { + dataview dvInvoice (DataSource: $Invoice) { + dynamictext txtVat (Content: 'VAT: {1}', ContentParams: ({1} = BugTestSpec.Invoice_Party/BugTestSpec.Company/VatNumber)) + textbox tbVat (Label: 'VAT', Attribute: Invoice_Party/BugTestSpec.Company/VatNumber, Editable: Never) + } +}; diff --git a/mdl/executor/cmd_pages_assoc_path_specialization_test.go b/mdl/executor/cmd_pages_assoc_path_specialization_test.go new file mode 100644 index 000000000..6d264d19d --- /dev/null +++ b/mdl/executor/cmd_pages_assoc_path_specialization_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// TestApp WorkflowCommons.Snip_WorkflowUserTaskView_Details, text11: Studio Pro +// stores an association step whose DestinationEntity is a SPECIALIZATION of the +// association's target — +// +// Attribute "Administration.Account.FullName" +// Steps [ { Association "System.WorkflowUserTaskOutcome_User", +// DestinationEntity "Administration.Account" } ] +// +// describe printed `System.WorkflowUserTaskOutcome_User/FullName`, dropping the +// cast, and exec wrote System.User.FullName: mx check CE1613 "The selected +// attribute 'System.User.FullName' no longer exists." at Text 'text11'. +// `Assoc/Module.Entity/Attr` — Mendix's expression spelling of the cast — keeps it. + +// specializationPB extends ruleActionPB: SpecialRule specializes BusinessRule +// and declares Extra, which BusinessRule does not have. +func specializationPB(t *testing.T) *pageBuilder { + t.Helper() + pb := ruleActionPB(t) + dm := pb.execCache.domainModels[0] + dm.Entities = append(dm.Entities, &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: "e-special"}, Name: "SpecialRule", + GeneralizationRef: "Rules.BusinessRule", + Attributes: []*domainmodel.Attribute{{Name: "Extra"}}, + }) + return pb +} + +func TestAssociationPathSpecializationSegment(t *testing.T) { + pb := specializationPB(t) + qn, steps := pb.resolveInputAttribute("RuleAction_BusinessRule/Rules.SpecialRule/Extra") + if qn != "Rules.SpecialRule.Extra" || len(steps) != 1 || + steps[0].Association != "Rules.RuleAction_BusinessRule" || steps[0].DestinationEntity != "Rules.SpecialRule" { + t.Errorf("resolved %q %+v, want Rules.SpecialRule.Extra over Rules.RuleAction_BusinessRule to Rules.SpecialRule", qn, steps) + } + // Control: without the cast the step lands on the association's target. + qn, steps = pb.resolveInputAttribute("RuleAction_BusinessRule/Name") + if qn != "Rules.BusinessRule.Name" || len(steps) != 1 || steps[0].DestinationEntity != "Rules.BusinessRule" { + t.Errorf("control resolved %q %+v", qn, steps) + } + // An entity that does not specialize the step's target is not a cast. + if _, steps = pb.resolveInputAttribute("RuleAction_BusinessRule/Rules.RuleAction/ActionType"); len(steps) != 0 { + t.Errorf("a non-specialization was taken as a cast: %+v", steps) + } +} + +func TestDescribeAssociationPathKeepsSpecialization(t *testing.T) { + pb := specializationPB(t) + ref := func(attr, dest string) map[string]any { + return map[string]any{ + "Attribute": attr, + "EntityRef": map[string]any{"$Type": "DomainModels$IndirectEntityRef", "Steps": []any{int32(2), + map[string]any{"Association": "Rules.RuleAction_BusinessRule", "DestinationEntity": dest}}}, + } + } + isEnd := pb.associationHasEnd + if got := associationPathMDL(ref("Rules.SpecialRule.Extra", "Rules.SpecialRule"), false, isEnd); got != "Rules.RuleAction_BusinessRule/Rules.SpecialRule/Extra" { + t.Errorf("specialization described %q", got) + } + // Control: the association's own target is not printed. + if got := associationPathMDL(ref("Rules.BusinessRule.Name", "Rules.BusinessRule"), false, isEnd); got != "Rules.RuleAction_BusinessRule/Name" { + t.Errorf("control described %q", got) + } + // No lookup (no project): as before. + if got := associationPathMDL(ref("Rules.SpecialRule.Extra", "Rules.SpecialRule"), true, nil); got != "RuleAction_BusinessRule/Extra" { + t.Errorf("without a lookup described %q", got) + } +} diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index a2e9a3e8d..f56d9e4b7 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -2608,6 +2608,16 @@ func (pb *pageBuilder) resolveAssociationPathFrom(path, start string) (finalQN s // the path's start named a later hop into the wrong module. (#662) assocQN := pb.resolveAssociationPathIn(seg, current) dest, ok := pb.associationDestination(assocQN, current) + if !ok && len(steps) > 0 && seg != current && strings.Count(seg, ".") == 1 && pb.entityIsOrDescendsFrom(seg, current) { + // `Assoc/Module.Special/…`: a cast of the previous hop's + // destination to a specialization, which Studio Pro stores as that + // step's DestinationEntity (TestApp WorkflowCommons + // Snip_WorkflowUserTaskView_Details). Dropped, the attribute was + // qualified with the association's end: CE1613. + steps[len(steps)-1].DestinationEntity = seg + current = seg + continue + } if !ok { // Not a modelled association — it may be System.changedBy / // System.owner, which no domain model lists. (#1338) @@ -2793,6 +2803,16 @@ func (pb *pageBuilder) entityGeneralizations() (map[string]string, error) { return m, nil } +// associationHasEnd reports whether entity is one of the association's two +// ends; known is false when the association cannot be resolved. +func (pb *pageBuilder) associationHasEnd(assocQN, entityQN string) (isEnd, known bool) { + from, to, ok := pb.associationEndpoints(assocQN) + if !ok { + return false, false + } + return entityQN == from || entityQN == to, true +} + // associationEndpoints resolves a qualified association name to its FROM // (ParentID) and TO (ChildID) entity qualified names. // diff --git a/mdl/executor/cmd_pages_describe_assoc_test.go b/mdl/executor/cmd_pages_describe_assoc_test.go index 192efe54d..2099c970e 100644 --- a/mdl/executor/cmd_pages_describe_assoc_test.go +++ b/mdl/executor/cmd_pages_describe_assoc_test.go @@ -55,7 +55,7 @@ func TestAssociationTemplateParamPath(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if got := associationTemplateParamPath(tc.attrRef); got != tc.want { + if got := associationTemplateParamPath(nil, tc.attrRef); got != tc.want { t.Errorf("associationTemplateParamPath = %q, want %q", got, tc.want) } }) diff --git a/mdl/executor/cmd_pages_describe_column_assoc_test.go b/mdl/executor/cmd_pages_describe_column_assoc_test.go index fcea1b17f..e9281c54b 100644 --- a/mdl/executor/cmd_pages_describe_column_assoc_test.go +++ b/mdl/executor/cmd_pages_describe_column_assoc_test.go @@ -34,7 +34,7 @@ func TestColumnAttributeFromRef(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if got := columnAttributeFromRef(tc.ref); got != tc.want { + if got := columnAttributeFromRef(nil, tc.ref); got != tc.want { t.Errorf("columnAttributeFromRef = %q, want %q", got, tc.want) } }) diff --git a/mdl/executor/cmd_pages_describe_legacy_grid.go b/mdl/executor/cmd_pages_describe_legacy_grid.go index 9e028f70a..907dc2157 100644 --- a/mdl/executor/cmd_pages_describe_legacy_grid.go +++ b/mdl/executor/cmd_pages_describe_legacy_grid.go @@ -62,7 +62,7 @@ func parseLegacyGridItems(ctx *ExecContext, v any) []rawLegacyGridItem { Type: extractString(m["Type"]), } if ref, ok := m["AttributeRef"].(map[string]any); ok && ref != nil { - item.Attribute = columnAttributeFromRef(ref) + item.Attribute = columnAttributeFromRef(ctx, ref) } if item.Attribute == "" { // Older models store the binding as a path string instead. diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 55eb7b07e..4e74725cb 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -2153,21 +2153,38 @@ func formatParamFormatSuffix(ctx *ExecContext, pMap map[string]any) string { // template parameter whose AttributeRef binds an attribute over one or more // associations (AttributeRef.EntityRef = DomainModels$IndirectEntityRef). Returns // "" when the parameter is a plain (direct-attribute) binding. -func associationTemplateParamPath(attrRef map[string]any) string { +func associationTemplateParamPath(ctx *ExecContext, attrRef map[string]any) string { + return associationPathMDL(attrRef, false, describeAssociationEnds(ctx)) +} + +// associationEndFunc answers whether entity is one of the association's two +// ends; known is false when the association cannot be looked up. +type associationEndFunc func(assocQN, entityQN string) (isEnd, known bool) + +// associationPathMDL renders an IndirectEntityRef attribute binding as +// "Assoc/…/Attr" (shortAssoc drops each association's module), or "" when the +// binding is not over associations. +// +// A step whose DestinationEntity is not an end of its association is a cast to +// a specialization: Studio Pro stores Administration.Account after +// System.WorkflowUserTaskOutcome_User, whose end is System.User (TestApp +// WorkflowCommons.Snip_WorkflowUserTaskView_Details). It prints as +// "Assoc/Module.Entity/…", the Mendix expression spelling the builder reads +// back; dropped, the path re-executed against the association's end and the +// attribute was CE1613 "The selected attribute 'System.User.FullName' no +// longer exists". isEnd nil (no project) prints the associations only. +func associationPathMDL(attrRef map[string]any, shortAssoc bool, isEnd associationEndFunc) string { entityRef, ok := attrRef["EntityRef"].(map[string]any) - if !ok || entityRef == nil { - return "" - } - if extractString(entityRef["$Type"]) != "DomainModels$IndirectEntityRef" { + if !ok || entityRef == nil || extractString(entityRef["$Type"]) != "DomainModels$IndirectEntityRef" { return "" } steps := getBsonArrayElements(entityRef["Steps"]) if len(steps) == 0 { return "" } - assocs := make([]string, 0, len(steps)) - for _, s := range steps { - sm, ok := s.(map[string]any) + segs := make([]string, 0, len(steps)) + for _, st := range steps { + sm, ok := st.(map[string]any) if !ok { return "" } @@ -2175,13 +2192,35 @@ func associationTemplateParamPath(attrRef map[string]any) string { if assoc == "" { return "" } - assocs = append(assocs, assoc) + if shortAssoc { + segs = append(segs, shortAttributeName(assoc)) + } else { + segs = append(segs, assoc) + } + if dest := extractString(sm["DestinationEntity"]); dest != "" && isEnd != nil { + if end, known := isEnd(assoc, dest); known && !end { + segs = append(segs, dest) + } + } } attr := shortAttributeName(extractString(attrRef["Attribute"])) if attr == "" { return "" } - return strings.Join(assocs, "/") + "/" + attr + return strings.Join(segs, "/") + "/" + attr +} + +// describeAssociationEnds is the association-end lookup describe uses: the +// page builder's own, so describe and exec resolve an association one way. +func describeAssociationEnds(ctx *ExecContext) associationEndFunc { + if ctx == nil || ctx.Backend == nil || !ctx.Connected() { + return nil + } + if ctx.Cache == nil { + ctx.Cache = &executorCache{} + } + pb := &pageBuilder{backend: ctx.Backend, execCache: ctx.Cache} + return pb.associationHasEnd } // associationDataSourceExpr renders an association data source as the MDL diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 2a3e24f21..bb94b4bb7 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -1307,7 +1307,7 @@ func extractAttributeRef(ctx *ExecContext, w map[string]any) string { if _, ok := attrRef["Attribute"].(string); !ok { return "" } - path := columnAttributeFromRef(attrRef) + path := columnAttributeFromRef(ctx, attrRef) if !strings.Contains(path, "/") { // A plain binding: qualified where no entity is in scope (describeAttr). return describeAttr(ctx, extractString(attrRef["Attribute"])) @@ -1464,14 +1464,14 @@ func extractInputAttribute(ctx *ExecContext, w map[string]any) string { // the root it was refused at the snippet root (MDL-WIDGET34), or bound // against the wrong context (ako/mxcli#833). if root := firstNonEmpty(widget, parameterSourceName(w["SourceVariable"])); root != "" && attrRef != nil && attrRef["EntityRef"] != nil { - if path := columnAttributeFromRef(attrRef); strings.Contains(path, "/") { + if path := columnAttributeFromRef(ctx, attrRef); strings.Contains(path, "/") { return "$" + root + "/" + path } } if widget == "" || attrRef == nil { return extractAttributeRef(ctx, w) } - path := columnAttributeFromRef(attrRef) + path := columnAttributeFromRef(ctx, attrRef) if path == "" || strings.Contains(path, "/") { return extractAttributeRef(ctx, w) } diff --git a/mdl/executor/cmd_pages_describe_pluggable.go b/mdl/executor/cmd_pages_describe_pluggable.go index 1f5ec5e4a..bedac40b1 100644 --- a/mdl/executor/cmd_pages_describe_pluggable.go +++ b/mdl/executor/cmd_pages_describe_pluggable.go @@ -341,29 +341,11 @@ func buildColumnPropertyKeyMap(ctx *ExecContext, w map[string]any) map[string]st // `Assoc/.../Attr` using SHORT association names — the column-attribute grammar // (attributePathV3) accepts bare segments only, so a module-qualified association // would not re-parse. -func columnAttributeFromRef(attrRef map[string]any) string { - attr := shortAttributeName(extractString(attrRef["Attribute"])) - entityRef, ok := attrRef["EntityRef"].(map[string]any) - if !ok || entityRef == nil || extractString(entityRef["$Type"]) != "DomainModels$IndirectEntityRef" { - return attr - } - steps := getBsonArrayElements(entityRef["Steps"]) - assocs := make([]string, 0, len(steps)) - for _, s := range steps { - sm, ok := s.(map[string]any) - if !ok { - return attr - } - a := extractString(sm["Association"]) - if a == "" { - return attr - } - assocs = append(assocs, shortAttributeName(a)) // drop module prefix - } - if len(assocs) == 0 || attr == "" { - return attr +func columnAttributeFromRef(ctx *ExecContext, attrRef map[string]any) string { + if path := associationPathMDL(attrRef, true, describeAssociationEnds(ctx)); path != "" { + return path } - return strings.Join(assocs, "/") + "/" + attr + return shortAttributeName(extractString(attrRef["Attribute"])) } func extractDataGrid2Column(ctx *ExecContext, colObj map[string]any, colPropKeyMap map[string]string, entityContext string) rawDataGridColumn { @@ -492,7 +474,7 @@ func extractDataGrid2Column(ctx *ExecContext, colObj map[string]any, colPropKeyM // Check for AttributeRef (attribute property) if col.Attribute == "" { if attrRef, ok := value["AttributeRef"].(map[string]any); ok && attrRef != nil { - col.Attribute = columnAttributeFromRef(attrRef) + col.Attribute = columnAttributeFromRef(ctx, attrRef) } } @@ -670,7 +652,7 @@ func extractTextTemplateParameters(ctx *ExecContext, textTemplate map[string]any // reconstruct the "Assoc/.../Attr" path so DESCRIBE round-trips. The // bare attribute re-executes against the widget's own entity (CE1365, // mendixlabs/mxcli#1494). - if assocPath := associationTemplateParamPath(attrRef); assocPath != "" { + if assocPath := associationTemplateParamPath(ctx, attrRef); assocPath != "" { // Read from a named object (a parameter, a data view): the root // is part of the binding. Without it the path re-executed against // the context with no source, CE0402 (ako/mxcli#833). From e64d9de4cc5d1b0dcdac6a34085e8b90efc3ca4b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:13:05 +0000 Subject: [PATCH 24/26] bug-tests: use the canonical 'close page' spelling MDL-DEPR020 tolerates no deprecated spelling in mdl-examples; the conformance gate in make lint refused 'close_page' in the #2132 and #833 scripts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- .../bug-tests/2132-template-param-primitive-page-parameter.mdl | 2 +- .../bug-tests/833-association-path-from-snippet-parameter.mdl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl b/mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl index 280ebe995..392c78312 100644 --- a/mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl +++ b/mdl-examples/bug-tests/2132-template-param-primitive-page-parameter.mdl @@ -24,5 +24,5 @@ create module BugTest2132; create or modify page BugTest2132.PageParam (Params: ($IpAddress: String, $Port: Integer), Layout: Atlas_Core.Atlas_Default, Title: 'Param') { dynamictext txtIp (Content: 'Address {1}', ContentParams: ({1} = $IpAddress)) dynamictext txtPort (Content: 'Port {1}', ContentParams: ({1} = $Port)) - actionbutton btnIp (Caption: 'Go to {1}', CaptionParams: ({1} = $IpAddress), Action: close_page) + actionbutton btnIp (Caption: 'Go to {1}', CaptionParams: ({1} = $IpAddress), Action: close page) }; diff --git a/mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl b/mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl index fe508dfa4..ffd3cb3c7 100644 --- a/mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl +++ b/mdl-examples/bug-tests/833-association-path-from-snippet-parameter.mdl @@ -30,6 +30,6 @@ create association BugTest833.Order_Customer from BugTest833.Order to BugTest833 create snippet BugTest833.SN_OrderHeader (Params: ($Order: BugTest833.Order)) { dynamictext txtCustomer (Content: 'Customer: {1}', ContentParams: ({1} = $Order/BugTest833.Order_Customer/Name)) - actionbutton btnCustomer (Caption: '{1}', CaptionParams: ({1} = $Order/BugTest833.Order_Customer/Name), Action: close_page) + actionbutton btnCustomer (Caption: '{1}', CaptionParams: ({1} = $Order/BugTest833.Order_Customer/Name), Action: close page) textbox tbCustomer (Label: 'Customer', Attribute: $Order/BugTest833.Order_Customer/Name, Editable: Never) }; From 4556fe6022719683196824e7f9ae38a6a0e461d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:28:18 +0000 Subject: [PATCH 25/26] roundtrip: strike the TestApp snippets this branch fixed from knownFailures TestTestAppRoundTrip reports these as no longer breaking (the allowlist may only shrink): - Snip_UserTask_NameColumn, Snip_WorkflowUserTaskView_NameColumn, Snip_Workflow_Header: association paths read from a snippet parameter (#833). - Snip_WorkflowUserTaskView_Details: exec, via #833 and the specialization step. - Snip_WorkflowActivityRecord_Header now passes putget. It still breaks getput: System.WorkflowSubProcess is missing from the System module meta. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82rmxc4mZcUDT8ePYWM --- mdl/roundtrip/testapp_allowlist_test.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 30a0e511f..85e5fe7aa 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -126,19 +126,15 @@ var testAppKnownFailures = map[string]knownFailure{ "snippet WorkflowCommons.Snip_TaskAssignmentHelper_TargetUser_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Full": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_TimelineViewer_Timeline_Tasks": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_UserTask_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, "snippet WorkflowCommons.Snip_WorkflowActivityRecord_ActivityIcon": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Details": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, - "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); association-path template parameters and an expression parameter drift (#721)"}, + "snippet WorkflowCommons.Snip_WorkflowActivityRecord_Header": {laws: []law{lawGetPut}, issue: "#721 #833", why: "text4 reads System.WorkflowSubProcess over System.WorkflowActivityRecord_WorkflowSubProcess, which the System module meta (modelsdk/meta/system_module.go) lacks, so the path cannot resolve and is written as an expression (#833)"}, "snippet WorkflowCommons.Snip_WorkflowActivity_Details_ActivityType": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_WorkflowSubProcess_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_WorkflowSubProcess_IncompatibleWarning": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_WorkflowSubProcess_State": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowUserTaskView_Details": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_WorkflowUserTaskView_NameColumn": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path template parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_Workflow_CommentsAndAttachments_Admin": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet WorkflowCommons.Snip_Workflow_Header": {laws: []law{lawGetPut, lawPutGet}, issue: "#721 #826", why: "executes since describe prints snippet call Params (#826); an association-path caption parameter read from a snippet parameter loses its AttributeRef and SourceVariable (#721)"}, "snippet WorkflowCommons.Snip_Workflow_SubProcessDetails": {laws: []law{lawExec}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, } From 1c942f9cf8b8386f3ba047bca6cdce1424d1c12c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:31:56 +0000 Subject: [PATCH 26/26] docs: access-rule examples use the [%CurrentUser%] token, not $currentUser entity-access.md taught `where [... = $currentUser]` in a GRANT. An access rule has no variables: mxbuild reports CE0161 (measured on 11.14.0 for ako/mxcli#2099), and check now refuses it as MDL-XPATH04, which made check-skill-mdl fail on both examples. They now compare System.owner with the '[%CurrentUser%]' token, and the page says why. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011oK82sv2odH8kVjmGzPosB --- docs-site/src/language/entity-access.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs-site/src/language/entity-access.md b/docs-site/src/language/entity-access.md index b8f67069d..c105fd3b1 100644 --- a/docs-site/src/language/entity-access.md +++ b/docs-site/src/language/entity-access.md @@ -82,15 +82,20 @@ Limit which objects a role can see or modify using an XPath expression in the `W ```sql mdl 1; --- Users can only access their own orders +-- Users can only access their own orders (Shop.Order stores its owner) GRANT READ *, WRITE * ON ENTITY Shop.Order TO Shop.User - WHERE [Sales.Order_Customer/Sales.Customer/Name = $currentUser]; + WHERE [System.owner = '[%CurrentUser%]']; -- Only open orders are editable GRANT READ *, WRITE * ON ENTITY Shop.Order TO Shop.User WHERE [Status = 'Open']; ``` +An access rule has no variables: `$currentUser` (or any `$var`) is CE0161 in a +constraint, so write the user as the `'[%CurrentUser%]'` token, compared with +`System.owner` on an entity that stores its owner or with an association to +`System.User`. `mxcli check` reports a variable as MDL-XPATH04. + Note that single quotes inside XPath expressions must be doubled (`''`), since the entire expression is wrapped in single quotes. ### Additive Behavior @@ -125,7 +130,7 @@ which is how row-level security is normally written: mdl 1; -- Two separate rules for one role GRANT READ * ON ENTITY Shop.Order TO Shop.User WHERE [Status = 'Open']; -GRANT READ (Total) ON ENTITY Shop.Order TO Shop.User WHERE [Owner = $currentUser]; +GRANT READ (Total) ON ENTITY Shop.Order TO Shop.User WHERE [System.owner = '[%CurrentUser%]']; ``` Mendix combines them at runtime: a role's effective access is the union of every