diff --git a/.claude/lint-rules/sec_unconstrained_pii_read.star b/.claude/lint-rules/sec_unconstrained_pii_read.star index 36d1b8d1bb..3718236b34 100644 --- a/.claude/lint-rules/sec_unconstrained_pii_read.star +++ b/.claude/lint-rules/sec_unconstrained_pii_read.star @@ -52,18 +52,29 @@ def check(): if len(pii_attrs) == 0: continue - # Find roles with unconstrained READ + # Find roles that can read a PII attribute with no row constraint. The + # entity-level READ row is emitted when ANY member is readable, so it + # cannot answer this: a role granted `read (FullName)` has it too. The + # member row can. Its name is qualified for explicit member rights and + # bare when expanded from default rights, so match either spelling. unconstrained_roles = [] + readable_pii = [] for perm in permissions_for(e.qualified_name): - if perm.access_type == "READ" and perm.member_name == "" and not perm.is_constrained: - unconstrained_roles.append(perm.module_role_name) + if perm.access_type != "MEMBER_READ" or perm.is_constrained: + continue + for attr_name in pii_attrs: + if perm.member_name == attr_name or perm.member_name.endswith("." + attr_name): + if perm.module_role_name not in unconstrained_roles: + unconstrained_roles.append(perm.module_role_name) + if attr_name not in readable_pii: + readable_pii.append(attr_name) if len(unconstrained_roles) > 0: violations.append(violation( message="Entity '{}' contains PII attributes ({}) and is readable without XPath row constraints by: {}".format( e.qualified_name, - ", ".join(pii_attrs), - ", ".join(unconstrained_roles), + ", ".join(readable_pii), + ", ".join(sorted(unconstrained_roles)), ), location=location( module=e.module_name, diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index bbc33103d7..a5cc6021f8 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -139,5 +139,13 @@ {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli check`/`exec`/`fmt`/`diff` fail on a script saved by Windows PowerShell 5.1: a UTF-8 BOM gives `line 1:0 token recognition error at: '\\ufeff'` (an invisible character), UTF-16LE gives a token error on almost every character; the same through stdin and in .test.mdl files", "cause": "Every script reader passed the raw file bytes to the lexer, which reads UTF-8 without a BOM; there was no shared reader (fmt, diff, the multi-file check pass, the test runner and EXECUTE SCRIPT each called os.ReadFile on their own)", "fix": "New mdl/srctext.Decode (strip a leading UTF-8 BOM, decode UTF-16LE/BE by BOM); readMDLSource calls it and fmt, diff and parseScriptSet now read through readMDLSource; testrunner.ParseTestFile and EXECUTE SCRIPT call it directly", "insight": "A BOM also hides a `mdl 1;` header from langver.ScanWrittenHeader, so stripping it in the parser alone would have left the language version wrong: decode where the bytes are read, before anything inspects the text. Enumerate the readers (grep os.ReadFile / io.ReadAll(os.Stdin)), not just the one the report names", "issue": "mendixlabs/mxcli#1253", "file": "mdl/srctext/srctext.go; cmd/mxcli/mdlsource.go", "test": "mdl/srctext/srctext_test.go; cmd/mxcli/mdlsource_encoding_test.go"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli -p App.mpr -c \"\"` opens the interactive REPL (a generator spawning mxcli with an open stdin hangs at `mdl>`); `-c \"describe entity System.User; describe entity String; describe entity System.FileDocument\"` stops at statement 2 with `module name is required: objects must be created within a module` and the third statement is silently never run", "cause": "Root Run tested `commands != \"\"` to choose -c over the REPL, so an empty flag value was indistinguishable from no flag; the -c path used ExecuteProgram, which returns the first error without its position, and describe entity/association reached findModule(\"\"), whose message is written for the create path", "fix": "`cmd.Flags().Changed(\"command\")` selects the one-liner path; runCommandLine (cmd/mxcli/oneliner.go) refuses empty input, reports `statement N of M` and how many later statements were not run (via new Executor.ExecuteProgramReportingStop), and takes --continue-on-error like exec; execDescribe names an unqualified entity/association name", "insight": "A flag's zero value is not its absence: use Changed() whenever an empty value must mean something other than not given. Decided semantics: -c is fail-fast like exec (a later statement may depend on an earlier one), but a stop is never silent", "issue": "mendixlabs/mxcli#1218", "file": "cmd/mxcli/oneliner.go; cmd/mxcli/main.go; mdl/executor/executor.go; mdl/executor/executor_query.go", "test": "cmd/mxcli/oneliner_test.go; mdl/executor/describe_unqualified_name_test.go"} {"date": "2026-10-01", "area": "cmd/mxcli", "symptom": "`mxcli lsp --stdio` exits rc=2 with `panic: only file URIs are supported, got mendix-mdl` on textDocument/didOpen of a `mendix-mdl:` virtual document (the VS Code extension's describe previews); VS Code restarts it, it crashes again, and after 5 crashes it stops restarting the server", "cause": "checkableDocument (diagnostics) and CodeAction called go.lsp.dev/uri URI.Filename(), which panics on any scheme but file, to decide whether the document is a .test.mdl", "fix": "documentPath(uri) returns Filename() only for file: URIs and the URI's path component otherwise; runSemanticCheck (which shells out `mxcli check `) skips non-file documents; virtual documents are still diagnosed in memory", "insight": "Third-party helpers that panic on unexpected input are a crash path in a long-running server; every URI an LSP client sends is untrusted shape. A test with a non-file URI plus a file-URI control (same diagnostics) proves the virtual case is handled, not skipped", "issue": "mendixlabs/mxcli#1245", "file": "cmd/mxcli/lsp_helpers.go (documentPath, isFileURI); cmd/mxcli/lsp_diagnostics.go; cmd/mxcli/lsp_language.go", "test": "cmd/mxcli/lsp_virtual_uri_test.go"} +{"area": "cmd/mxcli", "date": "2026-10-02", "symptom": "`mxcli test --local` fails to chain the project's after-startup microflow, then cleanup restores `AfterStartupMicroflow: 'AfterStartupMicroflow: ''Mod.Flow'` and the project is left pointing at `MxTest.RegisterEndpoint`", "cause": "`parseSettingValue` scraped DESCRIBE SETTINGS text and split only on `=`; the describe rewrite changed the output to `Key: 'value'`, so the whole line was kept as the value. The #803 tests used the old `=` lines only and stayed green against output describe no longer emits", "file": "`cmd/mxcli/testrunner/runner.go` (`parseSettingValue`, `getAfterStartup`)", "insight": "A parser of another command's human-readable output breaks silently when that output changes; the table test must carry the line the producer emits *today*. Split on the first `:` or `=`, unescape `''`, and match the key as a line prefix. Longer term, read the setting through the backend instead of scraping describe", "refs": []} +{"area": "cmd/mxcli", "date": "2026-10-02", "symptom": "`mxcli check tests/X.test.mdl` (and the VS Code LSP) warns MDL-V1-SLASH once per test, on the doc-comment line, although the skill says a test file takes no `mdl 1;` header", "cause": "`CheckSource` renders each block as a microflow and closed the wrapper with `END; /`; the rendering is parsed headerless, so the visitor recorded a V1 slash note on a `/` mxcli itself wrote. The author's `/` separators are blanked and never reach the parser", "file": "`cmd/mxcli/testrunner/check_source.go`", "insight": "Generated MDL must be canonical under every language version, or the diagnostics blame the author's file. `TestCheckSourceWrapperIsCanonical` asserted no Deprecations but not LanguageNotes; it now asserts both", "refs": []} +{"area": "cmd/mxcli", "date": "2026-10-02", "symptom": "`theme create --from --base console` writes `_mxcli-.scss` with a stray `}}`/`}` where the @font-face section was (27 `{` vs 29 `}`); the theme does not compile. Same for --base signal and ledger", "cause": "`dropFontFaces` matched each `@each $weight { @font-face { … } }` block with `[^}]*\\}[^}]*\\}`; the `src: url(\"…-#{$weight}-…\")` interpolation's `}` ended the first span early, so the match stopped at the @font-face close and left the @each's `}`", "file": "`cmd/mxcli/theme/create_seeded.go` (`dropFontFaces`, now `dropFontFaceBlock`)", "insight": "Never match nested SCSS blocks with `[^}]*`; count braces (interpolations are balanced). The existing test used the real shape but asserted only absence of @font-face — any rewrite of a stylesheet should assert brace balance on every shipped asset. Not copying the fonts folder is intended when the design's font stacks name none of the vendored families", "refs": []} +{"area": "cmd/mxcli", "date": "2026-10-02", "symptom": "`mxcli check tests/X.test.mdl -p app.mpr --references` fails with `module not found: MxTest`; a body calling a microflow that does not exist is never reported", "cause": "`CheckSource` wraps each test block as `CREATE OR MODIFY MICROFLOW MxTest.…` but nothing in the rendering created MxTest (only a run does), and the CreateMicroflowStmt reference check returns on the missing module before `validateFlowBodyReferences`", "file": "`cmd/mxcli/testrunner/check_source.go` (`CheckSource`)", "insight": "A rendering checked against a project must declare everything the runner would create; an early return on the container hides every finding inside it, so a false positive here was also a false negative. `CREATE MODULE` on the first wrapper's line keeps line numbers", "refs": []} +{"area": "cmd/mxcli", "date": "2026-10-02", "symptom": "`mxcli exec` on a script with report pages prints ~35 MDL-WIDGET15 info notes (one per container with adjacent inline dynamictexts) on every run, burying the warnings and errors that matter", "cause": "execPreflight formatted every ValidateProgram violation with linter.TextFormatter, no severity filter — the same report `check` prints, on a command that is re-run", "file": "`cmd/mxcli/exec_preflight.go` (printPreflightViolations), `mdl/linter/output.go` (TextFormatter.OmittedInfos)", "insight": "Filter at the exec printer, not at the rule: `check` is where a script is reviewed and keeps every note, and lowering a rule's frequency would hide it there too. The formatter's summary line had to learn about the omitted notes, or it read `0 info` above the line saying N were hidden. exec has no structured diagnostics output, so JSON/SARIF (check --format) are untouched. `--verbose` on exec and diff restores the full report", "refs": []} +{"area": "cmd/mxcli", "date": "2026-10-02", "symptom": "`exec` of a script with a flow change it refuses (\"cannot be spliced\" under mdl 1) writes every statement before the refused one and none after it; `check -p` had predicted the refusal", "cause": "`execPreflight` ran the semantic pass, references and name clashes but not `CheckFlowVerdicts`, which `check -p` and `fmt` already run", "file": "`cmd/mxcli/exec_preflight.go` (`execPreflight`)", "insight": "Every verdict check can compute must also gate exec's pre-flight, or the two disagree and exec half-applies. --continue-on-error keeps running every other statement, so the verdict is printed there but does not refuse", "refs": []} +{"area": "cmd/mxcli", "date": "2026-10-02", "symptom": "`check -p` prints `1 issues: 0 errors, 1 warnings` then `✓ All references valid` then `1 issues: 1 errors, 0 warnings` — reads as an error next to a passing run, and neither line is the total", "cause": "each tier of runCheckFile (semantic, project verdicts, legacy widgets) formatted its batch with a TextFormatter that printed its own summary line", "file": "`cmd/mxcli/cmd_check.go` (`runCheckFile`), `mdl/linter/output.go` (`TextFormatter.NoSummary`, `WriteSummary`)", "insight": "A multi-stage report needs one summary at exit over everything printed; the per-batch formatter's summary is only right for single-batch callers (lint, exec preflight)", "refs": []} +{"date": "2026-10-02", "area": "cmd/mxcli", "symptom": "`run --local --watch` on Mendix 11.13: a page added while the loop runs is never bundled — the apply reports success (often 'applied via reload'), but opening the page 404s on dist/pages/..js and the page stays blank; restarting the loop fixes it", "cause": "mxbuild's tools/node/rollup-plugin-mendix-pages.mjs (byte-identical in 11.13.0 and 11.14.0) globs web/pages only at bundler start: watchChange compares path.relative(cwd, id) against PAGES_FOLDER=\"./pages\" + \"/\", a './' prefix relative() never yields, so shouldRefreshPageFiles never flips. mxcli could not see it: ensureClientServed probes index.js and its static imports, and pages are dynamic imports", "fix": "missingPageChunks compares web/pages/**/*.js against web/dist/pages/**/*.js (shape-gated: needs web/pages and web/dist/index.js, so 11.14 prebuilt and classic are no-ops); under --watch watchAndApply restarts the WebClientWatcher (a fresh rollup run re-globs) and reports one line; ensureClientServed does a one-shot BuildWebClient for the same condition. RunLocal stops whichever watcher is current at exit", "insight": "A stand-alone rollup watch repro (pages/A.js, then add pages/B.js) isolates it in seconds: the 'change pages/B.js create' event fires but the next bundle still emits only A; a fresh watcher, or the plugin with PAGES_FOLDER=\"pages\", emits both. Restart rather than one-shot: the stale watcher would also never rebuild the new page when it is edited later. A structural change (navigation) clears web/dist and the old index.js fallback re-bundles everything, masking the bug; a page-only add applied via reload is the reproducing case. E2E 11.13: pre-change binary 404 on the new chunk; fixed binary 200 + page text, and a follow-up edit of the same page re-bundled incrementally with no restart (control)", "file": "cmd/mxcli/docker/webclient_pages.go (missingPageChunks, recoverMissingPages); cmd/mxcli/docker/runlocal.go (watchAndApply, ensureClientServed, RunLocal watcher defer)", "test": "cmd/mxcli/docker/webclient_pages_test.go"} {"date": "2026-10-02", "area": "cmd/mxcli", "symptom": "A `.test.mdl` starting with `mdl 1;` loses its first test: `mxcli test --list` finds 41 of 42 with no message; `mxcli check` on it reads the bodies as mdl 0 (MDL-V1-SLASH / MDL-V1-LIMIT1 warnings fire under a file that says mdl 1); the generated runner scripts end every flow with `/`, which mdl 1 refuses; and `fmt --upgrade --header` adds no header to a test file", "cause": "Nothing in the test format read the header. It was body text in the first `/`-chunk, so that chunk's `/** @test */` was no longer a LEADING doc comment (scanDocComments) and parseMDLTests skipped it silently; CheckSource rendered only the bodies (dropping the header line) and put `END; /` on separators; GenerateTestRunner/GenerateTestFlows always wrote `/` and `create or replace`", "fix": "takeLanguageHeader reads the header with langver.HeaderSpan (the grammar's own rule: first token after trivia) and blanks it in place so lines/columns hold; TestCase carries Version + HeaderLine (markdown: per mdl-test block); CheckSource renders the header on its line and closes wrappers with `END;` (no `/`, any version); the generators write the header, `create or modify` and no `/` under mdl 1; parseTestFiles refuses a suite mixing versions (one suite = one script = one header); UpgradeSource honours AddHeader and writes the header at file top / inside each mdl-test block; UpgradeSource maps the upgraded rendering back with a line diff (difflib opcodes, every changed region must be body lines) instead of an index walk, because the MDL-V1-ESCAPE rewrite of `\\n` writes a real line break and adds a line; writeBodyLines no longer indents a body line that starts inside a string literal", "insight": "A header is not just a flag to pass along — in a format that is NOT parsed as one script, every consumer that splits the text has to take it out first, or it becomes content in whichever chunk it lands in. The silent drop came from the same 'leading doc comment' rule as #927; a per-test Version also forced the question 'what is a suite's version', which only refusal answers safely. The runtime run was what found the last defect: unit tests and `mx check` were green while an upgraded test asserting length 3 saw 5 — the generators indented the continuation line of a multi-line string literal, so the indent became part of the value. Compare an mdl 0 file, its fmt --upgrade output, and a headerless control in the running app (both runners)", "issue": "ako/mxcli#847", "file": "cmd/mxcli/testrunner/parser.go (takeLanguageHeader, suiteLanguageVersion); check_source.go; generator.go; generator_endpoint.go; upgrade_source.go; mdl/langver/langver.go (HeaderSpan)", "test": "cmd/mxcli/testrunner/language_header_test.go; e2e: mxcli test --local on a fresh 11.13.0 app, endpoint and --legacy-runner"} {"date": "2026-10-02", "area": "cmd/mxcli/check", "symptom": "`mxcli check x.test.mdl -p app.mpr` reports `module not found: MxTest` once per test and exits 1 on a file `mxcli test` runs green — a test file can never pass check, and a real missing reference in a body is hidden behind it", "cause": "CheckSource wraps each test body in a `MxTest.Check_*` microflow (the runner's module), and the reference pass resolved that module against the project; the runner creates MxTest as the first statement of every generated script and drops it afterwards, so the project never has it", "fix": "testrunner.WithRunnerModule appends `create module if not exists MxTest` to the program used for the reference and project-conflict pass of a test file (appended so `statement N` still numbers the tests; definitions are collected program-wide; `if not exists` keeps a user's own MxTest from reading as a conflict)", "insight": "Seed the module the way the runner does, rather than exempting the file: the control (a body with a real missing entity) must still fail, and before the fix it showed only the MxTest error — the false positive was also masking true ones", "issue": "ako/mxcli#677", "file": "cmd/mxcli/testrunner/check_source.go (WithRunnerModule); cmd/mxcli/cmd_check.go", "test": "cmd/mxcli/check_test_file_refs_test.go"} diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index d439467253..2c812702a1 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -159,7 +159,10 @@ {"date": "2026-10-02", "area": "mdl/backend", "symptom": "Studio Pro 11.15 over --mcp: every write in a module fails 'validation failed for ' with another document's error (e.g. an empty enumeration elsewhere); a nonexistent document checks 'No errors found.'", "cause": "11.15 replaced ped_check_errors' documents[] with filters{documentType,documentNamePrefix}+pagination and made all input schemas additionalProperties-permissive, so the old argument is accepted and IGNORED: the check ran project-wide. The answer became a paged listing ('Listing problems a-b (out of n). Check ID: k', then 'Name' (Type): unit headers).", "fix": "checkDocumentNow (mdl/backend/mcp/check_errors.go) sends filters when SupportsToolArg('ped_check_errors','filters'), fetches every page repeating the filters (a page without them lists the whole project), re-runs on a stale window, and keeps only problems under the exact 'Name' (Type): header (documentNamePrefix is a prefix). An unscoped listing from the fallback form is scoped the same way.", "insight": "A permissive schema turns an argument rename into a silent no-op, so 'the call succeeded' proves nothing; the live control needs a deliberately broken document AND a clean one checked in the same run (TestLive_CheckErrorsIsScoped). Gate on the newer shape's argument, never on serverInfo.version.", "file": "mdl/backend/mcp/check_errors.go", "issue": ""} {"date": "2026-10-02", "area": "mdl/backend", "symptom": "Studio Pro 11.15 over --mcp: DESCRIBE of an entity edited this session shows String(unlimited) for String(200) attributes; reconstructed associations have empty type/owner.", "cause": "11.15's ped_read_document omits every property equal to its schema default (StringAttributeType.length=200, Association.type=Reference/owner=Default, NoGeneralization.persistable=true, BooleanConditionOutcome.value=false); readers mapped absent to Go's zero value, and length 0 means unlimited.", "fix": "attributeTypeFromPED reads length as *int and defaults absent to 200; reconstructAssociations defaults absent type/owner to Reference/Default.", "insight": "Diff the READ output of the same element across releases, not just tool schemas: this change is in one sentence of ped_read_document's description. Any new PED reader must treat an absent property as the schema default (ped_get_schema kind:element shows '= default').", "file": "mdl/backend/mcp/read_router.go", "issue": ""} {"date": "2026-10-02", "area": "mdl/backend", "symptom": "create workflow \u2026 display 'X' over --mcp stores the document name as the workflow title and workflowName (Studio Pro 11.14 and 11.15).", "cause": "The context-shaped Workflows$Workflow constructor ignores the title (11.14 'caption', 11.15 'title') and workflowName it is given; 11.15 also renamed caption->title, which its permissive schema would have dropped silently anyway.", "fix": "workflowConstructorTakesContext's probe records the title key (workflowCtorTitle); workflowCreateLeafOps sets /title and /workflowName/text after the create (set ops both releases accept).", "insight": "Read back what a constructor stored instead of trusting SUCCESS: both releases accepted the key and applied nothing.", "file": "mdl/backend/mcp/workflow.go", "issue": ""} +{"date": "2026-10-02", "area": "mdl/backend", "symptom": "alter page … set (Caption = '…') on tabPage2 fails with `widget \"tabPage2\" not found` although describe page prints `tabpage tabPage2`; a tab caption missing its default-language translation (mxbuild CE4899 after switching DefaultLanguageCode) could only be fixed by re-creating the whole page.", "cause": "findInWidgetChildren (and its twins findNearestDSInChildren and collectWidgetScopeInChildren) walked TabPages[] only to descend into each page's Widgets[], never matching the Forms$TabPage's own Name — a tab page is a named element that lives in a list that is not a widget list.", "fix": "Walk TabPages through findInWidgetArray / findNearestDSInWidgets / collectWidgetScope like any named list; INSERT INTO a tab page appends to its Widgets, and INSERT BEFORE/AFTER, REPLACE and DROP on a tab page are refused (refuseTabPageSiblingEdit) because they would write widgets into TabPages or orphan the control's DefaultPagePointer.", "insight": "Resolving a new kind of node also hands it to every structural op that trusts parentKey/parentArr: making X addressable means deciding, per op, what writing into X's parent list does.", "file": "mdl/backend/pagemutator/mutator.go", "issue": ""} +{"date": "2026-10-02", "area": "mdl/backend", "symptom": "On a project whose default language is de_DE, alter page … set Caption on a tab page (and set Label on any Studio Pro 11 input widget) reports `Altered page` and stores nothing; set Caption on a button writes the German text over the first translation (en_US); set Title overwrites every language with the same string.", "cause": "Four text writers with four rules: setTranslatableText looked for a `Translations` key no Mendix document has, then DSet a `Text` field DSet cannot add (silent no-op on every real Texts$Text); setWidgetLabelMut read only the legacy `Label` key while 10+/11 store LabelTemplate (a Forms$ClientTemplate); setClientTemplateText wrote Items[0] whatever its language; updateTextsTextValue/updateClientTemplateText wrote all translations. A unit test built the fictional `Translations` shape and asserted nothing, so it stayed green.", "fix": "One helper, setTextsTextTranslation: update or append the Texts$Translation for model.AuthoringLanguage() (the project default DESCRIBE shows), keep other languages; setTranslatableText dispatches on $Type (Texts$Text / Forms$ClientTemplate) and refuses anything else; setWidgetLabelMut tries LabelTemplate first; execAlterPage resolves the authoring language before the first mutation.", "insight": "A text setter's test must build the shape read back from a Studio Pro-authored page (bson dump --format ndsl) and assert the stored translation per language; `err == nil` proves nothing for a setter whose miss path is DSet returning false. Verified live: de_DE switch → CE4899 on tabPage2, set Caption → de_DE added, en_US/nl_NL/ar_DZ kept, 0 errors.", "file": "mdl/backend/pagemutator/mutator.go", "issue": ""} {"date": "2026-10-02", "area": "mdl/backend", "symptom": "create entity over --mcp stores every attribute as String(200) on both Studio Pro 11.14 and 11.15 MCP servers; String lengths never written (also on alter entity add attribute).", "cause": "pedAttribute sent \"$Type\":\"DomainModels$Attribute\" inside the DomainModels$Entity constructor, whose attributes are PlainObject<{name,type,enumerationName}>; with $Type present PED ignores `type` and still answers SUCCESS and a clean check. Neither attribute constructor has a length property.", "fix": "buildEntityValue clears $Type on nested attributes (omitempty); applyAttributeLengths sets /entities/N/attributes/M/type/length after create and after add for every String not at the schema default 200.", "insight": "The same element has two constructor shapes depending on where it is added (nested PlainObject vs standalone constructor) \u2014 read ped_get_schema for the container, not the element. A clean ped_check_errors proves nothing about a dropped property; only a read-back of each type caught it.", "file": "mdl/backend/mcp/domainmodel.go", "issue": "ako/mxcli#923"} {"date": "2026-10-02", "area": "mdl/backend", "symptom": "TestLive_EntityAccessRuleReject fails on every live Studio Pro MCP server (entityIndex: not found) \u2014 its fixture module ExpenseApproval exists in no test project.", "cause": "The live test read a hand-made fixture (module/entity/role) from one developer's project via env defaults instead of building it.", "fix": "The test creates its own entity Zz_R12_AccessFixture_ in MXCLI_MCP_MODULE, grants MXCLI_MCP_ROLE (default .User) once, then asserts the second grant is rejected with the rule count unchanged; skips with the role to set when the role does not exist (module roles cannot be created over MCP).", "insight": "A live test that depends on state it does not create fails as an environment problem on every other machine, so its real failures read as noise; build the fixture, and skip only on what MCP cannot author.", "file": "mdl/backend/mcp/security_test.go", "issue": "ako/mxcli#924"} {"area": "mdl/backend", "date": "2026-10-02", "symptom": "A view entity's OQL document that Studio Pro excluded is re-included by any CREATE OR MODIFY of the view entity that changes its query.", "cause": "encodeViewEntitySourceDocument writes Excluded=false as a constant, and WriteViewEntitySourceDocument's update path carried only the stored ExportLevel (#816), not Excluded.", "file": "`mdl/backend/modelsdk/move_view_write.go` (WriteViewEntitySourceDocument), `export_level_carry.go` (keepStoredExcluded)", "insight": "A shared create/update encoder that writes model state as a constant needs a carry on the update path for every such key, not just the one a previous bug named (#816 ExportLevel, #914 Excluded).", "refs": ["ako/mxcli#827", "ako/mxcli#914"]} {"area": "mdl/backend", "date": "2026-10-02", "issue": "ako/mxcli#803", "symptom": "`create association A.X from A.E to B.F on delete restrict` (TO entity in another module) stores ChildDeleteBehavior DeleteMeIfNoReferences with a null ChildErrorMessage — the shape that stops the runtime starting (CapTrackV2 §1). The same statement within one module, and ALTER on the cross-module one, were fine.", "cause": "crossAssocToGen built the delete behaviour without the restrict message; #795 added patchCrossDeleteErrorMessage only at one call site (patchCrossAssociations' new-element arm), not in the converter, so CreateCrossAssociation still wrote null.", "file": "`mdl/backend/modelsdk/association_move_write.go` (crossAssocToGen)", "fix": "Call patchCrossDeleteErrorMessage inside crossAssocToGen, as assocToGen does inline; drop the now-redundant call site.", "insight": "A property fix belongs in the converter, not at the call site that was reported — enumerate the converter's callers. Test: mdl/backend/modelsdk/issue803_cross_assoc_restrict_test.go, keep behaviour as the null control."} +{"area": "mdl/backend", "date": "2026-10-02", "symptom": "getput: describe → exec of a combo box with `Editable: ` rewrites ConditionalEditabilitySettings/Conditions[0]: 2 -> 3", "cause": "Forms$ConditionalEditabilitySettings registered Conditions as a plain mandatory list (default marker 3); Studio Pro writes [2], as for visibility", "file": "`mdl/backend/modelsdk/widget_write.go` (RegisterTypeDefaults)", "insight": "measured on both editability settings in TestApp; the ALTER path (pagemutator setWidgetConditionalSettingMut) still hand-writes [3] for both settings", "refs": ["#721"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index bdc1b7daa5..c672869457 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -800,6 +800,30 @@ {"date": "2026-10-01", "area": "mdl/executor", "symptom": "mendixlabs/mxcli#175: `call workflow … on error continue` passed check and exec, then mx check: CE6035 at Call workflow activity. On 11.14.0 only continue fails: no clause, rollback and both custom handlers build.", "cause": "continueUnsupportedOn did not list call workflow, and adapters.StatementErrorHandling — a hand-kept list of statements carrying a clause — did not list CallWorkflowStmt (nor the REST/other workflow statements), so MDL076 could not even see the clause. MDL076 was also check-only: exec without the pre-check (-c, REPL, --no-check) wrote it.", "file": "mdl/executor/validate_microflow_error_handling.go; mdl/exprcheck/adapters/adapter_scope.go; mdl/executor/validate.go", "fix": "call workflow in continueUnsupportedOn; StatementErrorHandling falls back to reading any statement's ErrorHandling field by reflection (getErrorHandling delegates to it); MDL076 is exec-enforced.", "test": "TestMDL076_ReportsContinueOnCallWorkflow, TestMDL076_CallWorkflowAcceptsTheOtherClauses, TestMDL076_IsExecEnforced", "insight": "Three lists of 'statements with an ON ERROR clause' existed; each had drifted. A rule keyed on such a list silently does nothing for a statement the list forgot."} {"date": "2026-10-01", "area": "mdl/executor", "symptom": "ako/mxcli#571 / mendixlabs/mxcli#1206: `create published rest service` wrote only the path's {name} placeholders as operation parameters, each a String: every query and body microflow parameter failed mx check with CE0350, an Integer {id} with CE6539. `import mapping` / `export mapping` / `commit` on an operation parsed and were thrown away (CE0350 on the body, CE0354 on an object-returning microflow). Executing describe of a Studio Pro service (TestApp Services.OrdersRestApi) reported 'Modified' and broke a 0-error app with 5 errors: mappings cleared, Integer path params retyped String, body param dropped, Commit No->Yes, Basic+Session authentication turned off", "cause": "publishedRestOperationToGen built parameters from the path alone and wrote ExportMapping/ImportMapping \"\" and Commit \"Yes\" as constants; the reader never read parameters, mappings or commit, so describe could not print them and ALTER (which rewrites every operation) lost them too; the service writer also emitted constants for AuthenticationTypes / AuthenticationMicroflow / CorsConfiguration / Documentation / PublicDocumentation with no carry; Resources and operation Parameters were registered with list marker 2 where Studio Pro writes 3", "file": "mdl/executor/cmd_published_rest.go, mdl/backend/modelsdk/published_rest_write.go, mdl/backend/modelsdk/integration_read.go, mdl/backend/modelsdk/export_level_carry.go, mdl/visitor/visitor_rest.go, model/types.go", "fix": "the executor derives operation parameters from the microflow as Studio Pro does (path name -> Path, object/list -> Body, System.HttpRequest/HttpResponse -> none, else Query; the microflow parameter's type), merged over the stored parameters per bound microflow parameter so a header/renamed/described parameter survives; mappings and commit flow AST -> model -> BSON and back, describe prints them (commit when not Yes) and notes parameters MDL cannot state; an unknown commit value is refused at exec and by check (MDL-REST03); create or modify carries summary/documentation/object handling of the restated operation; UpdatePublishedRestService carries the stored service-level keys MDL cannot state (keepStoredTopLevel); list markers measured from TestApp", "test": "mdl/executor/cmd_published_rest_params_test.go; mdl/backend/modelsdk/published_rest_write_test.go TestCreatePublishedRestService_WritesParametersAndBindings, TestWithStoredTopLevel; mdl/roundtrip TestTestAppRoundTrip/published_rest_service_Services.OrdersRestApi (allowlist entry struck); mdl-examples/bug-tests/571-published-rest-parameters-and-mappings.mdl (TestApp copy: old binary 16 mx check errors, fixed 0, describe->exec Unchanged twice)", "insight": "A clause that parses and is then ignored is worse than a parse error: the grammar advertised import/export mapping for months while the writer hard-coded them empty. The fastest witness was the round-trip harness's own allowlist entry for the one Studio Pro published REST service in TestApp: removing it printed the whole loss set (bindings, parameter types, markers, authentication) in one diff. Studio Pro's metamodel (ped_get_schema over the MCP tunnel) gave the enum values and defaults: Commit defaults to No there, while mxcli keeps writing Yes when the clause is absent so existing scripts do not churn, and describe prints commit whenever it is not Yes."} {"area": "mdl/executor", "date": "2026-10-02", "symptom": "Nightly, Mendix 10.24 only: `TestMxCheck_DoctypeScripts/24-workflow-examples.mdl/modelsdk` → `skipped 481 version-gated lines` then `Execution error: entity 'WFTest.OrderContext' not found for parameter 'OrderContext'` — the same failure TestFilterByVersion_FileBaselineSurvivesAny was written for, back again with that test green", "cause": "`filterByVersion` treats a `-- @version:` directive as the file's floor only if no statement precedes it. #901 put `mdl 1;` on line 1 of every doctype script, above 24's `-- @version: 11.0+`; the header counted as a statement, the floor was lost, and PART H's `-- @version: any` re-enabled a section whose fixtures (WFTest.OrderContext, 11.0+) had been skipped", "file": "`mdl/executor/roundtrip_doctype_test.go` (filterByVersion)", "insight": "The language header is not a statement: `langver.IsHeaderLine` excludes it. The earlier regression test used a synthetic script without a header, so a corpus-wide header migration could not trip it — the new guard also runs filterByVersion on the real 24-workflow-examples.mdl. When a script-format migration lands, re-run the line-oriented tooling that reads those scripts (version gating, skip lists) against the real files, not synthetic ones. Repro: `MX_BINARY=~/.mxcli/mxbuild/10.24.24.119349/modeler/mx go test -tags integration -run TestMxCheck_DoctypeScripts/24-workflow ./mdl/executor/` (fails with exactly 481 skipped lines without the fix, 523 with it)", "refs": ["#901"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`create association M.X_Task from M.X to System.WorkflowUserTask` writes the association, then fails with `failed to reconcile access rules of module System: open …/00000000-0000-0000-0000-000000000002.mxunit: no such file`; the rest of the script is aborted. A re-run with `create or modify` is clean", "cause": "`reconcileModuleAccess` reconciles the TO end's module for a cross-module association; for System that is the virtual domain model, which has no stored unit to load. The `create or modify` re-run returns before reconciling, which hid it", "file": "`mdl/executor/cmd_associations.go` (`reconcileModuleAccess`)", "insight": "System is a module with a domain model but no unit — any per-module write sweep must skip it (`isBuiltinModuleEntity`). The alter-owner path reaches the same function", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`exec` prints `Created workflow: M.X` on every re-run of a `create or modify workflow`, even when `mxcli diff` says nothing would be written", "cause": "`execCreateWorkflow` printed a fixed `Created workflow:` line for both the create and the in-place update branch, bypassing `ReportMutation`, so write elision never surfaced as `Unchanged`", "file": "`mdl/executor/cmd_workflows_write.go` (`execCreateWorkflow`)", "insight": "Every mutating handler must report through `ctx.ReportMutation` (Created/Modified, downgraded to Unchanged on elision); a hand-rolled Fprintf is what makes a no-op look like churn", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "combo box `CaptionAttribute: FullName` on an entity extending Administration.Account fails mxbuild with CE1613 (`BIA.Employee.FullName` no longer exists); `Administration.Account.FullName` works. check and lint pass", "cause": "the widget engine's CaptionAttribute mapping qualified the name by concatenating the context entity, while every other attribute mapping goes through `resolveAttributePathForEntity`, which finds the declaring entity (the #12 inheritance fix missed this case)", "file": "`mdl/executor/widget_engine.go` (`resolveMapping`, case CaptionAttribute)", "insight": "Grep for `entity + \".\" +` when a CE1613 on an inherited attribute is reported: each hand-qualified path is a separate instance of the same defect", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`describe page` prints a combo box with `OnChange:` but without its `Attribute:` / `CaptionAttribute:` (only Label/DataSource/OnChange); the stored binding is correct and works at runtime, but describe → exec loses it", "cause": "`w.OnChange != \"\"` was added to the generic pluggable branch's has-content test for Slider/StarRating; that branch precedes the combo box's own branch and does not emit `Content` or `CaptionAttribute`", "file": "`mdl/executor/cmd_pages_describe_output.go` (`outputWidgetMDLV3`, generic pluggable branch)", "insight": "Widening the generic branch's guard captures known widgets with dedicated branches; gate the new term with `!isKnownCustomWidgetType`. A describe test per known widget with each newly-counted property would have caught it", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`rename entity M.VW_X to X` on a view entity: mxbuild CE6784 \"View Entity name is out of sync with the OQL query name\"; re-running `create or modify view entity M.X` then orphans the old source document (CE6786), which no MDL statement can drop", "cause": "`execRenameEntity` never renamed the `DomainModels$ViewEntitySourceDocument`, and `UpdateDomainModel` persisted the entity's stale `SourceDocumentRef`, undoing the RenameReferences sweep's rewrite (the same clobber `repointEntitySelfRefs` fixes for member names)", "file": "`mdl/executor/cmd_rename.go` (`execRenameEntity`), `mdl/backend/modelsdk/move_view_write.go` (`RenameViewEntitySourceDocument`)", "insight": "A view entity is two units; every entity-level operation (move, rename, drop) must carry the source document. Rename it by $Type+name, not `RenameDocumentByName`, which matches any unit of that name. Verified on testapp-views: describe → exec after the rename reports Unchanged", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`revoke` of a right that is already gone prints \"No access rules found matching M.Role on M.Entity\" — reads like an error on an idempotent re-run (exit 0)", "cause": "the no-op branches of `execRevokeEntityAccess` (partial and full) worded the outcome as a failed lookup instead of the Unchanged form other idempotent writes use", "file": "`mdl/executor/cmd_security_write.go` (`execRevokeEntityAccess`, `nothingToRevoke`)", "insight": "Wording only; an idempotent no-op should say Unchanged so it is not mistaken for a failure in script logs", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "MDL001 (nested loop) warns on every loop inside a loop, e.g. an intentional region x plan-type iteration, about 5 times per script", "cause": "the rule fired on `loopDepth > 0` alone, although its own message is only about the key-lookup case", "file": "`mdl/executor/validate_microflow.go` (LoopStmt case, `matchesOuterIterator`)", "insight": "A heuristic must test the shape its message describes: an IF inside the inner loop whose condition reads both the inner and an outer iterator. Independent cartesian iteration stays silent", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "Circular advice for a change inside a loop body. Under `mdl 1;` `create or modify microflow` of a flow whose only change is in a loop (`commit $c` -> `commit $c without events` inside `loop $c in $Cars`) is refused with 'Change activities with `alter microflow … { … }`'; following it, `alter … { replace commit $c with begin commit $c without events; end; }` fails with 'the fragment uses $c, which is not declared on the path before commit $c', and an alter that gets past scope (insert after / drop) is refused with '… inside a loop body … rewrite the loop with create or modify' — each mode sends the reader to the other", "cause": "Both refusals are intended (neither the create-or-modify splice nor the mutator edits inside a loop body, ADR-0012), but each gave generic advice naming the other mode. alter's scope check (checkFragmentScope/upstreamOf) walks only the top-level flows, so for an activity inside a loop it saw no path at all and refused the iterator before the mutator's real in-loop reason could fire", "file": "mdl/executor/flow_verdict.go (flowRefusal, replaceLoopAdvice), mdl/executor/cmd_flow_modify.go (sameLoopShell branch of patchDiff.gap, notSpliceable.loopHandle), mdl/executor/cmd_alter_flow.go (applyTo, enclosingLoop), mdl/backend/mfmutator/splice.go (graph.node)", "fix": "Both refusals now name the one statement that makes the change: `alter M.F { replace with begin begin … end loop|end while; end; };`, with the stored loop's handle (`loop $c in $Cars`, `while $N < 3`). alter checks for an in-loop target (the top-level loop holding it, at any depth) before any scope or fragment check, so the in-loop reason is reported first; the mutator's own in-loop message advises replacing the loop instead of create or modify. create or modify still refuses rather than replacing the loop itself, since that would renumber the loop silently", "test": "mdl/executor/cmd_flow_loop_advice_pedapp_test.go TestFlowLoopRefusals_PointAtReplaceLoop (exec and check -p, nested loop, controls: top-level scope refusal unchanged, the advised alter applies); flow_verdict_test.go TestFlowRefusalNamesTheFlowAndTheReason (while, control keeps generic advice); mfmutator splice_test.go TestSplice_Refusals. Stubbing enclosingLoop to nil brings back 'not declared on the path'; reverting splice.go brings back 'rewrite the loop with create or modify'", "insight": "When two refusals each recommend the other mode, test the advice by executing it: the test runs the alter the refusal names and asserts it succeeds. A check that runs per operation (scope) must not run before a check that refuses the whole target (in-loop): the earlier one sees a degenerate view of the target (no path inside a loop body) and reports a wrong reason. Measured on a fresh 11.14 app: `alter … replace loop` kept every object and flow outside the loop at the same $ID and position, renumbered only the loop and its body, and mx check stayed at 0 errors"} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`MDL067` names two unrelated diagnostics: the ERROR for `create or modify … if not exists` (contradictory guards) and the INFO note that a bare `commit $X;` now runs events. A user filtering or looking up MDL067 cannot tell which one they have", "cause": "Rule ids are string literals at each `addViolation` / `RuleID:` site with no registry, so a later rule picked an id already in use and nothing failed", "file": "`mdl/executor/cmd_enumerations.go` (validateIdempotencyGuard), `mdl/executor/cmd_create_guard.go`, `mdl/executor/validate_commit_events.go`", "insight": "The guard error moved to MDL085 (a gap no branch's history ever used); the commit note kept MDL067 because `fmt --upgrade`, mdl/upgrade and the released CHANGELOG already name it for that note. Before picking a rule id, grep the whole tree (`grep -rhoE 'MDL0[0-9]{2}' --include=*.go --include=*.md`) and `git log --all -S'MDLnnn'` — there is no `mxcli help ` and no central list to consult. The guard test now also asserts the old id is never reported", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "Re-running an idempotent `mdl 1` script with `exec -p` prints the MDL067 note (\"1 commit activity uses the default, which is now WITH EVENTS…\") for every microflow with a bare `commit $x;`, on every run — including a run that reports \"N documents already in sync (unchanged)\", where the stored flow already has WithEvents=true", "cause": "checkBareCommitEvents is a script-only check (ValidateProgram has a path, not a backend), so it cannot tell a flow whose stored commits would flip from one this script already wrote. execPreflight printed it unfiltered", "file": "`mdl/executor/validate_commit_events_settled.go` (DropSettledCommitNotes), `cmd/mxcli/exec_preflight.go`", "insight": "Filter after validation where a backend exists, rather than threading a backend into ValidateProgram: execPreflight holds the connected executor, and `StoredCommitEvents` (built for fmt --upgrade -p, ako/mxcli#873) already answers the per-variable stored WithEvents flags. Suppress only on exact per-variable multiset equality (bare = with events) — not found, plain create, extra commits or a stored `without` keep the note. `check` is unchanged: it prints violations before it connects. E2E on a Verify copy: run 2 of a two-flow script is silent; after storing one flow `without events`, only that flow is noted", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "Nightly test project, Mendix 11.14: `check script.mdl -p app.mpr --references` passed, `mx check` then failed with CE2421 \"Only attributes of type Hashed string, Integer, Long, String, Decimal, AutoNumber are allowed here.\" (a `textbox` bound to an enumeration) and CE0582 \"Widget drop-down is not supported in React client.\" (a classic `dropdown`). Probing the rest: every built-in input widget on an attribute type it does not take was equally silent, and `textbox (attribute: Some_Association)` was CE1613 \"The selected attribute 'M.E.Some_Association' no longer exists.\" — which also made `mx check` report NOTHING else in the project", "cause": "The project-tier page walk (validatePluggableAttributeScopes) only judged pluggable widgets, and its attributeIndex held attribute owners, never types; nothing read the Web UI `UseOptimizedClient` setting at check time. Built-in Forms input widgets were never asked what they bind", "file": "`mdl/executor/validate_widget_attribute_type.go` (builtinInputAttributeRules, memberTypeIndex, checkInputBinding, checkReactUnsupported), called from the walk in `validate_widget_attribute_scope.go`", "insight": "**Measure the matrix, do not transcribe the message.** One page per widget kind x attribute type on a fresh 11.14 app gave the whole table in one mxbuild run (25 widgets, 20 errors). Two traps surfaced only because every pair was built: (1) MDL `HashedString` in an entity is silently stored as String(unlimited), so a 'HashedString builds in a textarea' result was really a String — `describe entity` before trusting a probe's subject; HashedString and Date stay unjudged. (2) One CE1613 (bare association in an input) makes `mx check` report only that error — a probe page carrying one hides the rest, so it gets its own run. **Client setting, measured three ways**: patching `UseOptimizedClient` on copies (raw unit rewrite) showed CE0582 under `Yes` only; `No` and `MigrationMode` build the drop-down clean, so MigrationMode is not reported. **Why check, unlike MPR012 (lint)**: the drop-down on a `Yes` project is a build error mxbuild raises, not a deprecation — a describe->exec of such a page carries an error the project already fails on. Reuse the data-context walk (childContext) the pluggable scope check uses, so the two cannot disagree about which entity a widget sits on", "refs": [], "ce": ["CE2421", "CE0582", "CE1613"], "rules": ["MDL-WIDGET39", "MDL-WIDGET40"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`textbox (Label: 'x', ShowLabel: false)` (and checkbox, textarea, datepicker, dropdown, radiobuttons) still renders its label; check and exec are clean", "cause": "no input builder read ShowLabel (it is whitelisted in staticWidgetKnownProps, so nothing warned) and each set Label whenever one was given", "file": "`mdl/executor/cmd_pages_builder_v3_widgets.go` (`inputLabel`)", "insight": "Mendix stores \"Show label: No\" as LabelTemplate null — the writer's no-label case — so the fix is to drop the label, not to set a flag. Verified on 11.14: describe shows no Label and mx check is clean. Describe's extractShowLabel reads a TextVisible field the metamodel does not have (dead code)", "refs": []} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "A pluggable widget's visibility/editability is never stored. On a combo box (Mendix 11.14, `mdl 1;`): `visible: false` and `editable: Never` pass check, exec and `mx check` and are written as nothing (ConditionalVisibilitySettings null, Editable \"Always\"); `visible: ` / `editable: ` are refused as \"widget `c` (combobox) has no property `VisibleIf`/`EditableIf` [MDL-WIDGET01]\"; `editable: Never` drew MDL-WIDGET21 \"its `Editable` property is not written\"", "cause": "`buildPluggable` (cmd_pages_builder_v3.go) returned before the common tail of `buildWidgetV3` that runs `applyConditionalSettings`/`applyVisibleWhen` for every other widget, and its comment claimed the CustomWidget serializer hardcoded the settings to nil — it did not: `applyWidgetBase` writes ConditionalVisibility/EditabilitySettings onto any gen type with the setter, and gen CustomWidget has them plus `Editable`. The writer reads `CustomWidget.Editable` (filled by Finalize with def.DefaultEditable), not BaseWidget.Editable, so the shared helper alone still writes Always. Validation: `isBuiltinPropName` lacked VisibleIf/EditableIf/VisibleWhen (the visitor's lowered keys), so MDL-WIDGET01 refused them; MDL-WIDGET21's rationale (\"stored CustomWidget carries no Editable key\") was measured against a builder that never wrote it", "file": "`mdl/executor/pluggable_system_props.go` (new: applyPluggableSystemSettings, validatePluggableSystemProps = MDL-WIDGET41), `cmd_pages_builder_v3.go` (buildPluggable), `widget_engine.go` (isBuiltinPropName), `validate_widgets.go`, `validate_widget_editability.go` (MDL-WIDGET21 editability branch retired), `cmd_pages_describe_parse.go` (CustomWidget Editable read)", "insight": "**Read a Studio Pro-authored instance before believing a comment about what is stored.** A stock 11.14 app's Administration.Account_Edit combo boxes show `Editable`, `ConditionalVisibilitySettings`, `ConditionalEditabilitySettings` on the CustomWidget, and a declared system property as a PropertyType with `ValueType.Type == \"System\"` and NO WidgetProperty in the Object — so the settings belong on the widget, in the same Forms$ elements a text box uses, and the gate is the Type, not the Object. Gate on the package: Combobox declares Visibility+Editability, Switch only Editability, Datagrid/Accordion neither (`unzip -p .xml | grep systemProperty`). Read the declaration from the Type actually written (cw.RawType) at build time and from `mwidgets.GetTemplateBSON(widgetID, _, projectPath)` at check time — the same template + .mpk augmentation LoadWidgetTemplate uses, so check and exec cannot disagree. `AugmentTemplate` removes template system props the .mpk does not declare but never adds missing ones. Control: same script on the pre-fix binary → bson dump shows null settings and Editable Always for every combo box. `describe` already printed VisibleIf/EditableIf via appendAppearanceProps (the generic extractConditionalSettings runs for every widget); only CustomWidget Editable was unread. Side find: describe printed `Editable: Conditional, Editable: ` for ANY conditionally-editable widget (text box too).", "refs": [], "rules": ["MDL-WIDGET01", "MDL-WIDGET21", "MDL-WIDGET41"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "Mendix 11.13 project, security level Production, `mdl 1;`: a flow rebuilt with `drop microflow` in one exec run and `create microflow` in a LATER run builds to [CE0106] \"At least one allowed role must be selected if the microflow is used from navigation, a page, a nanoflow or a published service.\" -- `mxcli check -p` passed and nothing else flagged it. Within one run drop+create keeps the roles, and `create or modify` keeps them", "cause": "Not a write bug: a create in a later run is a NEW flow, and a new document in a module that has module roles of its own gets no access (defaultDocumentAccessRoles grants the auto-created .User only while the module has no other roles). consumeDroppedMicroflow carries a dropped flow's roles only within one session. The gap was that no check modelled 'flow with no allowed role + a document that needs one'", "file": "`mdl/executor/validate_flow_access.go` (CheckFlowAccess, MDL-SEC21), wired into the project tier of `cmd/mxcli/cmd_check.go`", "insight": "Measure what triggers a CE before modelling it -- the message names 'a published service' but a published REST operation's microflow does NOT raise CE0106 (mxbuild 11.14.0). Measured, one flow per use, no roles: page button, page data source, snippet (even unused), navigation menu item, menu document (even unused), nanoflow call (even from an unused nanoflow) -> CE0106 at Prototype AND Production, nothing at Off; a role-less NANOFLOW used from a page -> CE0106 with 'nanoflow' wording; REST op, microflow-only caller, unused flow, EXCLUDED page -> nothing. Every one of those uses stores the target's qualified name under a `Microflow`/`Nanoflow` key, so a raw-BSON walk of pages/snippets/layouts/nanoflows/menus/navigation finds them all without per-widget code. The check simulates the script (create/drop/grant/revoke/module roles/security level; documents it writes replace their stored uses) and reports only what the script CHANGES -- a before/after diff -- so a project's pre-existing CE0106 does not fail an unrelated script. E2E on a fresh 11.14 copy: setup at Production = 0 errors; exec `drop microflow`; `check` of the later `create microflow` -> MDL-SEC21 error, exit 1; exec anyway -> docker check CE0106; control with the grant -> check passes, 0 errors", "ce": ["CE0106"], "rules": ["MDL-SEC21"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "Mendix 11.13 project: a stub page created early kept access for every role after a later admin-only `grant view on page M.P to M.Admin;` — users expected the grant to restrict it. exec had printed only `Created page M.P`", "cause": "Not a write bug: a new page/microflow/nanoflow in a module with no module roles is granted to an auto-created `.User` (defaultDocumentAccessRoles, cmd_security_defaults.go), and document GRANT is additive, so the admin grant was added next to User. The default grant was silent, so nobody knew there was something to revoke", "file": "`mdl/executor/cmd_security_defaults.go` (reportAutoRoleGrant), called after the Created line in cmd_microflows_create.go / cmd_nanoflows_create.go / cmd_pages_create_v3.go via builtFlow.AutoGranted", "insight": "A default the tool applies on the user's behalf must be visible at the moment it is applied, or a later additive operation looks like it misbehaves. exec now prints ` access: granted to auto-created role M.User (the module has no other roles; a later grant adds to it — revoke it to narrow access)` under the create; documented next to GRANT (docs-site reference/security/grant.md) and in the manage-security skill. Control: a module with its own roles gets no default grant and no note. Measured on a fresh 11.14 copy: page + microflow in a new module both print the note, a microflow created after `create module role` does not, and `list access on page` after the admin grant shows User AND Admin"} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "A bare expression on a pluggable widget's Expression-typed property is silently dropped: chart series `dynamicBarColor: $currentObject/ColorHex`, HTML element attribute `attributeValueExpression: $currentObject/ColorHex` — check and exec clean, DESCRIBE shows nothing, mx check 0 errors, the chart renders default colours. The quoted form worked; a longer expression (`if … then … else`) was refused by the visitor with 'only DynamicClasses and a column's DynamicCellClass take an expression'", "cause": "`(IDENTIFIER|keyword) COLON dataSourceExprV3` matches `$var/X` (and `$var`) as a variable-led data source, so the visitor stored an *ast.DataSourceV3; every scalar reader in the pluggable engine (buildObjectListItem, resolveMapping's Value and default branches, the 4.6 explicit-property loop) went through stringifyAny, which returns \"\" for non-scalars, and the 4.6 loop's `default: continue` skipped it outright", "file": "`mdl/visitor/visitor_page_v3.go` + `visitor_widget_expression.go` (keepExpressionSource, genericWidgetExpression), `mdl/ast/ast_page_v3.go` (WidgetV3.ValueSource, WidgetExpressionV3), `mdl/executor/widget_expression_value.go` (scalarPropertyText), `widget_engine.go`, `validate_widget_expression_value.go` (MDL-WIDGET42)", "fix": "The visitor keeps the source text of a variable-led data-source value (WidgetV3.ValueSource) and stores `COLON expression` on a generic key as *ast.WidgetExpressionV3 instead of refusing it. One executor helper, scalarPropertyText, turns either into an Expression property's text and refuses it for every other scalar kind (texttemplate/primitive/attribute/…), at all four scalar read sites; check reports the mismatch as MDL-WIDGET42 from the .def.json mapping's operation", "insight": "The visitor cannot decide this — only the widget schema knows the key is Expression-typed — so the source text has to travel to the layer that knows the kind. A grammar alternative chosen by its first token (`$`) also claims every other meaning that token starts; any reader of that alternative's AST type in a scalar slot must refuse, not stringify to \"\". Control: pre-change binary on a fresh 11.14 app writes the series/attribute with no Expression (bson dump: 0 hits for the path), the fixed one writes `Expression: \"$currentObject/ColorHex\"` and mx check stays at 0 errors", "test": "mdl/executor/widget_expression_value_test.go (TestBuildObjectListItem_BareExpressionReachesAnExpressionProperty fails with expression = \"\" when widget_engine.go is reverted)"} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "MDL-WIDGET31 (inputs inside a list view written Editable false render read-only) stays quiet for a pluggable input: `listview lv (datasource: database M.Thing) { combobox cb (attribute: Status) }` — or a slider / star rating — checks clean and renders read-only in the app", "cause": "firstEditableInput only consulted editableWidgetTypes, the built-in Pages$ inputs carrying Editability; a pluggable widget is a CustomWidget and is in no such list", "file": "`mdl/executor/validate_listview_editable_inputs.go` (pluggableEditableInput, widgetDeclaresEditability)", "fix": "Count a pluggable widget as an input when its definition maps an attribute/association property the script sets AND its installed .mpk declares the Editability system property (cached per project+widget); without a readable package the binding alone decides. The registry is passed from validateWidgetTreeIn", "insight": "An attribute binding is not the same as an editable one: a progress bar binds `dynamicCurrentValue` as an attribute operation exactly like a slider binds its value, so `operation == attribute` alone would warn for display widgets. The package's (mpk.WidgetDefinition.SystemProps) is the discriminator, and it is already parsed. Control on a fresh 11.14 app: slider in a default list view warns, progress bar bound to an attribute does not; the pre-change binary warns for neither", "test": "mdl/executor/validate_listview_pluggable_inputs_test.go (TestMDLWIDGET31_PluggableInputInListView fails 0-vs-1 on the pre-change rule; TestWidgetDeclaresEditability_FromThePackage reads testdata/pedapp)"} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`check --references` REFUSES `[System.owner = '[%CurrentUser%]']` (and `System.changedBy`) on a System.FileDocument / System.Image / System.User specialization — \"the entity does not store owner\" and \"names System.owner, which is neither an attribute nor an association\" — while mxbuild 11.14 builds it clean; and it ACCEPTS bare `[owner = …]`, which mxbuild rejects with CE0161. `[System.Owner = …]` is CE1613 (case matters)", "cause": "Three cooperating mistakes: (1) `xpathImplicitMembers` listed bare `owner`/`changedBy` as members every entity has, but they are ASSOCIATIONS to System.User, addressed only as `System.owner`/`System.changedBy`; (2) `noteQualified` did not know those two associations, so the correct spelling was reported as naming nothing; (3) `validateRetrieveConstraints` read `HasOwner` etc. off the retrieved entity itself, but Mendix keeps the flags on the ROOT of the generalization chain (NoGeneralization) — a specialization never has them, and the System entities' flags were not recorded in `meta.SystemEntities` at all", "file": "`mdl/executor/validate_widget_member_refs.go` (`xpathImplicitMembers`, `xpathSystemAssociations`, `noteQualified`, `walkPath`), `mdl/executor/validate.go` (`systemMemberStoredOnChain`), `mdl/executor/validate_retrieve_members.go` (`systemMemberSpellingHint`), `modelsdk/meta/system_module.go` (`HasOwner`… on SystemEntityDef, `SystemEntityStoresMember`)", "insight": "The System entities' stored members are MEASURABLE: each root's NoGeneralization in `deployment/model/model.mdp` carries HasOwnerAttr/HasChangedByAttr/HasCreatedDateAttr/HasChangedDateAttr (FileDocument and User all four; Workflow/QueuedTask/ProcessedQueueTask owner; SynchronizationError owner+createdDate; Session, XASInstance and runtime tables createdDate). `TestSystemMemberFlagsMatchTheDeployedModel -mdp` re-measures. Any 'does entity X store system member Y' question must walk to the chain's root — a specialization's own flags are always false. Spelling table for hints: `createdDate`, `changedDate` (attributes, bare), `System.owner`, `System.changedBy` (associations, qualified, lower camel)", "refs": [], "ce": ["CE0161", "CE1613"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`create persistent entity M.Doc extends System.FileDocument (…, owner: AutoOwner)` (any case, any of the four Auto* pseudo-types, or `alter entity … add attribute owner: AutoOwner` on a specialization) reports \"Created\" / \"Unchanged\" and writes nothing; when the chain's root does not store owner, every `[System.owner = …]` on the entity then fails mxbuild with CE0161", "cause": "The pseudo-types set HasOwner etc. on the entity, but the writer stores those flags only on a NoGeneralization; a specialization serializes a Generalization, which has no such properties, so the flags were dropped at write time with no report", "file": "`mdl/executor/cmd_entities_system_members.go` (`checkSpecializationSystemMembers`), called from `execCreateEntity` and the ALTER ADD ATTRIBUTE pseudo-type branch in `mdl/executor/cmd_entities.go`, and predicted at check time in `validateWithContext` (`mdl/executor/validate.go`); writer side `mdl/backend/modelsdk/domainmodel_write.go` (`GeneralizationRef != \"\"` branch)", "insight": "Mendix keeps system members on the ROOT of the generalization chain, so a declaration on a specialization has exactly two meanings: inherited (root stores it — warn, it is redundant) or impossible (root does not — refuse and name the root to change; for a System root nothing can change it). Resolve the root with `systemMemberRoot` (walks the project index, falls back to `meta.SystemEntityStoresMember`). The refusal runs before the module auto-create, so it leaves nothing behind", "refs": [], "ce": ["CE0161"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "A script that creates an association between entities the PROJECT already has (`create association M.Child_Parent from M.Child to M.Parent`) and then uses it — `retrieve … from M.Child where [M.Child_Parent = $P]` or `create M.Child (Child_Parent = $P)` — fails `check --references` (\"names M.Child_Parent, which is neither an attribute nor an association\" / \"M.Child has no member Child_Parent\") while mxbuild 11.14 builds it clean", "cause": "Two resolvers looked at the project only: the retrieve-constraint walk (`validateRetrieveMembers` → `xpathMemberVisitor.noteQualified`) asked `execXPathModel` (MPR) for the association, and `authoredMembers` collected script-declared ATTRIBUTES but not CREATE ASSOCIATION, so the member check fell through to `resolveMemberOnEntity` (MPR). Script-created ENTITIES were silent (memberUnknown), which is why the bug only shows when the entity pre-exists", "file": "`mdl/executor/validate_retrieve_members.go` (`scriptXPathModel`), `mdl/executor/validate.go` (`scriptContext.assocEnds`, `recordAssociation`), `mdl/executor/validate_member_refs.go` (`authoredMembers`)", "insight": "The 'entity from project, member from script' mix is the shape every check that consults only one side gets wrong — test with the entity created by a PRIOR exec and the association in the checked script. `scriptContext.associations` held only unqualified→qualified names; a hop needs the ENDS, now kept in `assocEnds` and resolved through the start entity's generalization chain like a stored association", "refs": [], "ce": ["CE0161", "CE1613"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`retrieve … where [St = getKey($S)]` (St an enumeration attribute, $S an enumeration variable) passes check and exec, then mxbuild 11.14 reports CE0161 \"Error(s) in XPath constraint.\"; `[St = $S]` builds clean", "cause": "getKey() is a Mendix EXPRESSION function; XPath has no such function and needs none — it compares an enumeration attribute with an enumeration value directly. MDL091's table of expression-only functions (`xpathExpressionOnlyFunctions`) listed only the measured startsWith/endsWith pair", "file": "`mdl/executor/validate_microflow.go` (`xpathExpressionOnlyFunctions`, `xpathExpressionOnlyFunctionFixes`, `checkXPathFunctionNames`)", "insight": "An expression function with no XPath counterpart gets an empty replacement and its own fix text (here: drop the call, compare with the variable). Add functions to MDL091 only when MEASURED against mxbuild — the list is evidence, not a guess at every name the two languages spell differently", "refs": [], "ce": ["CE0161"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`retrieve … sort by CreatedDate` or `sort by M.E.CreatedDate` (capital C — the spelling describe prints for `CreatedDate: AutoCreatedDate`) passes check, then mxbuild 11.14 reports CE1613 \"The selected attribute 'M.E.CreatedDate' no longer exists.\"; `sort by createdDate` and `sort by M.E.createdDate` build clean", "cause": "The system members are named in lower camel case (createdDate, changedDate) wherever a reference names them; the case is significant. No check looked at the spelling of a SORT column — the XPath constraint walk had the hint (`systemMemberSpellingHint`) but sort columns are not part of the constraint", "file": "`mdl/executor/validate_member_refs.go` (`checkSortSystemMemberSpelling`, called from `checkFlowMembers` for a database retrieve)", "insight": "Report only where the entity provably lacks an attribute of the exact capitalised name (project lookup memberMissing, or a script declaration whose same-named attribute is an Auto* pseudo-type) — a persistent entity cannot declare an ordinary `CreatedDate` (MDL020), but a resolver that cannot establish the members must stay silent. The fix text keeps the author's qualification: `M.E.CreatedDate` -> `M.E.createdDate`", "refs": [], "ce": ["CE1613"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`grant read *, write * on entity M.Doc to M.Role` on a System.FileDocument / System.Image specialization exec'd clean and wrote HasContents (and PublicThumbnailPath) ReadWrite; with security level Production mxbuild 11.14 reports CE6592 \"Attribute 'HasContents' cannot have write rights, because it is a system attribute\"", "cause": "`types.WriteRightsForbidden` knew two CE6592 causes (calculated, autonumber) and not the third: System attributes the platform maintains. The reconcile (`ReconcileMemberAccesses`) cannot load System ancestors, so it preserved the inherited ReadWrite entry unjudged", "file": "`mdl/types/member_write_rights.go` (third parameter), `modelsdk/meta/system_module.go` (`SystemAttrDef.WriteForbidden`, `SystemAttributeWriteForbidden`), `mdl/executor/entity_hierarchy.go` (`EntityMember.IsSystemReadOnly`), `mdl/executor/cmd_security_write.go` (grant), `mdl/backend/modelsdk/domainmodel_security_write.go` (preserve branch downgrades)", "insight": "Measured with ONE grant of `write *` per specialization under security level PRODUCTION — at level Off mxbuild does not run the access-rule checks at all and reports 0 errors, which is how a probe can wrongly conclude there is no defect. Forbidden: FileDocument.HasContents, Image.PublicThumbnailPath; allowed: Name, DeleteAfterDownload, Contents, Size, EnableCaching. The reconcile's preserve branch can judge a System ref without loading System (it is in meta), so it downgrades there — which also makes `update security` the repair for rules written before", "refs": [], "ce": ["CE6592"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "`revoke write (HasContents) on entity M.Doc from M.Role` (any INHERITED member, e.g. a System.FileDocument attribute on a specialization) answers \"No access rules found matching M.Role on M.Doc\" and changes nothing, while the rule holds ReadWrite for that member", "cause": "The partial revoke qualified every named member with the entity in the statement (`M.Doc.HasContents`), but a MemberAccess is stored against the entity that DECLARES the member (`System.FileDocument.HasContents`); the backend matched nothing and reported 0 modified, which the executor prints as 'no rules found'", "file": "`mdl/executor/cmd_security_write.go` (`execRevokeEntityAccess`, partial-revoke branch)", "insight": "GRANT already resolved names through `EntityMembers` (declaring-entity refs, #758); REVOKE was the second writer of the same reference and had its own, older qualification — the duplicate-resolver pattern. Any statement that names members of an access rule must go through EntityMembers. The 'No access rules found' text is also printed when a matching rule exists but no entry matched, so it misleads in exactly this case", "refs": [], "ce": []} {"area": "mdl/executor", "date": "2026-10-02", "symptom": "`describe regular expression` of a regex Studio Pro exported prints `ExportLevel: API`, and re-running that output is refused (`unknown ExportLevel \"API\" — expected one of Hidden, Public`); `ExportLevel: Public` is accepted and written to the model verbatim.", "cause": "validateRegularExpressionStmt allowed Hidden|Public; the metamodel's Projects$ExportLevel is Hidden|API (modelsdk/gen/projects/enums.go). The codec writes the value as given.", "file": "`mdl/executor/cmd_regularexpressions.go`, `mdl/visitor/visitor_regularexpression.go`", "insight": "Check an enum validator against modelsdk/gen/*/enums.go (generated from the reflection data), and round-trip describe's own output through it — the mismatch shows at once. `Public` was accepted and written, so under ADR-0011 it became a deprecated alias (MDL-DEPR161) that builds API, and describe prints a stored `Public` as API so re-running it repairs the document.", "refs": ["ako/mxcli#827"]} {"area": "mdl/executor", "date": "2026-10-02", "issue": "ako/mxcli#817", "symptom": "`rename entity Mod.Old to New` reports 'Updated N reference(s)', then `mx check` fails CE1613 \"The selected entity 'Mod.Old' no longer exists.\" at a SAME-module entity that extends the renamed one (`extends Mod.Old` survives the rename). Other modules are fine.", "cause": "execRenameEntity / execRenameAssociation read the domain model, ran RenameReferences (a raw-BSON sweep over every unit, including this module's domain model), then persisted the semantic model read BEFORE the sweep — UpdateDomainModel put every swept name in this unit back. repointEntitySelfRefs / repointAssociationMemberRefs had patched only the renamed element's own member refs, one clobbered field at a time.", "file": "`mdl/executor/cmd_rename.go` (execRenameEntity, execRenameAssociation)", "fix": "Re-read the domain model with GetDomainModel after the sweep, then apply the rename to it. The self-ref repoint helpers stay (idempotent; the mock test has no sweep).", "insight": "Any handler that mixes a raw sweep with a semantic persist of the same unit must order them so the later write sees the earlier one: either persist first and sweep after (rename attribute, #910) or re-read after the sweep. Patching individual fields the sweep would have fixed is whack-a-mole — #812 patched two and missed generalization. Test on the Studio Pro PedApp (Administration.Account, GUID != $ID) with a same-module subclass: mdl/executor/rename_sweep_cross_module_test.go."} {"area": "mdl/executor", "date": "2026-10-02", "issue": "ako/mxcli#803", "symptom": "`rename association Mod.Assoc to New` fails 'association not found' for an association whose TO entity is in another module (DESCRIBE and SHOW ASSOCIATIONS list it fine).", "cause": "execRenameAssociation searched only dm.Associations; a cross-module association is stored in the FROM module's dm.CrossAssociations.", "file": "`mdl/executor/cmd_rename.go` (execRenameAssociation)", "fix": "Search and rename in dm.CrossAssociations as well (one namespace for the collision check). UpdateDomainModel's patchCrossAssociations edits the stored element in place, so its GUID is untouched.", "insight": "Every lookup by association name must cover both collections — the split is a storage detail the user cannot see. Repro on TestApp's Studio Pro-authored ViewAssociations.persistent_order (GUID != $ID)."} @@ -808,3 +832,7 @@ {"area": "mdl/executor", "date": "2026-10-02", "symptom": "`$A = import from mapping M.IMM_Obj($Json) first;` on an OBJECT-rooted mapping passed check --references, exec and mx check (0 errors) and threw at runtime ('exception during execution', nothing in the runtime log); `limit n offset m` on the same mapping passed check and failed mx check with CE6100 \"This entity does not support offset.\"", "cause": "No check-time rule related an import activity's Range to the mapping's root shape. `first` always stores ForceSingleOccurrence=true, which on an object-rooted mapping is the `key not found: Path(QName(None,),None,)` runtime exception the builder's own comments record. The CE6100 behaviour was documented in the syntax reference and skill but enforced nowhere.", "file": "`mdl/executor/validate_import_mapping_range.go` (MDL-MAP04), `jsonMappingRootIsList` split out of `mappingRootIsList` in `cmd_microflows_builder_calls.go`", "insight": "A rule that predicts from a mapping's shape needs a TRI-STATE shape: the builder's mappingRootIsList guesses 'object' when it cannot tell (the safe write), but a refusal built on that guess would block valid scripts. Only the root path crossing an array and a resolvable JSON structure are definite; an XML/message-definition mapping or a `root a/b` path into a script-only sample stays unjudged. Measured on 11.14.0 the CE6100 claim in the docs is too narrow: `offset` on a LIST-rooted JSON mapping was CE6100 too — not refused yet, left as a follow-up.", "refs": ["ako/mxcli#570"]} {"area": "mdl/executor", "date": "2026-10-02", "symptom": "A view entity declaring `Total: Long` for `sum(r.WaferCount)` over an Integer column passed check --references (and exec) and failed mx check with CE6770 \"View Entity is out of sync with the OQL Query\"", "cause": "`typesCompatible` (the project-aware view check) accepted Integer and Long for each other and Decimal over either as a 'widening', although its sibling `typesStrictlyCompatible` already documented that mxbuild treats them as distinct.", "file": "`mdl/executor/oql_type_inference.go` (typesCompatible)", "insight": "Measured one probe view per pairing on 11.14.0: every numeric declaration other than the column's own kind is CE6770, pass-through and aggregated alike (Long over AutoNumber is the one exception). Re-measuring trap: mx check is PHASED — while any CE1613 stands (here: two pages with a bogus attribute in the same probe app) it reports none of the CE6770s at all, so a probe app with an unrelated broken document makes an out-of-sync view look clean.", "refs": ["ako/mxcli#565"]} {"area": "mdl/executor", "date": "2026-10-02", "symptom": "`create association M.A from M.WorkOrder to System.Account` (or from a made-up FROM entity) passed check --references as 'All references valid'; exec then stopped at the statement with `child entity not found: System.Account`, the statements before it already written", "cause": "The --references case for CreateAssociationStmt resolved each endpoint's MODULE (and whether it was a view entity) but never whether the entity exists.", "file": "`mdl/executor/validate_association_endpoints.go`, wired in `validate.go` (CreateAssociationStmt); scriptContext gains indirectEntities / externalEntityModules", "insight": "Resolve with the SAME lookup exec uses (findEntity), so check and exec cannot disagree, System module included. The skip set is everything the script can bring into existence without a CREATE naming it — rename, move, and bulk `create external entities` (whose names are in the service contract, not the statement); missing one turns a gap fix into a false refusal that now blocks exec.", "refs": ["ako/mxcli#555"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "integration roundtrip: TestReplacePluggableKeepsWhatTheStatementDoesNotState — `alter page … replace comboBox1 with { combobox … }` turns the stored Editable \"Never\" into \"Always\" again (#1247 regressed)", "cause": "the #1247 merge reads \"replacement differs from the baseline\" as \"the statement states it\"; once describe printed a pluggable widget's stored `Editable: Never`, the baseline built from the description carried it while a replacement that does not mention Editable built the default, so the default was taken as stated", "file": "`mdl/executor/cmd_alter_page.go` (`replaceBaseline`)", "insight": "Every property describe newly prints moves into the #1247 baseline and becomes overwritable by omission. A property the statement does not mention must be absent from the baseline too; done here for the visibility/editability settings describe now prints", "refs": ["#1247"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "integration roundtrip: TestApp WorkflowCommons snippets break exec — `combobox … (Attribute: TimeFrame)` \"has no entity to bind against\", image `Visible: CompletionType in (…)` \"place the widget inside a data container\"; widgets sit directly in a snippet with a parameter, no data view", "cause": "Studio Pro binds a widget outside every data container to a page/snippet PARAMETER: AttributeRef (or ConditionalVisibilitySettings.Attribute, or a combo box IndirectEntityRef) beside SourceVariable Forms$PageVariable {SnippetParameter|PageParameter: name, Widget: \"\"}. describe printed the attribute bare and exec had no spelling for the source, so it refused (or, before describe/pluggable Visible were fixed, silently wrote no binding)", "file": "`mdl/executor/cmd_pages_parameter_binding.go`, `cmd_pages_builder_v3.go` (resolveInputBinding/parameterVariable), `widget_engine.go` (Attribute/Association mappings), `cmd_pages_builder_visible_when.go`, describe in `cmd_pages_describe_parse.go`/`cmd_pages_describe_pluggable.go`, writer `widgetobj.SetSourceVariable`, `conditionalVisibilityToGen`", "insight": "A newly fixed describe gap can surface as an exec refusal the allowlist never expected: the refusal was right for the bare spelling and the cure is a spelling for the stored source (`$Param.Attr`, `$Param.Module.Assoc`, `Visible: $Param.Attr in (…)`), not a looser guard. Survey SourceVariable slot combinations (W/P/S/L) across the fixture first: TestApp had --S- on built-in inputs, pluggable values and 76 visibility settings, all unspelled", "refs": ["#721", "#826"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "describe prints a pluggable image's `Visible: Attr in (…)` (and expression Visible/Editable) twice", "cause": "the image branch appended appendConditionalProps and then appendAppearanceProps, which appends the same conditional settings", "file": "`mdl/executor/cmd_pages_describe_output.go` (image branch)", "insight": "appendAppearanceProps already owns visibility/editability; a branch that also appends them duplicates the key — grep for both on one widget kind", "refs": ["#721"]} +{"area": "mdl/executor", "date": "2026-10-02", "symptom": "integration roundtrip: describe → exec of TestApp's WorkflowCommons.UserTask_Assign fails: \"widget `grid8` (datagrid) cannot have its visibility set: its widget package declares no Visibility system property\"", "cause": "MDL-WIDGET41 gated Visible: on the package declaring , measured only from which packages declare it — but Studio Pro stores ConditionalVisibilitySettings on a Datagrid whose Type declares no Visibility property, and mxbuild 11.14 accepts static and conditional visibility there (0 errors)", "file": "`mdl/executor/pluggable_system_props.go` (`undeclaredSystemProps`)", "insight": "A declared system property is evidence of where a setting is shown, not of whether it exists; a refusal must be measured against what Studio Pro actually stores. Run the roundtrip harness over Studio Pro-authored pages before adding a refusal on stored shapes. Editability stays gated", "refs": []} diff --git a/.claude/skills/fix-issue/findings/mdl-grammar.jsonl b/.claude/skills/fix-issue/findings/mdl-grammar.jsonl index 77f011e6e6..813572e158 100644 --- a/.claude/skills/fix-issue/findings/mdl-grammar.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-grammar.jsonl @@ -68,5 +68,6 @@ {"area": "mdl/grammar", "date": "2026-09-30", "symptom": "`create or modify constant M.DbPassword type string default '' PRIVATE;` -> `line 22:80 no viable alternative at input 'PRIVATE'` after upgrading past v0.24.0; `fmt --upgrade` failed with the same error because it must parse first. 9 credential constants in mxcli-formula1; the database-connections skill had taught the word.", "cause": "No grammar rule ever had PRIVATE. In v0.24.0 (and nightly 8c227f46) `helpStatement: IDENTIFIER (helpTopicWord …)*` was a catch-all and `;` was optional, so `… default '' private;` parsed as TWO statements: the constant, then a help statement `private` that the visitor built nothing for (`check` said `Syntax OK (2 statements)`). R7's IsHelpWord predicate (#755, bf0d1d38; the gated b518ae72 was reverted by dd977eea) closed the catch-all, turning the silent no-op into a parse error with no registry entry.", "file": "`mdl/grammar/domains/MDLDomainModel.g4` (constantOption `{IsPrivateWord(...)}? IDENTIFIER /* @alias MDL-DEPR138 */`), `mdl/grammar/MDLParser.g4` (IsPrivateWord), `mdl/deprecation/deprecation.go` (ConstantPrivate, RemovedIn 1), `mdl/visitor/visitor_enumeration.go` (record + delete fix), `mdl/visitor/visitor_deprecations.go` (recordDeprecation refuses at RemovedIn), tests `mdl/visitor/visitor_constant_private_test.go`, `mdl/upgrade/constant_private_test.go`, example `mdl-examples/deprecated-aliases/bug-tests--865-constant-private.mdl`", "insight": "To find what an old catch-all silently accepted, do not diff grammars — the word was in neither. Dump the OLD parse tree (`ToStringTree`) for the failing line; it showed `(helpStatement private)`. Then audit the class by walking every error-free old parse of a corpus for helpStatement nodes whose word is not help/exit/quit: over the repo's skills/docs/examples at 8c227f46 and all of mxcli-formula1, the only such word in a clean parse was PRIVATE (after a constant), so a targeted alias is the whole fix, not re-opening the catch-all. Match the word by a semantic predicate on IDENTIFIER, not a new keyword, so `private` stays usable as a name. Divergence to know: the old catch-all also swallowed words AFTER private (`private exposed to client` dropped the exposure); the alias does not.", "refs": ["ako/mxcli#865", "ako/mxcli#755", "ako/mxcli#714"]} {"area": "mdl/grammar", "date": "2026-10-01", "symptom": "`fmt --upgrade --header` refused `$Ordered = sort($Rows, Position);` with `MDL-V1-LIST: the operand is not a variable (a nested call or an expression)` and left the whole file at mdl 0 (2 mxcli-formula1 files); any keyword attribute (Position, Status, Type, Date, Value, Title, Caption, Content, Index) did it, and `sort($L, Status desc)` did not parse at all.", "cause": "The call form's `sortSpec` took only `IDENTIFIER | QUOTED_IDENTIFIER`, while the statement form's `listSortItem` took `identifierOrKeyword`. With a keyword attribute the listOperationStatement alternative failed and the line fell through to setStatement as a generic function call; mdl 0 still built the sort from it (buildListOrAggregateStatement), but the upgrade fix (setCallFix -> singleListCall) found no ListOperationContext and reported a nested call.", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (sortSpec: identifierOrKeyword (ASC|DESC)?), `mdl/visitor/visitor_microflow_actions.go` (buildSortSpecList), `mdl/visitor/visitor_microflow_expression.go` (sort spec args); tests `mdl/upgrade/gated_test.go` TestUpgrade_KeywordAttributeNames, `mdl/visitor/visitor_microflow_sort_quoted_test.go` TestUnquotedKeywordSortAttribute", "insight": "A misleading upgrade reason ('not a variable') was a grammar asymmetry between a call form and its statement form: when one rule falls through to the generic expression path, the upgrader sees a function call and loses the structure. Compare the two forms' operand rules side by side. Proven with a control binary: `check` over 741 example/doc/skill scripts identical, and `fmt --upgrade --header` over 172 rehearsal scripts differs in exactly the two formula1 files. find/filter by member and sum/min/max over `$L.Keyword` were already fine.", "refs": ["ako/mxcli#889", "ako/mxcli#714"]} {"date": "2026-10-01", "area": "mdl/grammar", "symptom": "mendixlabs/mxcli#992: `@anchor(true: (to: top))` — the per-case form DESCRIBE emits, with one side — parsed, passed check and exec, and left the true edge on the default sides. `@anchor(true: (from: right, to: top))` worked. `@curve(true: …)` on a NANOFLOW passed check and exec and was dropped (on a microflow MDL060 refused it).", "cause": "annotationParam listed `annotationValue | annotationParenValue`; `(to: top)` also matches annotationValue's expression alternative, as `to : top` — `:` is Mendix's division operator — and ANTLR took the first alternative, so the nested-anchor reader found no paren value. The visitor then skipped anything it could not use. Nanoflows never ran the annotation rules (ValidateNanoflow ran only MDL044).", "file": "mdl/grammar/domains/MDLSettings.g4 (annotationParam); mdl/visitor/visitor_microflow_statements.go (parseAnchorAnnotation)", "fix": "annotationParenValue before annotationValue; parseAnchorAnnotation records every parameter it cannot use in InvalidAnchors, refused as MDL092 at check and exec; ValidateNanoflow runs checkUnknownAnnotations over the whole body.", "test": "TestAnchorAnnotation_SplitBranchWithOneSide, TestAnchorAnnotation_RecordsWhatItCannotUse, TestSplitBranch_OneSidedAnchorReachesTheEdge, TestAnchorParameterItCannotUseIsRefused", "insight": "A two-key test case hid the one-key failure: `(from: x, to: y)` cannot be an expression (comma), `(to: y)` can. Dump the parse tree (ToStringTree) for the exact failing text before reading the visitor."} +{"area": "mdl/grammar", "date": "2026-10-02", "symptom": "`grant read on M.E (FullName, Region)` fails with \"mismatched input 'Region'\"; `\"Region\"` works. Same for Status, Title, Value, Date, Label, … — names `create entity` accepts unquoted", "cause": "`entityMemberName` in MDLSecurity.g4 was `IDENTIFIER | QUOTED_IDENTIFIER`, the one name rule without `| keyword`; `Region` lexes as SCROLLREGION. An earlier fix answered the error with a \"quote it\" hint instead of accepting the name", "file": "`mdl/grammar/domains/MDLSecurity.g4` (`entityMemberName`)", "insight": "A name a statement can declare must be usable everywhere it is referenced; compare the slot with `attributeName` before adding hints. The keyword hint's tests moved to `workflowActivityName`, which still rejects bare keywords", "refs": []} {"date": "2026-10-02", "area": "mdl/grammar", "symptom": "ako/mxcli#533: `call microflow M.MF_B(P: $P)` is a parse error (`mismatched input ':' expecting '='`) while `show page M.P(P: $P)` and a page action/data source `M.F(P: $P)` parse with an MDL-DEPR007 warning \u2014 the same argument has a different set of accepted spellings per call site", "cause": "MDL-DEPR007 (`Param: e`, R4) was registered as an alias, but only showPageArg and microflowArgV3 carried the `identifierOrKeyword COLON expression` alternative; callArgument (call microflow/nanoflow/java action/javascript action/external action/web service/database query) accepted only `=`", "file": "mdl/grammar/domains/MDLMicroflow.g4 callArgument; mdl/visitor/visitor_argument_binding.go ExitCallArgument", "fix": "Add `parameterName COLON /* @alias MDL-DEPR007 */ expression` to callArgument; ExitCallArgument records MDL-DEPR007 with the ` = ` rewrite (recordColonArgument now takes any name context). Kept the registry's RemovedIn 2: under ADR-0011 an alias deprecated in the mdl 1 era warns under mdl 0 and mdl 1 and is refused only from mdl 2 \u2014 refusing it under mdl 1 would change the frozen mdl 1 for show page", "insight": "When an alias is registered, grep every rule that binds the same construct (here: every argument rule) \u2014 a registry entry names a spelling, not the rules that accept it, so the alias coverage drifts per call site", "test": "mdl/visitor/visitor_argument_binding_test.go TestArgumentBindingAliases (call * colon cases); mdl/upgrade/argument_binding_test.go TestUpgrade_ColonArgumentOnCallStatements"} {"date": "2026-10-02", "area": "mdl/grammar", "symptom": "ako/mxcli#533, #569: a positional data-source argument `listview lv (datasource: microflow M.DS($Value))` reports `no viable alternative at input 'datasource'` at the property keyword, plus two cascade errors \u2014 not at the argument, and naming nothing; the create-page skill (widgets.md) and `syntax page.datasource` taught the positional form", "cause": "A widget property is predicted with full-LL lookahead over the whole `datasource: \u2026` alternative, so a failure anywhere inside it is reported at the property's first token. No argument rule had a positional alternative", "file": "mdl/grammar/domains/MDLPage.g4 microflowArgV3; mdl/grammar/domains/MDLMicroflow.g4 callArgument, showPageArg; mdl/visitor/visitor_argument_binding.go refusePositionalArgument", "fix": "Each argument rule gets a last `| expression` alternative that the visitor always refuses, at the argument's own line:column, naming `Param = ` and the syntax topic. Last so the ambiguity with `Param = e` (`=` is equality in an expression) resolves to the named alternative. call workflow's `(callArgumentList | VARIABLE)` reordered to `(VARIABLE | callArgumentList)` so `call workflow M.W($ctx)` keeps meaning the context variable. Docs corrected to `M.GetData(Param = $Param)`", "insight": "To move an ANTLR error to where the mistake is, accept the mistake in the grammar and refuse it in the visitor \u2014 the error-alternative pattern. A malformed named argument (`Param = )`) still reports at `datasource`: the general prediction-level case is not fixed by this", "test": "mdl/visitor/visitor_argument_binding_test.go TestPositionalArgumentIsRefusedAtTheArgument"} diff --git a/.claude/skills/fix-issue/findings/mdl-other.jsonl b/.claude/skills/fix-issue/findings/mdl-other.jsonl index 4b7e63a177..ba9476db31 100644 --- a/.claude/skills/fix-issue/findings/mdl-other.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-other.jsonl @@ -79,5 +79,7 @@ {"date": "2026-10-01", "area": "mdl/scriptdiff", "symptom": "`mxcli diff` disagrees with exec: after exec has applied a script (a second exec writes nothing), diff still reports the entity modified — `Success: Boolean` against the stored `Success: Boolean default false`, `Body: String` against `String(unlimited)`, an `@Position` line, a re-laid-out flow as 'N statements added'; and it is silent where exec writes (pages, translations, a layout repointed — 'Not compared'), reports a plain `create` of an existing element as unchanged although exec stops on 'already exists' (#807), and gives 'Could not diff' for a flow that calls one the script creates first (#856). Rehearsal 3: phantom changes on 66 scripts, silent on 37 that wrote", "cause": "diff answered 'what would exec write?' a second way: it rendered each statement as MDL with its own per-statement converters and compared the text with the stored document's describe output, without running any statement. Each fix (#997 one flow renderer, #794 module name, #839 the splice verdict, the storage carry) synchronised one more spot of the second answer, and every spelling exec normalises but the converter did not (implicit defaults, positions) stayed a phantom", "file": "mdl/scriptdiff/scriptdiff.go, cmd/mxcli/cmd_diff.go, cmd/mxcli/exec_preflight.go", "fix": "Remove the second answer: diff copies the project to a scratch folder, runs the script there with exec's own code (the same pre-flight, ExecuteProgram / ContinueOnError, the script's header), and compares the copy with the project unit by unit (bytes plus the Unit-table container, so a move is seen); the changed units are rendered by DESCRIBE on both sides (a domain model per entity/association, module and project security per role/demo user). The statement-based differ (cmd_diff_mdl.go, cmd_diff_render.go, spliceVerdict) is deleted. The scratch run always uses the file engine, also under --mcp", "test": "mdl/scriptdiff/scriptdiff_test.go (PedApp: applied script diffs empty with a control that the first run adds the entity; plain create refused; earlier statements run; layout change shown; folder move reported); property_test.go -tags integration: over mdl-examples doctype scripts, diff's written-unit set equals exec's, twice; MXCLI_DIFF_LEGS runs the rehearsal legs", "insight": "A preview that predicts a side-effecting command should BE that command run somewhere harmless, not a model of it. Every normalisation exec applies (canonical defaults, builtAsStored, write elision, the splice verdict) has to be re-derived by a renderer-based differ, and the drift only shows on real projects. Copying a 56 MB project and executing costs well under a second; seven rounds of synchronising the renderer did not converge. Also: a write that changes no unit bytes (a move) lives in the .mpr Unit table, so the snapshot must include each unit's container", "refs": ["#907", "#807", "#856"]} {"date": "2026-10-01", "area": "mdl/scriptdiff", "symptom": "`mxcli diff` of a headerless (mdl 0) script that starts with `connect local ''` wrote the script's changes into the real project and reported `exec would write nothing`; a connect to another project (or one inside an `execute script`) wrote that project; `sql ` and `import from` would run against real databases. Also: with TMPDIR inside the project folder the scratch copy copied itself until 'file name too long'", "cause": "diff executes the script for real on a scratch copy, but exec follows a CONNECT (allowed under mdl 0 with a MDL-V1-SESSION warning, and inside nested scripts under any header) and SQL/IMPORT act on databases, so 'run it on a copy' only holds for statements whose effects stay in the connected project. The snapshot compared was the copy's, which saw no write, so the report said nothing changed", "file": "mdl/scriptdiff/scriptdiff.go (outsideGuard), mdl/executor/executor.go (SetStatementGuard), mdl/scriptdiff/copy.go", "fix": "Executor.SetStatementGuard: a hook every statement passes through in Execute, nested EXECUTE SCRIPT included. diff's guard redirects a connect to the -p project onto the copy, and refuses a connect elsewhere, a SQL query and an IMPORT with an error (not as exec's verdict). copyProject skips the folder the copy is created in", "test": "mdl/scriptdiff/outside_test.go: connect to the project diffs onto the copy and agrees with exec, project untouched; connect to another project and a nested one refused, other project untouched; SQL query / import refused; scratch folder inside the project", "insight": "Running the real command on a copy is only a dry run for effects that stay inside the copy. Enumerate every statement that reaches outside the connected project (connect, SQL, import, nested scripts) and guard them at the dispatch point, not per top-level statement, or a nested script walks around it", "refs": ["#907", "#913"]} {"area": "mdl/roundtrip", "date": "2026-10-02", "symptom": "Nightly, every Mendix version: `TestFlowVerdictAgreement_Corpus` fails `no probe was refused or rebuilt: the agreement was never tested on a refusal` / `0 probes refused or rebuilt`, while push-test CI passes", "cause": "The guard's refusals came only from TestApp (testdata/testapp, a submodule); PedApp has no flow with a loop. nightly.yml checked out without submodules, so the TestApp subtest skipped and the control had nothing to count", "file": "`mdl/roundtrip/flow_verdict_agreement_test.go`, `.github/workflows/nightly.yml`", "insight": "A test's control must not depend on an optional fixture that can skip: a fully scanned fixture with no loop now gets an mxcli-authored loop flow (made part of the fixture copy via `harness.addToFixture`, so a verdict's restore keeps it). And any workflow that runs the roundtrip suite needs `submodules: true` — push-test.yml had it, nightly.yml did not, so the gap showed only in the nightly. Repro: run the test in a worktree without `git submodule update`"} +{"area": "lint-rules", "date": "2026-10-02", "symptom": "SEC008 reports Engineer.Email as readable without row constraints by roles whose `show access` shows Email = None (they were granted `read (FullName)` only)", "cause": "`sec_unconstrained_pii_read.star` counted the entity-level READ permission row, which the catalog emits when ANY member is readable (`entityAccessFromMemberRights`), so it says nothing about the PII attribute", "file": "`cmd/mxcli/lint-rules/sec_unconstrained_pii_read.star` and `.claude/lint-rules/` copy", "insight": "For member-level questions use MEMBER_READ rows. Their member_name is qualified (`M.E.Attr`) for explicit member rights but bare when expanded from DefaultMemberAccessRights — match both. SEC008 also only fires after a full catalog build (permissions table)", "refs": []} +{"area": "mdl/linter", "date": "2026-10-02", "symptom": "lint MPR008 reports two merges of a nested if/else as overlapping ((820,200) and (860,200)) in a flow mxcli laid out itself; `mxcli layout flows` says it is already laid out", "cause": "MPR008 judged every node as a 120x60 activity box; the two 40x40 merges abut edge to edge (centres 40px apart), which auto-layout does on purpose", "file": "`mdl/linter/rules/mpr008_overlapping_activities.go` (`boxesOverlap`, `newActInfo`)", "insight": "Positions are RelativeMiddlePoint (centres): two boxes intersect when |dx|*2 < wa+wb and |dy|*2 < ha+hb, using each object's stored Size. With 120x60 on both sides this is exactly the old threshold, so activity overlaps are still reported", "refs": []} {"area": "mdl/mendixexpr", "date": "2026-10-02", "symptom": "A flow expression slot laid out over several lines — the last value of a member list before `)`, a declare's value before `;` — is stored with the layout whitespace after it (`false\\n `). Invisible: describe, mx check and the flow diff (#886, Canonical) all ignore it; only the raw unit shows it.", "cause": "The visitor keeps an expression slot's trailing whitespace (appendSourceExpressionSuffix, for describe's layout), and mendixexpr.String returned a SourceExpr's Source verbatim, so the builder stored it.", "file": "`mdl/mendixexpr/mendixexpr.go` (String, SourceExpr case)", "insight": "Trim at the write boundary (String), not in the visitor: the AST keeps its layout for formatting, the model gets the expression. Idempotence was checked with a control on PedApp: a stored expression that still carries the whitespace (an earlier mxcli's write) is not rewritten by a re-run, because the flow diff compares by Canonical; changing the value is. Inspect the raw unit (InitialValue / Value), not describe — describe hid it.", "refs": ["ako/mxcli#898"]} {"area": "mdl/mendixexpr", "date": "2026-10-02", "symptom": "`find($L, Mod = 1)` and `find($L, mod = 1)` compare equal in the flow diff, and the stored text of `Mod = 1` is lowered to `mod = 1` — a bare member name spelled like a keyword (Mod, Div, Not, And, Or, Then) is case-folded.", "cause": "mendixexpr.Canonical and NormalizeOperatorCase decided keyword-ness by spelling plus the byte before the word (`.`, `/`, `$`), not by position, so a bare member name at an operand position was taken for the operator.", "file": "`mdl/mendixexpr/canonical.go` (keywordUse), `mdl/mendixexpr/mendixexpr.go` (NormalizeOperatorCase)", "insight": "Position decides: a binary word operator (and/or/div/mod, then/else) only follows an operand (a name, a literal, `)`, `]`); not/if only precede one. The two functions must share the rule — fixing Canonical alone would make a script that writes `Mod` differ from the stored `mod` on every run (churn).", "refs": ["ako/mxcli#898", "ako/mxcli#886"]} diff --git a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl index cf8d6f572a..b79c8cc47a 100644 --- a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl @@ -53,3 +53,4 @@ {"date": "2026-10-01", "area": "mdl/visitor", "symptom": "ako/mxcli#877: mxcli-rest wrote `drop microflow if exists X;` between each flow's `/** … */` doc comment and its `create`; six flows lost their documentation. `mx check` passed, `exec` was clean, describe round-tripped, `mxcli check` said nothing; only lint QUAL002 (19 -> 22) noticed.", "cause": "The grammar's `statement: docComment? (…)` lets every statement start with a doc comment, but only the create exits read one (findDocComment); every other statement drops it silently. Nothing recorded that a comment was parsed and not stored.", "file": "mdl/visitor/visitor_doc_comment_placement.go, mdl/visitor/visitor_r2_children.go, mdl/visitor/visitor_strict_terminators.go, mdl/ast/ast.go, mdl/executor/validate_detached_doc_comments.go, mdl/executor/validate_program.go", "fix": "Enter/ExitStatement record, per top-level statement, a doc comment on a statement that does not store one (not a createStatement, or a create whose AST has no Documentation/OuterDocumentation/DocumentationSet field: create module, module/user role, demo user, …) as ast.Program.DetachedDocComments, with the next storing statement's head and line filled in when it is reached. ValidateProgram warns MDL089 naming that statement (check and exec, every version). The write-microflows pitfalls reference and check-syntax skill show drop-then-create with the comment between the drop and the create.", "test": "mdl/visitor TestDetachedDocComments (drop between comment and create, grant/revoke, create module; controls: comment above its create, entity and constant); mdl/executor TestValidateProgram_DetachedDocComment (message names the create and its line; control: kept comment not reported). Revert check: without the ExitStatement hook, 3 visitor subtests fail and the executor test reports 0 MDL089 warnings.", "insight": "A grammar that accepts a construct everywhere but a handful of exits read it is a silent-drop generator; record what was parsed and not consumed at the one rule that admits it, rather than auditing every reader. Surveying mdl-examples found the same loss in the repo's own scripts (section-header doc comments above grant/revoke/create module role)."} {"date": "2026-10-01", "area": "mdl/visitor", "symptom": "`show features for version 10.24` (and `list features for version 10.24`) prints the session `show version` output (Mendix Version: 11.x ...) instead of the feature table; `show features` and `show features added since 10.24` work", "cause": "ExitShowStatement is one long else-if chain over token presence; the bare `ctx.VERSION() != nil` branch for `show version` sat ~380 lines above the FEATURES branch, and `for version` also carries the VERSION token, so the earlier, broader test won", "fix": "Guard the show-version branch with `ctx.FEATURES() == nil`", "insight": "In a token-presence else-if chain, a branch keyed on ONE token claims every alternative containing it; order the chain most-specific first or make each branch exclude the head keyword of the others. TestEveryShowAlternativeProducesAStatement cannot see this: it only checks that SOME statement is produced, not the right one", "issue": "ako/mxcli#910", "file": "mdl/visitor/visitor_query.go (ExitShowStatement)", "test": "mdl/visitor/show_features_for_version_test.go"} {"date": "2026-10-01", "area": "mdl/visitor", "symptom": "(a) `grant … where '[\"Status\" = ''Accepted'']'` (and the mdl 1 bracketed form) stores `\"Status\"` verbatim: a string literal to Mendix, the rule is always false and grants no rows; check, exec, lint and mx check all pass, found only by an as-user runtime test. (b) `retrieve … where [Name = \"ServiceManager\"]` passes check and stores `Name = ServiceManager`, a bare member token instead of the string", "cause": "(a) The retrieve/datasource XPath sinks route their source through stripExpressionIdentifierQuotes (2026-07-08 finding); the grant (both spellings) and workflow targeting (both spellings, plus alter workflow) did not, and only a constraint the multi-line canonicaliser rewrote lost its quotes by accident. (b) stripExpressionIdentifierQuotes strips EVERY double-quoted token outside single quotes, so a value written with the wrong quotes was turned into a name", "fix": "bracketedXPathText and the quoted grant/targeting branches strip identifier quotes; a new listener check (ExitXpathConstraint for every bracketed XPath, plus the string-literal forms of retrieve, grant and targeting) refuses a double-quoted token that is the right-hand operand of a comparison, naming the single-quoted spelling", "insight": "Quote-stripping is right for names and wrong for values, and position tells them apart: Mendix XPath never compares two members, so a quoted token after =, !=, <, <=, >, >= is always a mis-quoted string. Refusing it fixes a silent wrong write, so it applies under mdl 0 and mdl 1 (ADR-0011). When a normalisation lives in N sinks, grep its callers (bracketedXPathText, unquoteStringLit on an XPath) — the reported statement was 1 of 6", "issue": "mendixlabs/mxcli#1243; ako/mxcli#566", "file": "mdl/visitor/visitor_xpath_quotes.go; mdl/visitor/visitor_xpath_brackets.go; mdl/visitor/visitor_security.go; mdl/visitor/visitor_workflow.go; mdl/visitor/visitor_alter_workflow.go; mdl/visitor/visitor_retrieve_range.go", "test": "mdl/visitor/xpath_double_quotes_test.go"} +{"date": "2026-10-02", "area": "mdl/visitor", "symptom": "`create persistent entity M.E (Pwd: HashedString)` stores DomainModels$StringAttributeType with Length 0 (unlimited String): no error, no warning, check and exec both clean. describe prints `Pwd: String(unlimited)`; a Studio Pro-authored HashedString attribute described as `Pwd: Unknown`, which re-parses as an enumeration reference. In a parameter, variable, constant or Java action the word was stored as String (a microflow parameter as Void)", "cause": "HASHEDSTRING_TYPE is in the dataType rule, but buildDataType (visitor_helpers.go) had no branch for it and fell through to its final `return TypeString`; ast had no HashedString kind, convertDataType no case, getAttributeTypeName no case. Everything below the executor (sdk type, codec, backend read/write, MCP backend) already handled HashedStringAttributeType", "file": "`mdl/visitor/visitor_helpers.go` (buildDataType), `mdl/visitor/visitor_silent_drops.go` (EnterDataType/EnterNonListDataType refusal outside attribute slots), `mdl/ast/ast_datatype.go` (TypeHashedString), `mdl/executor/helpers.go` (convertDataType, getAttributeTypeName), `mdl/executor/cmd_diff_local.go`, `mdl/executor/oql_type_inference.go`", "fix": "Add ast.TypeHashedString (appended, no kind renumbered); map it in buildDataType, convertDataType, getAttributeTypeName, the OQL inference reverse map and the exprcheck adapter (KindString). Refuse HashedString where the dataType's parent is not AttributeDefinition / AlterEntityAction, like Float/Currency (#706): Mendix has it only as a DomainModels attribute type. diff-local tests HashedStringAttributeType BEFORE StringAttributeType, because it matches by substring and one contains the other", "insight": "A fall-through default that is itself a legal value (String) makes a missing branch silent all the way to disk; the guard is a table test over every attribute type keyword asserting each builds its own kind, which found exactly this one. GUID preservation on a String<->HashedString type change was already correct (UpdateEntity carries the attribute GUID; the type is a child of the attribute) - tested on the expr-checker fixture with GUID != $ID asserted. `Date` is NOT the same defect: Mendix 11 has no date-only attribute type (no DomainModels$DateAttributeType in the metamodel; date-only is DateTime with LocalizeDate=false, which MDL cannot author, only carry), and MDL already treats `Date` as a deprecated DateTime alias (MDL-DEPR160)"} diff --git a/.claude/skills/fix-issue/findings/sdk.jsonl b/.claude/skills/fix-issue/findings/sdk.jsonl index dd5df4cca5..a3b1694d64 100644 --- a/.claude/skills/fix-issue/findings/sdk.jsonl +++ b/.claude/skills/fix-issue/findings/sdk.jsonl @@ -43,3 +43,4 @@ {"area": "sdk/mpr", "date": "2026-09-12", "symptom": "The legacy writer's image widgets disagree with Studio Pro. `serializeStaticImage` omits AlternativeText entirely; `serializeDynamicImage` writes one containing a `FallbackValue` string; both write BSON null for the unset Image / DefaultImage. mxbuild accepts all of it at 0 errors", "cause": "`Forms$ClientTemplate` has exactly three properties — Fallback (Texts$Text), Parameters, Template (generated/metamodel, and all three Studio Pro references). The dynamic image hand-rolled its own holder instead of calling `serializeClientTemplate`, and invented FallbackValue. AlternativeText is declared without omitempty on both image types and appears in 3/3 references, so omitting it is a drop, not an optional key", "file": "`sdk/mpr/writer_widgets_display.go` (serializeStaticImage, serializeDynamicImage, emptyAlternativeText)", "insight": "**A hand-rolled copy of a shared serializer is where the invented key lives.** The correct helper was four lines away and carried a comment naming this exact mistake; the copy still got it wrong, because nothing compares the two. Grep for a type's $Type string and check whether every construction site goes through one builder. **mxbuild is not a check for this class at all** — it tolerates unknown properties, while Studio Pro resolves every stored property against the type's property list and throws \"Sequence contains no matching element\" at MprProperty.cs. The available substitutes are generated/metamodel (the arbiter) and a real Studio Pro document from a marketplace module in the fixture", "refs": []} {"area": "sdk/mpr", "date": "2026-09-14", "symptom": "Legacy engine: a user task's on-created microflow (set in Studio Pro) reads back as empty — `describe workflow` omits it and the semantic `UserTask.OnCreated` is \"\" — while the modelsdk engine reads it", "cause": "`parseUserTask` did `raw[\"OnCreatedEvent\"].(string)`, but the stored value is a PART document: `{ $Type: Workflows$MicroflowBasedEvent, Microflow: \"Mod.MF\" }` or `{ $Type: Workflows$NoEvent }`. The type assertion never matched, silently", "file": "`sdk/mpr/parser_workflow.go` (`parseUserTask`)", "insight": "A `.(string)` assertion on a key whose metamodel type is a part/by-name-in-a-part fails silently and yields the zero value — indistinguishable from 'not set'. When a field reads empty on one engine only, check the stored shape with a Studio Pro reference document (ako/TestApp) before assuming the model lacks it. The test round-trips the writer's own document through `bson.Marshal`/`Unmarshal` so the parser sees real decoded types; control: restoring the string assertion fails it"} {"area": "sdk/versions", "date": "2026-09-17", "symptom": "On a Mendix 10.24.25 project, `CREATE PAGE Mod.P (Params: { $X: Mod.E })` is refused with \"create page with parameters requires Mendix 11.0+ (project is 10.24.25)\", so no parameterised page can be authored from MDL on any 10.x project at all — and `SHOW PAGE Mod.P ($X = $obj)` has nothing to bind to. Teams fall back to a microflow data source that re-derives the object from $currentUser. mendixlabs/mxcli#1121", "cause": "The version registry's floor was never measured. `pages.page_parameters: 11.0.0`, `microflows.show_page_with_params: 11.0.0` and `pages.page_variables: 11.0.0` match the illustrative `show features` sample table in docs/11-proposals/PROPOSAL_version_aware_agent_support.md and nothing else. Measured against the Mendix Model SDK's own StructureVersionInfo records (mendixmodelsdk 4.115.0, src/gen/pages.js): Pages$PageParameter and Page.parameters are 9.4.0, PageSettings.parameterMappings 9.7.0, Pages$LocalVariable 10.17.0 with its DefaultValue 10.20.0. What IS 11.5.0 is PageParameter.IsRequired and PageParameter.DefaultValue — which pageParameterToGen emitted unconditionally, on every version.", "file": "`sdk/versions/mendix-9.yaml`, `sdk/versions/mendix-10.yaml` (floors); `mdl/backend/modelsdk/page_write.go` (pageParameterToGen, now takes *types.ProjectVersion); `mdl/executor/cmd_pages_create_v3.go` (gate kept, it is still right for 9.0-9.3). Tests `sdk/versions/page_parameter_floor_test.go`, `mdl/backend/modelsdk/page_parameter_version_test.go`, example `mdl-examples/bug-tests/1121-page-parameters-on-mendix-10.mdl`", "insight": "**A version floor in the registry is a measurement, and the mendixmodelsdk npm package is where to take it** — every class and property carries a StructureVersionInfo with `introduced`/`deleted`, so `npm pack mendixmodelsdk` and grep beats reasoning about release notes. Nothing in the repo distinguished a measured floor from a made-up one, which is how a number from a proposal's *sample output* became the thing that refused users' scripts; the floors now carry a `notes:` naming the source. **A wrong gate and a wrong writer hid each other**: the 11.0 gate was masking that the writer emits two 11.5-only keys, so lifting the gate alone would have traded an honest refusal for a page Studio Pro cannot open (mxbuild accepts unknown properties; Studio Pro throws InvalidOperationException at MprProperty.cs) — check what the gate was compensating for before removing one. The same wrongness also means the bug was **live on 11.0-11.4**, which passed the gate and got both keys written; that row is the regression control in the writer test. **Split the element from its properties**: the thing being gated was a 9.4 element with an 11.5 tail, and one floor cannot express that — the registry gates the element, the writer gates the tail.", "refs": ["#1121"]} +{"area": "sdk/javaactions", "date": "2026-10-02", "symptom": "A `create java action` whose body uses a parameter compiles one way and breaks the other: under `--watch` hot reload (which compiles mxcli's .java) an entity parameter is an `IMendixObject`, an `integer` a `java.lang.Integer`, an enum / `entity ` / `pEntity` a `java.lang.Object`; after a full build mxbuild regenerates the file and the same fields are the proxy class (`myfirstmodule.proxies.Doc`), `java.lang.Long`, the proxy enum, `String`, `IMendixObject` — so `Dc.getValue(ctx, ...)` or `return 1;` stops compiling (reported on 11.13). Every build after an mxcli edit also rewrote the file (LF -> CRLF, imports)", "cause": "`GenerateSource` hand-wrote a type map from the Model SDK view of the types instead of from mxbuild's output: no proxy fields (nor the deprecated `__Name` raw field and the Optional/stream initialize chain for lists), Integer instead of Long, Object for enum / type-parameter / microflow / string-template parameters, an `integer`/enum return as Integer/Object (mxbuild: Long/String), fully-qualified IMendixObject without the import mxbuild adds, a multi-line constructor even with no parameters, LF endings. Retention moved IContext/UserAction to the top where mxbuild keeps the import list exactly as it stands", "file": "`sdk/javaactions/source.go` (`GenerateSource`, `javaParamType`, `javaReturnType`, `completeImports`), `sdk/javaactions/retain.go` (`RetainSections`), goldens `sdk/javaactions/testdata/mxbuild/` (+ `.gitattributes -text` so git keeps their CRLF)", "insight": "Measure the generator, don't model it: create actions with mxcli, `mxcli docker build`, keep what mxbuild leaves as goldens, and assert regenerating over each golden returns it byte for byte. Hand-edit the files *before* a build to measure mxbuild's rules — it keeps the import list in order (an unused import stays), appends the needed ones it lacks sorted, rewrites an LF file that is otherwise identical to CRLF, and leaves an identical file's mtime alone. 10.24.27 and 11.14.0 wrote identical bytes, so no version branch. Control: the pre-fix binary's files differ from mxbuild's on every action with an object, list, enum, integer or type-parameter parameter"} diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index 229c969c17..8dc587bc10 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -424,6 +424,30 @@ are in. which is the usual shape — it matches the segment's leading name, not the whole segment. +### A used flow left without access — `MDL-SEC21` (CE0106) + +With a project (`check -p`), `check` simulates the script's creates, drops, +grants and revokes and reports a microflow or nanoflow the script leaves with +**no allowed role** while something that needs one names it. MxBuild's error: + +> CE0106 "At least one allowed role must be selected if the microflow is used +> from navigation, a page, a nanoflow or a published service." + +Measured on Mendix 11.14: a page button or data source, a snippet, a navigation +or menu-document item, or a nanoflow call needs a role (even from an unused +snippet, menu document or nanoflow). A **published REST operation does not**, +nor a microflow called only from another microflow, nor an excluded page. It is +an **error at security level Prototype or Production** and a warning at Off, +where MxBuild does not check it. + +The usual cause is **drop + create in separate runs**: a create in a later run +is a *new* flow, and a new flow in a module that has its own module roles gets no +access. Within one run, and with `create or modify`, the stored roles are kept. +Fix: `grant execute on microflow M.Flow to M.Role;` in the same script, or rebuild +with `create or modify microflow` instead of dropping. Only what the script +*changes* is reported — a project that already has CE0106 does not fail an +unrelated script; `mxcli docker check` shows those. + **`check` is still necessary, not sufficient.** Run `mx check` (or `mxcli docker check`) after every `exec`; these two rules narrow the gap, they do not close it. diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 55de2c087b..364e807022 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -474,6 +474,15 @@ bare-association spelling in each. Note what it is NOT: this shows a value from the associated object, it does not make it editable through the association — for editing the other object, nest a dataview over the association instead. +**Match the input widget to the attribute type** — mxbuild refuses every other +pairing with CE2421, and `check -p --references` reports it as MDL-WIDGET39: +textbox → String / Integer / Long / Decimal / AutoNumber; textarea → String; +datepicker → DateTime; checkbox → Boolean; radiobuttons → Boolean or +Enumeration. An **enumeration** goes in `radiobuttons` or `combobox`, never a +textbox. Do not write the classic `dropdown` on a React-client project +(`show settings` → `OptimizedClient: Yes`, as a fresh 11.14 app has): it is CE0582 +(MDL-WIDGET40); use `combobox`. + `Association:` names a reference on the containing entity, so `Association: Issue_Assignee` resolves against the dataview's entity, not the option list's module. diff --git a/.claude/skills/mendix/generate-domain-model/reference/syntax.md b/.claude/skills/mendix/generate-domain-model/reference/syntax.md index 4aacc5d207..c2bb362726 100644 --- a/.claude/skills/mendix/generate-domain-model/reference/syntax.md +++ b/.claude/skills/mendix/generate-domain-model/reference/syntax.md @@ -519,7 +519,7 @@ type reference; Prefer `if not exists` when the statement is a *delta* rather than the element's complete definition. `or modify` rebuilds the element from the statement, so a partial `create or modify entity` drops every attribute it does not list. Writing -both is refused as **MDL067**. +both is refused as **MDL085**. **Association Types**: - `reference` - One-to-one or many-to-one (foreign key on FROM entity) diff --git a/.claude/skills/mendix/java-actions/SKILL.md b/.claude/skills/mendix/java-actions/SKILL.md index 1489c35df8..3912177386 100644 --- a/.claude/skills/mendix/java-actions/SKILL.md +++ b/.claude/skills/mendix/java-actions/SKILL.md @@ -221,6 +221,13 @@ from describe output has changed an action's export level to Public. Check it ag | `pEntity` (type param ref) | Type parameter reference (entity instance) | | `list of pEntity` | List of type-parameter instances | +**In the `$$` body, a parameter is the generated field — not the raw value.** An entity +parameter is its proxy class (`module.proxies.Entity`; `.getMendixObject()` for the +`IMendixObject`), a list a `java.util.List` of proxies, `integer` a `java.lang.Long`, an +enumeration its proxy enum (but an enumeration *return* is a `String`). The full table is +in [writing-java.md](reference/writing-java.md#step-2-define-parameters); it is what mxbuild +generates, so the body compiles both under `--watch` and after a full build. + ### Examples #### Simple Action (No Parameters) diff --git a/.claude/skills/mendix/java-actions/reference/writing-java.md b/.claude/skills/mendix/java-actions/reference/writing-java.md index 8936705c93..2a2104721a 100644 --- a/.claude/skills/mendix/java-actions/reference/writing-java.md +++ b/.claude/skills/mendix/java-actions/reference/writing-java.md @@ -13,17 +13,30 @@ In Studio Pro: ### Step 2: Define Parameters -| Parameter Type | Mendix Type | Java Type | -|----------------|-------------|-----------| -| String | String | `java.lang.String` | -| Integer | Integer/Long | `java.lang.Long` | -| Decimal | Decimal | `java.math.BigDecimal` | -| Boolean | Boolean | `java.lang.Boolean` | -| DateTime | Date and time | `java.util.Date` | -| Object | Entity | `IMendixObject` | -| List | List of Entity | `java.util.List` | -| StringTemplate(Sql) | SQL template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | -| StringTemplate(Text) | Text template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | +The Java type is what the **field** the user code reads is declared as. It is +what mxbuild generates (measured on 10.24 and 11.14 — they agree), and what +`create java action` writes, so code written against an mxcli-generated stub +still compiles after a full build regenerates it. + +| MDL Type | Field Java type | Notes | +|----------|-----------------|-------| +| `string` | `java.lang.String` | | +| `integer` | `java.lang.Long` | Not `Integer` — Mendix's Integer is 64-bit. Same for an `integer` return. | +| `long` | `java.lang.Long` | | +| `decimal` | `java.math.BigDecimal` | | +| `boolean` | `java.lang.Boolean` | | +| `datetime` | `java.util.Date` | | +| `Module.Entity` | `module.proxies.Entity` | The proxy class (package = lower-cased module). The raw `IMendixObject` is in a deprecated `__Name` field — use `Name.getMendixObject()` instead. | +| `list of Module.Entity` | `java.util.List` | Raw list in a deprecated `__Name` field. | +| `enum Module.Enum` | `module.proxies.Enum` | An enumeration **return** is `java.lang.String` — return `value.name()`. | +| `entity ` | `java.lang.String` | The entity's qualified name. | +| `pEntity` | `IMendixObject` | No proxy: the type is only known at runtime. | +| `list of pEntity` | `java.util.List` | | +| `stringtemplate(sql)` / `stringtemplate(text)` | `java.lang.String` | | +| `Microflow` | `java.lang.String` | The microflow's qualified name. | + +Return types: an entity, `pEntity` or `System.FileDocument` return is +`IMendixObject`, a list return `java.util.List` — no proxies. **Note:** `stringtemplate(sql)` and `stringtemplate(text)` are specialized types for parameterized SQL/OQL queries and text templates respectively. @@ -36,41 +49,54 @@ In Studio Pro: ### Basic Structure +What mxbuild generates for `JA_CalculateTax(Amount: decimal, TaxRate: decimal, Order: Sales.Order) returns decimal` +(the files are CRLF): + ```java -package mymodule.actions; +package sales.actions; import com.mendix.systemwideinterfaces.core.IContext; -import com.mendix.webui.CustomJavaAction; -import com.mendix.core.Core; import com.mendix.systemwideinterfaces.core.IMendixObject; +import com.mendix.systemwideinterfaces.core.UserAction; -public class JA_CalculateTax extends CustomJavaAction +public class JA_CalculateTax extends UserAction { - private java.math.BigDecimal amount; - private java.math.BigDecimal taxRate; - - public JA_CalculateTax(IContext context, java.math.BigDecimal amount, java.math.BigDecimal taxRate) - { - super(context); - this.amount = amount; - this.taxRate = taxRate; - } - - @java.lang.Override - public java.math.BigDecimal executeAction() throws Exception - { - // begin user CODE - if (this.amount == null || this.taxRate == null) { - return java.math.BigDecimal.ZERO; - } - - return this.amount.multiply(this.taxRate); - // end user CODE - } + private final java.math.BigDecimal Amount; + private final java.math.BigDecimal TaxRate; + /** @deprecated use Order.getMendixObject() instead. */ + @java.lang.Deprecated(forRemoval = true) + private final IMendixObject __Order; + private final sales.proxies.Order Order; + + public JA_CalculateTax( + IContext context, + java.math.BigDecimal _amount, + java.math.BigDecimal _taxRate, + IMendixObject _order + ) + { + super(context); + this.Amount = _amount; + this.TaxRate = _taxRate; + this.__Order = _order; + this.Order = _order == null ? null : sales.proxies.Order.initialize(getContext(), _order); + } + + @java.lang.Override + public java.math.BigDecimal executeAction() throws Exception + { + // BEGIN USER CODE + if (Amount == null || TaxRate == null) { + return java.math.BigDecimal.ZERO; + } + return Amount.multiply(TaxRate); + // END USER CODE + } + ... } ``` -**CRITICAL**: Only code between `// begin user CODE` and `// end user CODE` is preserved. Everything else is regenerated by Studio Pro. +**CRITICAL**: Only the import list, the code between `// BEGIN USER CODE` and `// END USER CODE`, and the code between `// BEGIN EXTRA CODE` and `// END EXTRA CODE` are preserved. Everything else is regenerated by Studio Pro / mxbuild. ### Working with Mendix Objects diff --git a/.claude/skills/mendix/manage-security/SKILL.md b/.claude/skills/mendix/manage-security/SKILL.md index 902d99ea60..64a209da39 100644 --- a/.claude/skills/mendix/manage-security/SKILL.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -222,6 +222,31 @@ grant view on page MyModule.Customer_Overview to MyModule.User, MyModule.Admin; revoke view on page MyModule.Customer_Overview from MyModule.User; ``` +### What a New Document Starts With — and Why GRANT Does Not Narrow It + +Document grants (page, microflow, nanoflow) are **additive**, like entity +grants: GRANT adds roles, REVOKE removes them, nothing replaces the list. What a +newly **created** document starts with depends on its module: + +| Module has… | New page / microflow / nanoflow gets | +|---|---| +| no module roles | an auto-created `.User` role (created on first use), granted to every new document while it is the module's only role. `exec` prints `access: granted to auto-created role .User (…)` under the create | +| module roles of its own | **no** allowed roles — grant them in the same script | + +Consequences: + +- A stub page created early (module still role-less) is open to `.User`. + A later `grant view on page M.Stub to M.Admin;` **adds** Admin; every user role + mapped to `M.User` can still open it. Narrow it explicitly: + `revoke view on page M.Stub from M.User;` +- **Drop + create in separate runs loses access.** `create or modify`, and drop + + create of the same name within one run, keep the stored roles. A `create` in + a later run than the `drop` is a new document; in a module with its own roles + it has none, and if a page, snippet, nanoflow or navigation item uses the flow + MxBuild fails with **CE0106** at security level Prototype/Production. + `mxcli check -p` reports it as **MDL-SEC21** before exec. Prefer + `create or modify` to rebuild a flow; otherwise grant in the same script. + ### Always Qualify a Module Role A module role is always `Module.Role`. The grammar makes the module part @@ -567,6 +592,8 @@ rule's default cover it, or change the default. 3. **Forgetting qualified names** — roles use `Module.Role` format in GRANT/REVOKE 4. **User roles without System module roles** — in Production security, user roles need at least one System module role (CE0156) 5. **Entity access without proper member rights** — use `read *` for all members or `read (Attr1, Attr2)` for specific ones +6. **Expecting a GRANT to replace a default** — a new document in a role-less module is granted to the auto-created `.User`; a later grant adds to it. `revoke … from .User` to narrow +7. **Rebuilding a flow with drop + create across runs** — the later create starts with no access in a module that has roles (CE0106 when the flow is used from a page/nanoflow/navigation). Use `create or modify`, or grant in the same script ## Validation diff --git a/.claude/skills/mendix/migrate-k2-nintex/SKILL.md b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md index ca444ba1b6..8c9f2e56a5 100644 --- a/.claude/skills/mendix/migrate-k2-nintex/SKILL.md +++ b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md @@ -383,7 +383,7 @@ create module role CRM.User description 'Can create and edit own records'; -- Access rules grant create, delete, read *, write * on entity CRM.Order to CRM.Manager; -grant create, read *, write * on entity CRM.Order to CRM.User where [owner = '[%CurrentUser%]']; +grant create, read *, write * on entity CRM.Order to CRM.User where [System.owner = '[%CurrentUser%]']; ``` ## Common Challenges and Solutions diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 6b8d498a0e..3a5e49e872 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -45,7 +45,7 @@ Choose the mode by who owns the microflow ([choose-edit-mode](../choose-edit-mod clause, `return` value, `if` condition, header clause, parameter (added/retyped; removed only if unused) or stated `@position`/`@start` change is patched in place (a move keeps the node's flows). A redrawn `@anchor`/`@curve`, loop body, error handler or other `return` added/taken away rebuilds under mdl 0 (`MDL-V1-REBUILD`: IDs - renumbered, merges and curves lost) and is refused under `mdl 1;`. + renumbered, merges and curves lost) and is refused under `mdl 1;`. **To change a loop body, `alter … replace` the whole loop** — neither mode edits inside one ([pitfalls](reference/pitfalls.md#11-changing-something-inside-a-loop-body)). ## When to Use a Microflow vs a Nanoflow diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index b3830b2002..a418e0a368 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -265,6 +265,44 @@ end if; The name in that position must resolve to a real **rule** — a microflow there is the same CE0117, and mxcli refuses the statement rather than writing it. + +### 11. Changing Something Inside a Loop Body + +**Refusal**: `create or modify` under `mdl 1;` — "the Loop at (x, y) changes inside +its body; the splice does not edit inside a loop"; `alter` aimed at an activity in +the loop — "… is inside the body of loop $Car in $Cars; alter does not splice inside +a loop body". Both refusals spell out the statement below. + +Neither editing mode splices **inside** a loop, so do not try one after the other. +Replace the **whole loop**, addressed by its handle from `describe microflow X with +handles`, with the body as it should be: + +❌ **REFUSED** (either way): +```mdl +mdl 1; +alter microflow MyModule.ACT_SaveAll { + replace commit $Car with begin commit $Car without events; end; +}; +``` + +✅ **CORRECT** — replace the loop: +```mdl +mdl 1; +alter microflow MyModule.ACT_SaveAll { + replace loop $Car in $Cars with begin + loop $Car in $Cars + begin + commit $Car without events; + end loop; + end; +}; +``` + +Everything outside the loop keeps its `$ID`s, positions and flows; the loop and its +body are rebuilt (new IDs, drawn by mxcli). A `while` loop is the same: +`replace while $N < 3 with begin while $N < 3 begin … end while; end;`. For a loop +nested in another loop, replace the outer one — alter addresses nothing inside a loop. + ## Implicit Variable Creation (CE0111 Duplicate Variable) These statements **implicitly create a new variable** with the name on the left side: @@ -313,7 +351,7 @@ conditional and assign in every branch: ```mdl -- WRONG: $GTotalText is created only in the `then` arm → not declared in `else` if $HasVariance then - $GTotalText = call microflow Module.FMT_Variance($v); -- created here only + $GTotalText = call microflow Module.FMT_Variance(Value = $v); -- created here only else set $GTotalText = 'n/a'; -- error: not declared end if; @@ -321,7 +359,7 @@ end if; -- CORRECT: declare before, then set in each branch (call into a temp, then set) declare $GTotalText string = ''; if $HasVariance then - $Tmp = call microflow Module.FMT_Variance($v); + $Tmp = call microflow Module.FMT_Variance(Value = $v); set $GTotalText = $Tmp; else set $GTotalText = 'n/a'; diff --git a/.claude/skills/mendix/write-nanoflows/SKILL.md b/.claude/skills/mendix/write-nanoflows/SKILL.md index 4f8ceb08ce..283c7bdfd3 100644 --- a/.claude/skills/mendix/write-nanoflows/SKILL.md +++ b/.claude/skills/mendix/write-nanoflows/SKILL.md @@ -38,6 +38,12 @@ Choose the mode by who owns the nanoflow ([choose-edit-mode](../choose-edit-mode rebuilds the whole nanoflow under mdl 0 (warning `MDL-V1-REBUILD`: element IDs renumbered, merges removed, curves reset) and is refused under `mdl 1;`. +- **Changing something inside a loop body** (either owner): neither `create or modify` + (under `mdl 1;`) nor an `alter` aimed at an activity in the loop can make it — `alter` + does not splice inside a loop. Replace the **whole loop**, addressed by its handle, with + the body as it should be (`replace loop $Item in $Items with begin loop $Item in $Items + begin … end loop; end;`). Everything outside the loop keeps its `$ID`s, positions and + curves; the loop and its body are rebuilt. For a nested loop, replace the outer one. ## When to Use a Nanoflow vs a Microflow diff --git a/.gitattributes b/.gitattributes index 6b3a3fd091..a7b1246067 100644 --- a/.gitattributes +++ b/.gitattributes @@ -52,6 +52,10 @@ Dockerfile text eol=lf *.cdx.json binary bun.lock binary +# mxbuild's generated Java, kept byte for byte: its CRLF line endings are part +# of what the generator is tested against. +sdk/javaactions/testdata/mxbuild/* -text + # Append-only knowledge files use git's union merge driver. # # Every bug fix appends one finding, so two concurrent fixes write to the same diff --git a/CHANGELOG.md b/CHANGELOG.md index 94fd1a1b95..28c6a34cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,9 +49,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`DynamicClasses` and a column's `DynamicCellClass` are written as Mendix expressions** (mendixlabs/mxcli#750) — the expression is written as-is, so the doubled-quote spelling is gone: `dynamicclasses: if $currentObject/Featured then 'is-featured' else ''`, and `dynamicclasses: 'is-featured'` is the string — the class `is-featured`. The same rule as the OData client's credentials. `create page`, `alter page … set` and `describe` all use it, and a describe → exec round trip stores identical values (measured on a Mendix 11.14.0 project). **Migrating a script:** the old spelling, the expression's text in quotes (`'if … then ''a'' else '''''`), still parses but would now store that text as a class name, so `check` and `exec` refuse it as **MDL-WIDGET33** and give the unquoted expression. An expression in any other widget property is an error rather than an empty value; a pluggable property whose schema kind is Expression (a column's `Visible`, for one) keeps the quoted form until a following change. - **An OData client's credentials and header values are written as Mendix expressions** (mendixlabs/mxcli#750) — `HttpUsername`, `HttpPassword`, `ClientCertificate` and every `headers (…)` value hold an expression, and MDL now writes it as-is: `HttpUsername: 'admin'` is the string `'admin'`, `@Module.Const` reads a constant, and `'Bearer ' + @Module.Token` concatenates. Before, a quoted value was the expression's *text*, so `'admin'` stored the identifier `admin` and a string needed `'''admin'''`. `describe` prints the stored expression as-is, so Studio Pro's `'abc'` now reads `HttpUsername: 'abc'`; measured against a Studio Pro-authored client, and a describe → exec round trip stores identical values. **Migrating a script:** `'''admin'''` becomes `'admin'`, and a quoted constant `'@Module.Const'` becomes `@Module.Const` — both old forms still parse but would now store something else, so `check` and `exec` refuse them as **MDL-ODATA07**. A compound expression in any other OData property (`Path: 'a' + 'b'`) is an error rather than an empty value. `ServiceUrl` is a constant reference, not an expression — see the next entry. - **An OData client's `ServiceUrl` names a constant, like `ProxyHost`** (mendixlabs/mxcli#750) — Studio Pro picks the service URL as a constant and stores it as `@Module.Name`. `ServiceUrl: Module.Location` is now accepted alongside `@Module.Location` and `'@Module.Location'` (the bare name used to be refused as "not a constant reference"); all three store the same value, and `describe` prints the bare name, as it does for the proxy references. A literal URL is still refused (CE6825). +- **`create or modify … if not exists` is reported as `MDL085`** — the error had the id `MDL067`, which also names the unrelated bare-commit note (a bare `commit $X;` now runs events). One id for two diagnostics made it useless for looking either one up. The commit note keeps `MDL067`; a CI filter or suppression keyed on `MDL067` for the guard error needs `MDL085`. +- **`exec -p` no longer repeats the bare-commit note (`MDL067`) on a re-run** — for a `create or modify microflow` whose stored flow already commits each variable the way the script writes it (a bare commit counting as with events), re-running changes nothing about events, so exec drops the note; it printed on every run of an idempotent script, burying the warnings that apply. A flow not stored yet, a plain `create`, or a stored commit without events that the bare one would flip still gets it, and `check` still names every flow with a bare commit. +- **`exec` and `diff` count the pre-flight's info notes instead of printing each one** — errors and warnings still print in full; info notes (`MDL-WIDGET15` alone was ~35 a run on report pages) become one line, `N info notes not shown — run mxcli check to see them`, and the summary reads `… N info (not shown)`. `--verbose` prints them in full. `check` is unchanged, as are `check --format json|sarif`. ### Fixed +- **A pluggable widget stores its `Visible:` and `Editable:`** — on a combo box, `visible: false` and `editable: Never` passed `check`, `exec` and `mx check` and were written as nothing, and the expression forms were refused as MDL-WIDGET01 "no property `VisibleIf`". All forms (static, expression, `Attr in (…)`) are now stored on the widget as Studio Pro stores them, and `describe page` prints them back. `Editable:` on a widget whose package declares no `Editability` system property (a data grid, an image) is refused as **MDL-WIDGET41**; `Visible:` is accepted on every pluggable widget, as Studio Pro offers it; MDL-WIDGET21 no longer reports `editable:` on pluggable widgets. + - **`create entity` over `--mcp` stores the declared attribute types and String lengths** (ako/mxcli#923) — on both the Studio Pro 11.14 and 11.15 MCP servers every attribute of a created entity was stored as String(200): the entity constructor's attributes were sent with a `$Type`, which makes Studio Pro ignore their `type`, and no String length was ever sent (by `create entity` or `alter entity … add attribute`). Both now write e.g. `String(50)`, `String(unlimited)`, Integer, Long, Decimal, Boolean, DateTime, Enumeration, AutoNumber, HashedString and Binary as declared. An entity created over `--mcp` by an earlier release keeps the wrong types; its attributes have to be retyped in Studio Pro. - **A named argument has the same spellings at every call site, and a positional one is refused at the argument** (ako/mxcli#533, #569) — `call microflow M.F(P: $P)` (and nanoflow, java action and the other call statements) parses as the deprecated alias MDL-DEPR007, like `show page` and page actions already did: a warning under `mdl 0` and `mdl 1`, refused from `mdl 2`, rewritten to `P = $P` by `fmt --upgrade`. The canonical form is `Param = expression` everywhere (R4). An argument written by position — `datasource: microflow M.DS($Value)`, `call microflow M.F($O)`, `show page M.P($O)` — is an error at the argument's own line and column that names `Param = $Value`; a page data source used to report `no viable alternative at input 'datasource'` at the property keyword. The create-page skill and `syntax page.datasource` no longer teach the positional form. - **A flow expression no longer stores the layout whitespace after it** (ako/mxcli#898) — the last value of a member list laid out over several lines, or a value followed by a line break before `;`, was stored with that whitespace (`false\n `). It is trimmed when written; a project that already stores it is not rewritten by a re-run. @@ -95,6 +100,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **`exec` says when a new document is granted to the auto-created `.User` role** — a new page, microflow or nanoflow in a module with no module roles is granted to `.User` (created on first use), and that grant was silent. Because document GRANT is additive, a later `grant view on page M.Stub to M.Admin;` left the page open to `M.User` as well, with nothing on screen to say why. `exec` now prints ` access: granted to auto-created role M.User (the module has no other roles; a later grant adds to it — revoke it to narrow access)` under the create. The behaviour is documented under GRANT and in the manage-security skill. +- **`check -p` reports a used flow the script leaves without access — MDL-SEC21 (CE0106)** — a microflow or nanoflow with no allowed module role that a page, snippet, layout, nanoflow, menu document or navigation profile names (stored, or written by the script) builds to CE0106 at security level Prototype or Production. The reported shape was `drop microflow` in one exec run and `create microflow` in a later one: the later create is a new flow, and a new flow in a module with its own roles gets no access. The check simulates the script's creates, drops, grants, revokes and module roles, reports only what the script changes, and names the fix (`grant execute on microflow … to …`, or `create or modify` instead of drop + create). Error at Prototype/Production, warning at Off. Measured on Mendix 11.14: a published REST operation, a microflow-only caller and an excluded page do not raise CE0106 and are not reported. - **Migration reference: `mxcli help `, `mxcli syntax --deprecated`, and a generated "Language versions and migration" page** (ako/mxcli#714, decisions 2 and 3) — `mxcli help MDL-DEPR001` / `mxcli help MDL-V1-LIMIT1` prints a code's entry: old form, new form, whether `fmt --upgrade` rewrites it, and the version that refuses it, and every warning carrying such a code now ends with `(mxcli help )`. `mxcli syntax page --deprecated` lists the old spellings filed under a topic (`--json` too). The docs page `language/versions.md` covers how to migrate a script and the behaviour changes of waves 5–10, and its two tables (every `MDL-V1-*` change, every `MDL-DEPR*` spelling) are generated from the registries by `make gen-migration-reference`; CI fails when they are stale (`make check-migration-reference`). - **`mxcli fmt --upgrade --header` reads a called flow's return type** (ako/mxcli#860) — `find(…)` / `contains(…)` over the result of a microflow or nanoflow call is the string function when the called flow returns a String and the List operation otherwise, which the script alone does not say. The upgrade now reads it where `exec` does: from a flow an earlier statement of the script creates, else from the project given with `-p app.mpr` (read only). Without a project such a call still blocks the header, and the message now says to pass one. A callee the script drops, renames or moves first, one the project lacks, or an operand that is a String on one path and a list on another is still reported for a hand edit. diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index b802fa3ee5..504d186656 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -142,6 +142,19 @@ func runCheckFile(cmd *cobra.Command, filePath string) int { outputFormat := linter.OutputFormat(format) formatter := linter.GetFormatter(outputFormat, !isStructured) + // The text report runs in tiers (semantic checks, references, project + // verdicts, legacy widgets) and each printed its own "N issues" line, so + // a warning in one tier and an error in another read as two separate + // counts with a "✓" between them, and neither was the total. The tiers + // now print their violations and finish prints one summary over all. + var printed []linter.Violation + if tf, ok := formatter.(*linter.TextFormatter); ok { + tf.NoSummary = true + } + report := func(vs []linter.Violation) { + formatter.Format(vs, os.Stderr) + printed = append(printed, vs...) + } // In a structured format the payload is ONE document on stdout, emitted // once at the end (or at the first failing phase). Each phase used to @@ -152,6 +165,9 @@ func runCheckFile(cmd *cobra.Command, filePath string) int { finish := func(code int) int { if isStructured { formatter.Format(structured, os.Stdout) + } else if len(printed) > 0 { + fmt.Fprintln(os.Stderr) + linter.WriteSummary(os.Stderr, printed, 0) } return code } @@ -253,7 +269,7 @@ func runCheckFile(cmd *cobra.Command, filePath string) int { structured = append(structured, violations...) } else if len(violations) > 0 { fmt.Fprintln(os.Stderr) - formatter.Format(violations, os.Stderr) + report(violations) } if len(violations) > 0 { @@ -376,18 +392,23 @@ func runCheckFile(cmd *cobra.Command, filePath string) int { projectViolations := exec.CheckEntityMemberDrops(prog) projectViolations = append(projectViolations, exec.TypeCheckProgram(prog)...) projectViolations = append(projectViolations, exec.CheckFlowVerdicts(prog)...) + // MDL-SEC21 (MxBuild CE0106): a flow this script leaves with no allowed + // role while a page, snippet, nanoflow, menu or navigation uses it. The + // reported shape was drop + create in separate runs, which loses the + // roles that `create or modify` keeps. + projectViolations = append(projectViolations, exec.CheckFlowAccess(prog)...) if len(projectViolations) > 0 { if isStructured { structured = append(structured, projectViolations...) } else { fmt.Fprintln(os.Stderr) - formatter.Format(projectViolations, os.Stderr) + report(projectViolations) } if linter.Summarize(projectViolations).Errors > 0 { return finish(1) } } else if !isStructured { - fmt.Printf("✓ Expression types OK, no unstated member drops, no flow change exec would refuse\n") + fmt.Printf("✓ Expression types OK, no unstated member drops, no flow change exec would refuse, no used flow left without access\n") } } @@ -411,7 +432,7 @@ func runCheckFile(cmd *cobra.Command, filePath string) int { structured = append(structured, legacyViolations...) } else if len(legacyViolations) > 0 { fmt.Fprintln(os.Stderr) - formatter.Format(legacyViolations, os.Stderr) + report(legacyViolations) fmt.Fprintf(os.Stderr, "\n✗ %d legacy widget(s) found\n", len(legacyViolations)) } else { fmt.Printf("✓ No legacy native widgets found\n") diff --git a/cmd/mxcli/cmd_diff.go b/cmd/mxcli/cmd_diff.go index 7cced952ab..700eb31680 100644 --- a/cmd/mxcli/cmd_diff.go +++ b/cmd/mxcli/cmd_diff.go @@ -63,6 +63,7 @@ Examples: useColor, _ := cmd.Flags().GetBool("color") width, _ := cmd.Flags().GetInt("width") skipCheck, _ := cmd.Flags().GetBool("no-check") + verbose, _ := cmd.Flags().GetBool("verbose") continueOnError, _ := cmd.Flags().GetBool("continue-on-error") showExecOutput, _ := cmd.Flags().GetBool("exec-output") depPolicy := deprecationPolicy(cmd) @@ -98,7 +99,7 @@ Examples: NewBackend: func() backend.FullBackend { return modelsdkbackend.New() }, ContinueOnError: continueOnError, Preflight: func(scratch *executor.Executor, w io.Writer) string { - return execPreflight(scratch, prog, projectPath, skipCheck, depPolicy, w, useColor) + return execPreflight(scratch, prog, projectPath, filePath, skipCheck, verbose, continueOnError, depPolicy, w, useColor) }, } if filePath != "-" { diff --git a/cmd/mxcli/cmd_exec.go b/cmd/mxcli/cmd_exec.go index 035e37d80c..34c5774b8b 100644 --- a/cmd/mxcli/cmd_exec.go +++ b/cmd/mxcli/cmd_exec.go @@ -23,7 +23,8 @@ Before anything is written, the script is put through the same semantic checks as "mxcli check". If any of them reports an error, nothing is executed: exec applies statements one at a time and cannot roll back, so running a script with a known error leaves the model partly updated. Warnings are printed and do not -stop the run. Use --no-check to apply a script anyway. +stop the run; info notes are counted on one line (--verbose prints them, as +"mxcli check" does). Use --no-check to apply a script anyway. A deprecated MDL spelling (MDL-DEPRnnn, e.g. "create or replace" for "create or modify") is a warning; --deprecations=error makes it an error. @@ -60,6 +61,7 @@ Example: projectPath, _ := cmd.Flags().GetString("project") continueOnError, _ := cmd.Flags().GetBool("continue-on-error") skipCheck, _ := cmd.Flags().GetBool("no-check") + verbose, _ := cmd.Flags().GetBool("verbose") if force, _ := cmd.Flags().GetBool("force"); force { mmpr.AllowWritesWhileStudioProOpen = true if lock, _ := mmpr.StudioProLockFile(projectPath); lock != "" { @@ -116,7 +118,7 @@ Example: os.Exit(1) } - if refusal := execPreflight(exec, prog, projectPath, skipCheck, depPolicy, os.Stderr, true); refusal != "" { + if refusal := execPreflight(exec, prog, projectPath, filePath, skipCheck, verbose, continueOnError, depPolicy, os.Stderr, true); refusal != "" { fmt.Fprint(os.Stderr, refusal) os.Exit(1) } @@ -147,6 +149,8 @@ Example: func init() { execCmd.Flags().Bool("no-check", false, "Skip the pre-flight semantic checks and apply the script even if mxcli check would report errors") + execCmd.Flags().Bool("verbose", false, + "Print the pre-flight checks' info notes in full instead of counting them (mxcli check always prints them)") execCmd.Flags().Bool("force", false, "Write even though Studio Pro appears to have the project open (its .mpr.lock is present) — e.g. a lock left behind by a crash") execCmd.Flags().Bool("continue-on-error", false, diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 433e61142f..143bfff469 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -695,7 +695,9 @@ func RunLocal(opts LocalRunOptions) error { if err != nil { return fmt.Errorf("starting web client bundler: %w", err) } - defer watcher.Stop() + // Stop whichever watcher is current at exit: watchAndApply replaces it + // when a newly added page needs a fresh bundler (see missingPageChunks). + defer func() { _ = watcher.Stop() }() } else { fmt.Fprintln(w, "Bundling web client...") if err := BuildWebClient(WebClientOptions{DeployDir: opts.DeployDir, MxBuildPath: mxbuildPath, Stdout: w}); err != nil { @@ -925,7 +927,7 @@ func RunLocal(opts LocalRunOptions) error { // 7. Stay up until interrupted. With --watch, rebuild + hot-apply on every // project change; otherwise just keep the runtime serving. if opts.Watch { - return watchAndApply(opts, serve, rt, watcher, mxbuildPath) + return watchAndApply(opts, serve, rt, &watcher, mxbuildPath) } fmt.Fprintln(w, "(run with --watch to rebuild and hot-apply on changes; Ctrl-C to stop)") if waitForInterruptOrExit(rt.Exited()) { @@ -1191,6 +1193,14 @@ func ensureClientServed(deployDir, appURL, mxbuildPath string, out io.Writer) er strings.Join(still, ", ")) } } + // A page module with no bundled chunk is likewise invisible to the index.js + // probe: pages are loaded by dynamic import. A one-shot bundle re-globs + // web/pages and emits it. See missingPageChunks. + if _, err := recoverMissingPages(deployDir, func() error { + return BuildWebClient(WebClientOptions{DeployDir: deployDir, MxBuildPath: mxbuildPath, Stdout: out}) + }, out); err != nil { + return fmt.Errorf("web client re-bundle: %w", err) + } if clientBundlePresent(deployDir) && clientBundleServedWithin(appURL, clientProbeWindow) { return nil } @@ -1253,8 +1263,19 @@ func settleSourceWith(projectPath string, seen time.Time, sigCh <-chan os.Signal // watchAndApply polls the project for changes and applies each rebuild until the // user interrupts (Ctrl-C). StartLocalRuntime already resolved the JVM; here we // only rebuild via serve and let the RuntimeController decide reload vs restart. -func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, watcher *WebClientWatcher, mxbuildPath string) error { +func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, watcherRef **WebClientWatcher, mxbuildPath string) error { w := opts.Stdout + watcher := *watcherRef + // restartWatcher replaces the incremental bundler with a fresh one, whose + // first build re-globs web/pages. The caller's reference is updated so it + // stops the live watcher, not the one replaced here. + restartWatcher := func() error { + _ = watcher.Stop() + next, err := StartWebClientWatch(WebClientOptions{DeployDir: opts.DeployDir, MxBuildPath: mxbuildPath, Stdout: io.Discard}) + watcher = next + *watcherRef = next + return err + } sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -1350,6 +1371,19 @@ func watchAndApply(opts LocalRunOptions, serve *ServeServer, rt *LocalRuntime, w continue } } + // The incremental bundler never picks up a page added after it + // started (mxbuild's pages plugin globs once), so a new page would + // 404 in the browser while everything above reports success. A fresh + // bundler re-globs. Only meaningful with a live watcher; without one, + // ensureClientServed's one-shot covers the same check. + if watcher != nil { + restarted, err := recoverMissingPages(opts.DeployDir, restartWatcher, w) + if err != nil { + fmt.Fprintf(opts.Stderr, " %v\n", err) + continue + } + bundled = bundled || restarted + } action, err := rt.Controller().ApplyBuild(build, rt.Restart) if err != nil { diff --git a/cmd/mxcli/docker/webclient_pages.go b/cmd/mxcli/docker/webclient_pages.go new file mode 100644 index 0000000000..2f8c2b7813 --- /dev/null +++ b/cmd/mxcli/docker/webclient_pages.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// missingPageChunks returns the generated page modules (web/pages/**/*.js) that +// have no bundled counterpart under web/dist/pages, as slash-separated paths +// relative to web/pages. +// +// Why this exists: on deployments that mxcli bundles with rollup (Mendix 11.13 +// and earlier), mxbuild's rollup-plugin-mendix-pages globs web/pages once, at +// bundler start, and never again — its watchChange compares the changed path +// against "./pages/", a prefix path.relative() never produces. So under --watch a +// page ADDED while the loop runs is written by the serve build, the incremental +// bundler rebuilds without it, and the browser 404s on dist/pages/.js when +// the page is opened. Neither the index.js probe nor danglingClientChunks can see +// it: pages are loaded by dynamic import, so nothing in the static graph refers to +// the missing file. +// +// The check is gated on the deployment's shape, not the Mendix version: it only +// applies when web/pages holds page modules AND web/dist/index.js exists. Mendix +// 11.14+ (mxbuild writes dist itself, no web/pages) and the classic client (no +// dist) both yield nothing. An empty result means "nothing missing". +func missingPageChunks(deployDir string) []string { + webDir := filepath.Join(deployDir, "web") + if _, err := os.Stat(filepath.Join(webDir, "dist", "index.js")); err != nil { + return nil // no bundle at all is clientBundlePresent's business + } + srcDir := filepath.Join(webDir, "pages") + distDir := filepath.Join(webDir, "dist", "pages") + + var missing []string + _ = filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".js") { + return nil // unreadable entries are skipped: this gates a recovery + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return nil + } + if _, err := os.Stat(filepath.Join(distDir, rel)); err != nil { + missing = append(missing, filepath.ToSlash(rel)) + } + return nil + }) + sort.Strings(missing) + return missing +} + +// recoverMissingPages runs rebundle when a page module has no bundled chunk, and +// reports it in one line. It returns whether a re-bundle ran. Under --watch +// rebundle restarts the incremental bundler (a one-shot bundle would be right +// until the stale watcher's next rebuild, which still does not know the page); +// otherwise it is a one-shot BuildWebClient. +func recoverMissingPages(deployDir string, rebundle func() error, out io.Writer) (bool, error) { + missing := missingPageChunks(deployDir) + if len(missing) == 0 { + return false, nil + } + fmt.Fprintf(out, " %d page(s) missing from the client bundle (%s) — mxbuild's incremental bundler does not pick up added pages; re-bundling...\n", + len(missing), strings.Join(missing, ", ")) + if err := rebundle(); err != nil { + return true, fmt.Errorf("re-bundling for added pages: %w", err) + } + if still := missingPageChunks(deployDir); len(still) > 0 { + return true, fmt.Errorf("pages still not bundled after re-bundle: %s", strings.Join(still, ", ")) + } + return true, nil +} diff --git a/cmd/mxcli/docker/webclient_pages_test.go b/cmd/mxcli/docker/webclient_pages_test.go new file mode 100644 index 0000000000..bd580ca9e5 --- /dev/null +++ b/cmd/mxcli/docker/webclient_pages_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +// `run --local --watch` on Mendix 11.13: a page added while the loop runs is +// never bundled. The serve build writes web/pages/.js, the incremental +// rollup watcher rebuilds, the apply is reported as successful — and the browser +// 404s on dist/pages/.js when the page is opened. Restarting the loop +// fixed it, because a fresh rollup run re-globs web/pages. +// +// The defect is in mxbuild's rollup-plugin-mendix-pages.mjs: watchChange tests +// the changed path against "./pages/", which path.relative() never produces, so +// the page list is only ever globbed once, at watcher start. +package docker + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// pagesDeployment writes a 11.13-shaped web/ tree: the generated page modules +// under web/pages, and the bundle under web/dist with the given pages emitted. +func pagesDeployment(t *testing.T, source, bundled []string) string { + t.Helper() + deploy := t.TempDir() + write := func(rel, content string) { + p := filepath.Join(deploy, "web", filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + write("dist/index.js", "//") + for _, p := range source { + write("pages/"+p, "export default {};") + } + for _, p := range bundled { + write("dist/pages/"+p, "export default {};") + } + return deploy +} + +func TestMissingPageChunks_NewPageNotBundled(t *testing.T) { + // The reported failure: the serve build wrote the new page's module, the + // running watcher did not emit it. + deploy := pagesDeployment(t, + []string{"MyFirstModule.Home_Web.js", "MyFirstModule.NewPage.js"}, + []string{"MyFirstModule.Home_Web.js"}) + got := missingPageChunks(deploy) + if len(got) != 1 || got[0] != "MyFirstModule.NewPage.js" { + t.Fatalf("got %v, want [MyFirstModule.NewPage.js]", got) + } +} + +func TestMissingPageChunks_AllBundledIsNotReported(t *testing.T) { + // Control: this gates a bundler restart (a cold build), so a false positive + // would cost seconds on every apply. + deploy := pagesDeployment(t, + []string{"MyFirstModule.Home_Web.js", "Administration.Account_Edit.js"}, + []string{"MyFirstModule.Home_Web.js", "Administration.Account_Edit.js"}) + if got := missingPageChunks(deploy); len(got) != 0 { + t.Fatalf("fully bundled pages reported as missing: %v", got) + } +} + +func TestMissingPageChunks_NestedPathIsPreserved(t *testing.T) { + // The plugin emits each page at its path relative to web/, so a page module in + // a subfolder lands in the same subfolder under dist. + deploy := pagesDeployment(t, + []string{"Mod/A.js", "Mod/B.js"}, + []string{"Mod/A.js", "B.js"}) + got := missingPageChunks(deploy) + if len(got) != 1 || got[0] != "Mod/B.js" { + t.Fatalf("got %v, want [Mod/B.js]", got) + } +} + +func TestMissingPageChunks_IgnoresNonModules(t *testing.T) { + deploy := pagesDeployment(t, []string{"A.js", "A.js.map", "notes.txt"}, []string{"A.js"}) + if got := missingPageChunks(deploy); len(got) != 0 { + t.Fatalf("non-.js files reported as missing pages: %v", got) + } +} + +func TestMissingPageChunks_NoPageSourceIsNotReported(t *testing.T) { + // Mendix 11.14+ writes web/dist itself and leaves no web/pages; the classic + // client has no dist at all. Neither shape has anything to compare. + deploy := pagesDeployment(t, nil, []string{"A.js"}) + if got := missingPageChunks(deploy); len(got) != 0 { + t.Fatalf("no web/pages: got %v, want nothing", got) + } + noDist := t.TempDir() + if err := os.MkdirAll(filepath.Join(noDist, "web", "pages"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(noDist, "web", "pages", "A.js"), []byte("//"), 0o600); err != nil { + t.Fatal(err) + } + if got := missingPageChunks(noDist); len(got) != 0 { + t.Fatalf("no web/dist/index.js: got %v, want nothing (clientBundlePresent's business)", got) + } +} + +func TestRecoverMissingPages_RestartsOnlyWhenAPageIsMissing(t *testing.T) { + calls := 0 + restart := func() error { calls++; return nil } + + // Control first: nothing missing, nothing restarted, nothing printed. + healthy := pagesDeployment(t, []string{"A.js"}, []string{"A.js"}) + var out strings.Builder + did, err := recoverMissingPages(healthy, restart, &out) + if err != nil || did || calls != 0 || out.Len() != 0 { + t.Fatalf("healthy: did=%v err=%v calls=%d out=%q; want no restart", did, err, calls, out.String()) + } + + // The restart stands in for a fresh rollup run, which re-globs web/pages and + // emits the new page. + broken := pagesDeployment(t, []string{"A.js", "B.js"}, []string{"A.js"}) + rebundle := func() error { + calls++ + return os.WriteFile(filepath.Join(broken, "web", "dist", "pages", "B.js"), []byte("//"), 0o600) + } + did, err = recoverMissingPages(broken, rebundle, &out) + if err != nil || !did || calls != 1 { + t.Fatalf("missing page: did=%v err=%v calls=%d; want one restart", did, err, calls) + } + if lines := strings.Count(strings.TrimSpace(out.String()), "\n"); lines != 0 || !strings.Contains(out.String(), "B.js") { + t.Errorf("want one line naming the page, got %q", out.String()) + } + + // A restart that still leaves the page out is an error, not a success. + stale := pagesDeployment(t, []string{"A.js", "C.js"}, []string{"A.js"}) + if _, err := recoverMissingPages(stale, restart, io.Discard); err == nil || !strings.Contains(err.Error(), "C.js") { + t.Fatalf("page still missing after restart not reported: %v", err) + } + + // A failed restart is surfaced, not swallowed. + _, err = recoverMissingPages(stale, func() error { return errors.New("boom") }, io.Discard) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("restart failure not surfaced: %v", err) + } +} + +// TestEnsureClientServed_RecoversWhenAPageIsMissing: index.js is present and +// served, so the existing probe passes; a page module with no bundled chunk must +// still take the re-bundle branch. The bogus mxbuild path makes that branch fail +// visibly, which proves it ran (TestEnsureClientServed_NoRecoveryWhenServed is +// the control: same server, no missing page, no error). +func TestEnsureClientServed_RecoversWhenAPageIsMissing(t *testing.T) { + deploy := pagesDeployment(t, []string{"A.js", "B.js"}, []string{"A.js"}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/dist/index.js" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + err := ensureClientServed(deploy, srv.URL+"/", "/nonexistent/mxbuild", io.Discard) + if err == nil || !strings.Contains(err.Error(), "re-bundle") { + t.Fatalf("expected the re-bundle branch to run for a missing page chunk, got: %v", err) + } +} diff --git a/cmd/mxcli/exec_preflight.go b/cmd/mxcli/exec_preflight.go index 61432cdfac..5a6af9d74c 100644 --- a/cmd/mxcli/exec_preflight.go +++ b/cmd/mxcli/exec_preflight.go @@ -18,18 +18,28 @@ import ( // checks, so it refuses exactly the scripts exec refuses (ako/mxcli#807). // // exec is the executor the script would run on, connected to projectPath ("" when -// the script connects itself). -func execPreflight(exec *executor.Executor, prog *ast.Program, projectPath string, skipCheck bool, depPolicy deprecation.Policy, w io.Writer, color bool) string { +// the script connects itself). script names the script for the hint that +// points at `mxcli check` ("-" for stdin). showInfo prints info-level notes in +// full; otherwise they are counted on one line (see printPreflightViolations). +// continueOnError is exec's --continue-on-error: a flow change exec would refuse +// is then reported but does not refuse the script, since that mode asks for +// every statement that can run to run. +func execPreflight(exec *executor.Executor, prog *ast.Program, projectPath, script string, skipCheck, showInfo, continueOnError bool, depPolicy deprecation.Policy, w io.Writer, color bool) string { // Pre-flight: refuse a script whose semantic checks report an error, // rather than writing part of it and leaving the model to mxbuild. // exec is not transactional, so "run it and see" means a half-applied // model. Warnings are printed and do not stop the run. if !skipCheck { violations := executor.ApplyDeprecationPolicy(executor.ValidateProgram(prog, projectPath), depPolicy) - if len(violations) > 0 { - formatter := linter.GetFormatter(linter.OutputFormatText, color) - formatter.Format(violations, w) + // The bare-commit note (MDL067) says a re-run flips what is stored; + // for a flow the project already holds that way it does not, and the + // note would repeat on every run of an idempotent script. + if exec != nil { + if b := exec.Backend(); b != nil { + violations = executor.DropSettledCommitNotes(violations, prog, executor.NewStoredCommitEvents(b)) + } } + printPreflightViolations(violations, script, showInfo, w, color) if summary := linter.Summarize(violations); summary.Errors > 0 { return fmt.Sprintf( "\nRefusing to execute: %d error(s) above. Nothing was written.\n"+ @@ -109,6 +119,64 @@ func execPreflight(exec *executor.Executor, prog *ast.Program, projectPath strin " create is still refused when it runs).\n", len(clashes)) } + + // Fourth pass: a flow change exec would refuse when it reaches it — a + // splice under mdl 1, an alter whose patch fails. `check -p` already + // runs this verdict; exec did not, so it wrote every statement before + // the refused one and none after, leaving the model half-applied. + verdicts := exec.CheckFlowVerdicts(prog) + if len(verdicts) > 0 { + (&linter.TextFormatter{UseColor: color}).Format(verdicts, w) + if n := linter.Summarize(verdicts).Errors; n > 0 && !continueOnError { + return fmt.Sprintf( + "\nRefusing to execute: %d flow change(s) above would be refused when reached. Nothing was written.\n"+ + " exec applies statements one at a time, so the statements before a refused\n"+ + " change would be written and the ones after it would not.\n"+ + " Make the change the refusal names, or re-run with --continue-on-error to\n"+ + " apply every other statement.\n", + n) + } + } } return "" } + +// printPreflightViolations prints what exec's semantic pass found: errors and +// warnings in full, info notes as one count line unless showInfo is set. +// +// An info note never stops exec and asks nothing of a script that is being +// re-run; printed in full on every run they buried the warnings that do (on +// report pages MDL-WIDGET15 alone was ~35 notes a run). `check` is where a +// script is reviewed, and it still prints every note, so the count line +// points there. Only this text report changes: exec has no structured +// diagnostics output, and `check --format json|sarif` is untouched. +func printPreflightViolations(violations []linter.Violation, script string, showInfo bool, w io.Writer, color bool) { + shown := violations + infos := 0 + if !showInfo { + shown = nil + for _, v := range violations { + if v.Severity == linter.SeverityInfo { + infos++ + continue + } + shown = append(shown, v) + } + } + if len(shown) > 0 { + // The summary line counts the omitted notes too, marked not shown. + f := &linter.TextFormatter{UseColor: color, OmittedInfos: infos} + f.Format(shown, w) + } + if infos > 0 { + noun := "info notes" + if infos == 1 { + noun = "info note" + } + if script == "" { + script = "