From e4894b6f214becfd52c6f72e7119e1d45077220d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:16:22 +0000 Subject: [PATCH 01/11] Add proposal: Mendix whiteboard A canvas on which a user and an agent sketch a Mendix app through five layers (sketch, notes, impression, spec & tests, model) and three lenses (security, integration, architecture), with requirements in the project brain and the model built through mxcli's existing MDL, check, exec and run --local. Proposes per-requirement status in brain plan --json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WGkvWsVVLHHP6JsDmKgztC --- .../PROPOSAL_mendix_whiteboard.md | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 docs/11-proposals/PROPOSAL_mendix_whiteboard.md diff --git a/docs/11-proposals/PROPOSAL_mendix_whiteboard.md b/docs/11-proposals/PROPOSAL_mendix_whiteboard.md new file mode 100644 index 000000000..f08b1547a --- /dev/null +++ b/docs/11-proposals/PROPOSAL_mendix_whiteboard.md @@ -0,0 +1,340 @@ +--- +title: Mendix whiteboard — sketch an app with an agent, from napkin to running model +status: draft +date: 2026-10-14 +related: + - PROPOSAL_project_brain.md + - PROPOSAL_graph_analysis.md + - PROPOSAL_mxcli_dev_warm_loop.md + - starlark-security.md + - journey-architecture-viz.md + - PROPOSAL_vscode_visualizations.md +--- + +# Proposal: Mendix whiteboard + +**Status:** Draft +**Date:** 2026-10-14 + +## Problem Statement + +Requirements for a Mendix app are usually worked out at a whiteboard, then +translated by hand into Studio Pro. The translation loses most of what was +said: the sketch is photographed, the notes are typed up into a backlog, and the +model that gets built is checked against neither. With an agent and mxcli the +translation step can disappear — but only if the conversation happens somewhere +the agent can read and draw, and only if every later artifact (requirement, +MDL, model, running app) stays connected to the sketch it came from. + +The whiteboard is that place: a large canvas on which a user sketches a user +journey, screens, flows and data by hand; the agent asks questions until the +sketch is specific, draws progressively more exact versions of it on top, and +turns it into MDL that mxcli checks, applies and runs. The user can at any time +switch a part of the board between **sketch**, **MDL** and **app**, and read +the state of the whole solution through three **lenses**: security, +integration and architecture. + +Who benefits: + +- **Business analysts and product owners** who think in journeys and screens, + not in microflows — they get a running app without leaving the whiteboard. +- **Mendix developers** who receive a spec that is already traced to model + elements, test data and test cases, instead of a slide and a backlog. +- **Architects and security reviewers**, who get the solution's access rules, + integrations and module structure as pictures computed from the model, not as + a document someone has to keep current. + +An illustrated walkthrough of the concept exists as a sketch deck (16 slides, +private artifact): . This +proposal is the written form; where the two disagree, this document wins. + +What it is not: a replacement for Studio Pro, a general-purpose diagramming +tool, or a store for model facts. The model stays the source of truth for +everything mxcli can compute (see "State stays in its native home" below). + +## The concept + +### One board, five layers + +Every part of the board is drawn in layers, each on top of the previous one, +like onion skin. Any layer can be faded in or out; the earlier ones stay +visible underneath, so the provenance of a field or a flow is always one glance +away. + +| # | Layer | Drawn by | Content | +|---|-------|----------|---------| +| 0 | **sketch** | user, pen | Personas, screens as boxes, the flow as arrows, data as boxes | +| 1 | **notes** | agent, from the user's answers | Post-its: the answers to the agent's questions, each one a requirement | +| 2 | **impression** | agent, marker style | Screens with real fields and widgets; the flow as BPMN-like lanes, gateways and timers; entities with attributes and multiplicities | +| 3 | **spec & tests** | agent with user | Requirements tree, test data, Given/When/Then cases, edge cases | +| 4 | **model** | mxcli | Studio Pro-exact renderings of the workflow, domain model and pages, and screenshots of the running app | + +The agent never skips a layer for a part of the board, but parts move through +the layers independently: the workflow may be at layer 4 while the pages are +still at layer 2. The deck's recommended order for the model layer is +**workflow first, then domain model, then pages**, each in small iterations. + +### Frames are model elements + +The unit of the board is a **frame**: a page, a microflow, a workflow, or a +domain-model region. A frame's title is the **qualified name** of the element +it becomes (`Helpdesk.Ticket_Detail`). That one convention joins the board, the +brain and the model with no mapping table: the frame name is the brain anchor +and the MDL target. Arrows between frames mean *navigates to*, *calls* or +*uses entity*, and become buttons, microflow calls and data sources. + +### Three views per frame + +| View | What it shows | Source | +|------|---------------|--------| +| **Sketch** | Strokes, notes, reference images, the layer stack | The artifact database | +| **MDL** | The frame's script, one file per frame | `whiteboard/.mdl` in the project repo | +| **App** | Pages: a screenshot of the page in the running app. Workflows, microflows, domain models: mxcli's rendering of the *model* (`describe --format elk/mermaid`), since these have no running view | `mxcli run --local --watch` + Playwright; `mxcli describe` | + +### Three lenses + +A lens is an overlay the user lays over the whole board. The agent computes it +from the model and draws it onto the same elements the user sketched; problems +are marked in red, each with a numbered finding and a proposed fix. + +| Lens | Question | Drawn as | Computed from | +|------|----------|----------|---------------| +| **security** | Who can see and do what? Is every part covered? Does any role lack privileges it needs? | A coloured dot per role on every screen, task and entity (dashed where a role needs access and lacks it); a role × element access matrix; red rings on gaps | `CATALOG.permissions`, `role_mappings`, `navigation_role_homes`, page/task access; the `sec_*` lint rules (`.claude/lint-rules/`, SEC001–SEC010) | +| **integration** | What is connected, what data flows which way, how is it processed, and is there exactly one source of truth for each piece of data? | External systems around the board, plugged into the elements they touch; each arrow labelled with what flows and how (OData, REST, business event); a star on the source of truth; red rings on copies that will drift | `odata_clients_data`, `odata_services_data`, `external_entities_data`, `rest_clients_data`, `published_rest_services_data`, `business_event_services_data`, `import_mappings_data`, `export_mappings_data` | +| **architecture** | Is the internal structure sound and future-proof? What needs refactoring, what should be extracted as its own app? | The board coloured by owning module; a module dependency graph with size/heat; cycles, god modules and extraction candidates marked; a target picture | `mxcli graph-report` and the `CATALOG.graph_*` views (`graph_module_dependencies`, `graph_module_cycles_data`, `graph_god_nodes`, `graph_module_coupling`, `graph_integration_surface`); ARCH lint rules | + +The lenses are what makes the board an architecture tool and not only a +drawing tool: every finding is anchored to something the user drew, and every +finding is re-computed after the fix, so "0 gaps" is a measurement, not a claim. + +## Design principles + +These follow from rules this repository already holds; each is a constraint +on the implementation, not a preference. + +1. **State stays in its native home** (CLAUDE.md, "Documentation Artifacts"). + The board stores only what nothing else can: strokes, notes, images, frame + positions. Requirements live in the project brain; MDL lives in the repo; + model facts live in the model and are queried, never copied onto the board. +2. **Status is derived, never stored.** "Built" comes from `brain plan` + resolving anchors; "checked" from `mxcli check`; "verified" from a test or + screenshot run; lens findings from catalog queries. The board displays these + and is refreshed on every sync — it never holds a status field a human or + the agent has to keep up to date. +3. **A human promotes.** The agent drafts requirements and decisions with + `brain capture`; the user accepts them on the board, and only then does the + agent run `brain promote` (the brain's own rule, PROPOSAL_project_brain.md + §4.3). +4. **Renaming is not re-creating.** Renaming a frame or an element on the board + must become `mxcli rename`, never a drop-and-create: an entity's `GUID` is + the database's identity, and re-minting it drops the table on the next + deploy (CLAUDE.md, "A `GUID` Is the Database's Identity"). On a throw-away + prototype database this is cheap; the whiteboard must not teach a habit that + loses production data later. +5. **Ambiguity becomes a question, not a guess.** A sketch that does not decide + something (grid or list, who may reopen) produces an open question pinned to + the frame (`brain capture --open`), answered with `brain resolve`. + +## Where each piece of state lives + +| State | Home | Written by | Read by the board as | +|-------|------|------------|----------------------| +| Strokes, typed notes, images, frame geometry and kind | Artifact database (`frames`, `strokes`, `texts`, `images`) | The page | — (it is the board) | +| Requirements, decisions, open questions | `docs/brain/plan/.md`, `docs/brain/modules/.md` | Agent (`capture`), user (`promote`, `resolve`) | A requirements tree: slices → frames → requirements, each marked built / planned / open | +| Pending user actions on the tree (accept, reject, answer) | Artifact database (`queue`) | The page | Applied by the agent on the next sync, then cleared | +| MDL per frame | `whiteboard/.mdl` in the project repo | Agent; later the user, from the MDL view | The MDL view | +| The model | `.mpr` | `mxcli exec` | — (queried) | +| Rendered impressions, model diagrams, app screenshots | Artifact assets | Agent | The impression and app views | +| Lens findings | Recomputed per sync from the catalog | — | Lens overlays (cached in the artifact database only for display) | + +The tree in the board's side panel replaces the Sketch Deck's slide list. It is +a view of `brain plan` plus `brain staged`, never an independent list. + +## The loop + +On "done" (or on any change, once the loop is trusted): + +1. **Export** the board's collections from the artifact database and render each + frame (as Sketch Deck's `sketch_render.py` does). +2. **Read** each changed frame with its notes and the arrows that touch it. +3. **Ask** about anything the sketch does not decide; pin each question to its + frame and capture it as an open question. +4. **Capture** each new requirement against its frame's qualified name and the + frame's slice; apply the user's queued accept/reject/answer actions with + `brain promote`, `brain drop`, `brain resolve`. +5. **Draw** the next layer for the frames that are ready for it. +6. **Write** or update the frame's MDL; `mxcli check`, then + `mxcli check --references`; pin errors to the frame they came from. +7. **Apply** with `mxcli exec`; `mxcli run --local --watch` hot-reloads page and + microflow changes in about a second and restarts the runtime for entity + changes. +8. **Render** the app view: Playwright screenshots of pages, `describe` diagrams + of everything else; upload them as assets. +9. **Recompute** the active lenses and the requirements tree; publish. + +The artifact cannot reach `localhost`, so screenshots are the reliable path for +the app view. A live view through `run --hub` is a later option and is +Linux-only by design (ADR-0009). + +## BSON Structure + +Not applicable. The whiteboard adds no document types and writes nothing +mxcli does not already write: every model change goes through MDL and the +existing executor (`mxcli exec`), with its existing reconciliation, identity +carry and GUID guard. No storage names, fields or pointers are introduced. + +## Proposed MDL Syntax + +None. The whiteboard is a consumer of existing MDL, not an extension of it. + +Two CLI changes are proposed; both are small and each has a consumer outside +the whiteboard. + +### 1. Per-requirement status in `brain plan --json` + +`brain plan --json` reports counts per slice (`SliceProgress`: built, planned, +questions, unanchored). The requirements tree needs the state of each +requirement. The per-anchor states already exist (`AnchorFinding` in +`cmd/mxcli/brain/check.go`); the change exposes them grouped by entry: + +```json +{ + "slices": [ + { + "slice": "02-triage", + "built": 1, "planned": 1, "questions": 0, "unanchored": 0, + "requirements": [ + { "id": "a1b2c3", "title": "SLA per priority", "state": "built", + "anchors": [ { "anchor": "@Helpdesk.Ticket.DueAt", "state": "resolved" } ] }, + { "id": "d4e5f6", "title": "Only the service desk reassigns", "state": "planned", + "anchors": [ { "anchor": "@Helpdesk.ACT_Reassign", "state": "not found" } ] } + ] + } + ], + "built": 1, "total": 2 +} +``` + +Additive: existing fields keep their meaning. + +### 2. `mxcli lens` (phase 3, only if the skill-side queries prove insufficient) + +Each lens is first implemented in the skill as catalog SQL (`mxcli -p app.mpr +-c "select … from CATALOG.permissions …"`) plus the existing `lint` and +`graph-report` output. Only if the same queries are needed by a second consumer +(the VS Code extension, `mxcli serve`) do they move into a command: + +```bash +mxcli lens security -p app.mpr --json # role × element access, gaps +mxcli lens integration -p app.mpr --json # systems, flows, sources of truth, copies +mxcli lens architecture -p app.mpr --json # modules, dependencies, cycles, candidates +``` + +The output is keyed by qualified name, so any view can place a finding on the +element it is about. Not adding the command up front keeps the CLI surface free +of a shape that has one caller. + +## Implementation Plan + +The whiteboard is three things: an **artifact page** (the board), a **skill** +(the agent's procedure), and the two **mxcli** changes above. The page and the +skill do not ship in this repository's binary; they follow the Sketch Deck's +model — a template artifact plus a skill — and the skill is published as a +Mendix skill so it reaches user projects. + +### Phase 1 — board and loop, layers 0–2, screenshots + +- Fork the Sketch Deck template into a "Mendix Whiteboard" template: frame + kinds (page, workflow, microflow, domain), frame titles as qualified names, + the requirements tree in place of the slide list, the Sketch / MDL / App + toggle per frame, a queue collection for tree actions. +- The skill: the loop above for layers 0–2 and the app view of pages. +- `brain plan --json` per-requirement status (change 1). + +### Phase 2 — spec, tests and the model layer + +- Layer 3: test data and test cases written from the requirements, stored as + `mxcli test` files next to the frame's MDL. +- Layer 4: model renderings via `describe --format elk/mermaid` for workflows, + microflows and domain models. +- Editable MDL in the board's MDL view; an edit there outranks the sketch on + the next sync. + +### Phase 3 — lenses + +- Security, integration and architecture overlays from catalog queries, lint + and `graph-report`. +- `mxcli lens` only if a second consumer appears (change 2). + +### Phase 4 — start from an existing app + +- Seed a board from a project: `describe` per document gives MDL, the catalog + gives the frames and their arrows, the lenses work unchanged. + +### Files to modify/create + +| File | Change | +|------|--------| +| `cmd/mxcli/brain/check.go` | Group `AnchorFinding`s by entry; add a per-requirement state to `SliceProgress` | +| `cmd/mxcli/cmd_brain.go` | `planReport` emits `requirements` per slice | +| `cmd/mxcli/brain/brain_test.go` (or a new `plan_json_test.go`) | Per-requirement state test, with control | +| `.claude/skills/mendix/whiteboard/SKILL.md` | The loop, the layer and lens procedures, the naming and rename rules | +| `docs-site/src/` | A user page on whiteboarding with mxcli | +| Phase 3, conditional: `cmd/mxcli/cmd_lens.go`, `mdl/catalog/` | `mxcli lens` | + +The template page and the drawing kit live with the template artifact, not in +this repository. + +## Version Compatibility + +No new Mendix model features are used, so no version gating is added. The +whiteboard inherits the requirements of what it calls: + +- `mxcli run --local` — a JDK matching the project's Java version and a + PostgreSQL database, or `--db-type hsqldb` for none. +- Workflows need a Mendix version with workflows, and the workflow constructs + the existing MDL supports; the skill must not draw a construct mxcli cannot + write. +- `run --hub` (an optional live app view) is Linux-only (ADR-0009). + +## Test Plan + +- **Brain JSON (unit).** A slice with one requirement whose anchor resolves and + one whose anchor does not: assert `built` and `planned` per requirement. + Control: stub the per-entry grouping and confirm the test fails with both + requirements reported the same. +- **Lens queries (integration, `-tags integration`).** A fixture project with a + known gap of each kind — an entity with no access rule, a page a needed role + cannot open, an entity copied from a consumed OData service, a module cycle. + Each lens must report exactly those, and nothing after the fix is applied. + Control: the same project with the fixes, which must report zero findings. +- **Skill evals.** A recorded board export (frames, strokes, notes) for the + Helpdesk example; the eval asserts that the produced MDL passes + `mxcli check --references` and that every frame's qualified name resolves + after `exec`. Uses the eval framework (`proposal-eval-framework.md`). +- **Rename safety.** A frame renamed on the board must produce `mxcli rename`, + not drop-and-create. Verified on a **Studio Pro-authored** entity (an + mxcli-created one has `GUID == $ID` and cannot show the defect), comparing + the stored `GUID` before and after. +- **Board page.** As Sketch Deck does: a seeded local copy, Playwright, one pass + over the new controls (frame kind, view toggle, tree actions), no page errors. + +## Open Questions + +1. **Slices.** Is a slice a board region the user draws, a frame group, or only + a name prefix on requirements? The brain needs a slice per requirement. +2. **Where the MDL lives.** `whiteboard/.mdl` per frame, or one + script per slice? Per frame matches the view; per slice matches how + requirements are planned. +3. **Edits in two places.** When the user edits MDL in the board and the sketch + in the same round, which wins? Proposed: MDL edits win; the agent redraws the + impression from the model and flags the sketch as stale. +4. **Lens scope.** Should the integration lens also read the *runtime* + (configured endpoints, constants per environment), or only the model? The + model cannot tell a mock endpoint from production. +5. **Screenshots and privacy.** App screenshots of a database seeded with real + data would be stored in the artifact's asset store. The default should be + generated test data only; how is that enforced? +6. **Shared boards.** Several people on one board means several writers to the + brain's staging queue, which is local and gitignored. Does the queue move + into the artifact database for shared boards? From c3d2b01d07f39483d1a2f929473607806d0a8d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:16:22 +0000 Subject: [PATCH 02/11] Index the Mendix whiteboard proposal in the proposals README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WGkvWsVVLHHP6JsDmKgztC --- docs/11-proposals/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/11-proposals/README.md b/docs/11-proposals/README.md index 90f440a54..7ebc226a8 100644 --- a/docs/11-proposals/README.md +++ b/docs/11-proposals/README.md @@ -26,7 +26,7 @@ for display in this README): -## Active Proposals (114) +## Active Proposals (115) ### In Progress (partial) (15) @@ -94,7 +94,7 @@ for display in this README): | [VS Code Search — Quick Pick + Workspace Symbol](PROPOSAL_vscode_search.md) | Proposed | Full-text search exists in mxcli (mxcli search) but is only accessible via the terminal. | | [Workflow Improvements: ALTER WORKFLOW + Cross-References](PROPOSAL_workflow_improvements.md) | Proposed | Workflow support in mxcli has full CREATE/DESCRIBE/DROP/SHOW coverage with 13 activity types and BSON round-trip fidelity. | -### Draft (47) +### Draft (48) | Proposal | Status | Summary | |----------|--------|---------| @@ -118,6 +118,7 @@ for display in this README): | [MDL Syntax and Grammar Improvements](PROPOSAL_mdl_syntax_improvements.md) | Draft | This document proposes a set of improvements to the Mendix Definition Language (MDL) syntax and grammar. | | [MDL Syntax Design Guidelines](PROPOSAL_mdl_syntax_design_guidelines.md) | Draft | Related: PROPOSAL_mdl_syntax_improvements.md, PROPOSAL_mdl_syntax_improvements_v2.md | | [MDL Test Framework](proposal-mdl-test-framework.md) | Draft | A testing framework that uses MDL (Mendix Definition Language) as the test definition language, | +| [Mendix whiteboard — sketch an app with an agent, from napkin to running model](PROPOSAL_mendix_whiteboard.md) | Draft | Requirements for a Mendix app are usually worked out at a whiteboard, then | | [Microflow ADD Expression To List](PROPOSAL_microflow_add_expression_to_list.md) | Draft | Status: Draft | | [Microflow Call Web Service Statement](PROPOSAL_microflow_call_web_service_statement.md) | Draft | Status: Draft | | [Microflow CHANGE Refresh Modifier](PROPOSAL_microflow_change_refresh_modifier.md) | Draft | Status: Draft | From e94a6df2c6eb08643c19758bffd02ba9cb990612 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:34:34 +0000 Subject: [PATCH 03/11] fix(alter page): resolve widgets inside Accordion groups (#891) ALTER PAGE could not address a DataGrid 2, or any widget, inside a pluggable widget's object-list item such as an Accordion group or a Pop-up menu item: set (Caption: 'Quantity') on Qty -> widget "Qty" not found ... available columns: Name, Qty set (Caption: 'Quantity') on g1.Qty -> widget "g1" not found findInWidgetChildren descended into a pluggable widget's own widget properties and into DataGrid 2 columns, but not into other object lists, whose items keep widgets at Objects[].Properties[].Value.Widgets. The not-found message is built by a generic walk that does descend, so it listed the columns the resolver had just refused. Add objectListItemValues and use it in the finder (skipping `columns`, which keeps its derived-name-first lookup) and in the widget-scope collector, matching the descent findNearestDSInChildren got for #935. Control: with the fix stashed, the new tests and the unfixed binary fail with the reporter's exact messages; fixed, mx check (11.12.1) reports 0 errors. Refs ako/mxcli#1274 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018d9s3aLWW7MCos2mfNnXYo --- ...lter-page-grid-inside-accordion-group.json | 1 + ...alter-page-grid-inside-accordion-group.mdl | 43 ++++ .../accordion_group_addressing_test.go | 235 ++++++++++++++++++ mdl/backend/pagemutator/mutator.go | 58 ++++- 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-grid-inside-accordion-group.json create mode 100644 mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl create mode 100644 mdl/backend/pagemutator/accordion_group_addressing_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-grid-inside-accordion-group.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-grid-inside-accordion-group.json new file mode 100644 index 000000000..2769a0f97 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-grid-inside-accordion-group.json @@ -0,0 +1 @@ +{"area":"mdl-backend","date":"2026-10-15","symptom":"`alter page … set (Caption: …) on Qty` against a DataGrid 2 inside an **Accordion group** reports `widget \"Qty\" not found … available columns: Name, Qty`, and the qualified `on g1.Qty` reports `widget \"g1\" not found` — the grid and everything else in the group are unaddressable while the error advertises its columns","cause":"`findInWidgetChildren` descended into a pluggable widget's own `Object.Properties[].Value.Widgets` and into the `columns` object list (#834), but not into any OTHER object list — Accordion groups and Pop-up menu items keep their widgets at `Value.Objects[].Properties[].Value.Widgets`. The not-found message comes from `collectColumnNamesBson`, a generic walk of the whole tree, so it saw what the resolver could not. `collectWidgetScopeInChildren` had the same gap; `findNearestDSInChildren` had already been given the descent for #935","file":"`mdl/backend/pagemutator/mutator.go` (`objectListItemValues`, used by `findInWidgetChildren` and `collectWidgetScopeInChildren`)","insight":"Four walkers traverse the same page tree (`findInWidgetChildren`, `collectWidgetScopeInChildren`, `findNearestDSInChildren`, the generic `collectColumnNamesBson`) and each was patched per container, so they disagree: when an error message lists a name the resolver just refused, diff the walker that BUILT THE MESSAGE against the one that resolved — the message is a free oracle for what the resolver should reach. Key the descent on the BSON shape (`Objects[].Properties[].Value.Widgets`), not on a property key like `groups`, so Pop-up menu items are covered too; skip `columns` there so a column's derived name still wins over a same-named widget in its customContent cell. Unit fixture trap: a column type with no `ValueType.Type` makes the caption setter fall to `PrimitiveValue` and `DSet`-append onto a copied `bson.D`, so the write may or may not be visible depending on slice capacity — declare `header` as `TextTemplate`, as the shipped widget does. Control: the unfixed binary refuses with the reporter's exact message; fixed, `mx check` (11.12.1) 0 errors. Repro `mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl`. Issue #891 (ako/mxcli#1274)","refs":["#891","ako/mxcli#1274","#834","#935"]} diff --git a/mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl b/mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl new file mode 100644 index 000000000..bf23692e6 --- /dev/null +++ b/mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl @@ -0,0 +1,43 @@ +mdl 1; +-- upstream #891 (2) / ako/mxcli#1274 — ALTER PAGE could not address a DataGrid 2 +-- (or any widget) inside an Accordion group. +-- +-- The resolver descended into a pluggable widget's own widget properties and +-- into DataGrid 2 columns, but not into other object-list items — an Accordion +-- group, a Pop-up menu item — which keep their widgets one level deeper, at +-- Objects[].Properties[content].Value.Widgets. The not-found message is built +-- from a generic walk that does descend, so it advertised the very columns it +-- had just refused: +-- +-- set (Caption: 'Quantity') on Qty +-- -> widget "Qty" not found ... available columns: Name, Qty +-- set (Caption: 'Quantity') on grid1.Qty +-- -> widget "grid1" not found +-- +-- Verify: +-- mxcli exec 891-alter-page-grid-inside-accordion-group.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe page A1274.AccPage" +-- -- columns now captioned 'Item name' and 'Quantity', the grid's page +-- -- size 5, all still inside the Accordion's group +-- mxcli docker check -p app.mpr + +create module A1274; +create entity A1274.Item ( Name: String(100), Qty: Integer ); +create page A1274.AccPage ( Title: 'Acc', Layout: Atlas_Core.Atlas_Default ) +{ + PLUGGABLEWIDGET 'com.mendix.widget.web.accordion.Accordion' acc1 { + GROUP g1 (Caption: 'Group one') { + DATAGRID grid1 (DataSource: DATABASE A1274.Item) { + COLUMN (Attribute: Name, Caption: 'Name') + COLUMN (Attribute: Qty, Caption: 'Qty') + } + } + } +}; + +-- Qualified column, bare column, and the grid itself — each was "not found". +alter page A1274.AccPage { + set (Caption: 'Item name') on grid1.Name; + set (Caption: 'Quantity') on Qty; + set (PageSize: 5) on grid1; +}; diff --git a/mdl/backend/pagemutator/accordion_group_addressing_test.go b/mdl/backend/pagemutator/accordion_group_addressing_test.go new file mode 100644 index 000000000..2e134000b --- /dev/null +++ b/mdl/backend/pagemutator/accordion_group_addressing_test.go @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/model" +) + +// --------------------------------------------------------------------------- +// #891 / ako/mxcli#1274: the ALTER PAGE resolver did not descend into a +// pluggable widget's object-list items (Accordion groups, Pop-up menu items), +// so a DataGrid 2 inside an Accordion group could not be addressed — neither the +// grid by its name nor its columns by their derived names — while the not-found +// message, built from a generic walk that does descend, listed those very +// columns as available: +// +// set (Caption: 'Quantity') on Qty +// -> widget "Qty" not found ... available columns: Name, Qty +// set (Caption: 'Quantity') on g1.Qty +// -> widget "g1" not found +// --------------------------------------------------------------------------- + +// accordionWithGroupContent builds the minimal Accordion BSON the resolver has +// to traverse: a `groups` object list whose single group keeps its widgets at +// Objects[].Properties[content].Value.Widgets — the same depth a DataGrid 2 +// column's customContent sits at, but under a property that is not `columns`. +func accordionWithGroupContent(groupWidgets ...bson.D) bson.D { + groupsID := idBin(0x30) + contentID := idBin(0x31) + + widgets := bson.A{int32(2)} + for _, w := range groupWidgets { + widgets = append(widgets, w) + } + group := bson.D{ + {Key: "$Type", Value: objectListItemType}, + {Key: "Properties", Value: bson.A{ + int32(2), + bson.D{ + {Key: "TypePointer", Value: contentID}, + {Key: "Value", Value: bson.D{{Key: "Widgets", Value: widgets}}}, + }, + }}, + } + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: "accordion1"}, + {Key: "Type", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{ + {Key: "$ID", Value: groupsID}, + {Key: "PropertyKey", Value: "groups"}, + {Key: "ValueType", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{{Key: "$ID", Value: contentID}, {Key: "PropertyKey", Value: "content"}}, + }}, + }}, + }}, + }, + }}, + }}, + }}, + {Key: "Object", Value: bson.D{ + {Key: "Properties", Value: bson.A{ + int32(2), + bson.D{ + {Key: "TypePointer", Value: groupsID}, + {Key: "Value", Value: bson.D{{Key: "Objects", Value: bson.A{int32(2), group}}}}, + }, + }}, + }}, + } +} + +// gridWithCaptionColumns builds a DataGrid 2 whose columns are addressed by +// caption, with `header` declared as a TextTemplate the way the shipped widget +// declares it — so the caption setter edits the stored translation in place. +func gridWithCaptionColumns(name string, captions ...string) bson.D { + colsID := idBin(0x50) + headerID := idBin(0x51) + cols := bson.A{int32(2)} + for _, c := range captions { + cols = append(cols, bson.D{ + {Key: "$Type", Value: objectListItemType}, + {Key: "Properties", Value: bson.A{ + int32(2), + bson.D{ + {Key: "TypePointer", Value: headerID}, + {Key: "Value", Value: bson.D{ + {Key: "TextTemplate", Value: bson.D{ + {Key: "Template", Value: bson.D{ + {Key: "Items", Value: bson.A{int32(3), bson.D{ + {Key: "$Type", Value: "Texts$Translation"}, + {Key: "LanguageCode", Value: model.AuthoringLanguage()}, + {Key: "Text", Value: c}, + }}}, + }}, + }}, + }}, + }, + }}, + }) + } + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: name}, + {Key: "Type", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{ + {Key: "$ID", Value: colsID}, + {Key: "PropertyKey", Value: "columns"}, + {Key: "ValueType", Value: bson.D{ + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{ + {Key: "$ID", Value: headerID}, + {Key: "PropertyKey", Value: "header"}, + {Key: "ValueType", Value: bson.D{{Key: "Type", Value: "TextTemplate"}}}, + }, + }}, + }}, + }}, + }, + }}, + }}, + }}, + {Key: "Object", Value: bson.D{ + {Key: "Properties", Value: bson.A{ + int32(2), + bson.D{ + {Key: "TypePointer", Value: colsID}, + {Key: "Value", Value: bson.D{{Key: "Objects", Value: cols}}}, + }, + }}, + }}, + } +} + +func accordionGridPage() bson.D { + grid := gridWithCaptionColumns("g1", "Name", "Qty") + return pageWith(accordionWithGroupContent(grid)) +} + +// treeHolds reports whether a string value equal to want sits anywhere in the +// tree — enough to show the write landed in the stored page, under the +// Accordion, rather than in a detached copy. +func treeHolds(v any, want string) bool { + switch x := v.(type) { + case string: + return x == want + case bson.D: + for _, e := range x { + if treeHolds(e.Value, want) { + return true + } + } + case bson.A: + for _, e := range x { + if treeHolds(e, want) { + return true + } + } + } + return false +} + +// The grid itself must be reachable by name through the Accordion group. +func TestFindBsonWidget_GridInsideAccordionGroup(t *testing.T) { + if res := findBsonWidget(accordionGridPage(), "g1"); res == nil { + t.Fatal(`widget "g1" inside an Accordion group was not found`) + } +} + +// The qualified form: `set (Caption: 'Quantity') on g1.Qty`. +func TestSetColumnProperty_GridInsideAccordionGroup(t *testing.T) { + m := &Mutator{rawData: accordionGridPage(), widgetFinder: findBsonWidget} + if err := m.SetColumnProperty("g1", "Qty", "Caption", "Quantity"); err != nil { + t.Fatalf("set Caption on g1.Qty: %v", err) + } + if !treeHolds(m.rawData, "Quantity") { + t.Error("the caption write did not land in the stored page") + } +} + +// The bare form: `set (Caption: 'Quantity') on Qty` — the message used to list +// Qty as available and then report it not found. +func TestSetWidgetProperty_ColumnInsideAccordionGroup(t *testing.T) { + m := &Mutator{rawData: accordionGridPage(), widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("Qty", "Caption", "Quantity"); err != nil { + t.Fatalf("set Caption on Qty: %v", err) + } + if !treeHolds(m.rawData, "Quantity") { + t.Error("the caption write did not land in the stored page") + } +} + +// A plain widget inside a group (not a grid) is the same descent, and the +// widget scope `check` uses must see it too. +func TestAccordionGroupWidgets_InFinderAndScope(t *testing.T) { + txt := bson.D{ + {Key: "$Type", Value: "Forms$DynamicText"}, + {Key: "$ID", Value: idBin(0x40)}, + {Key: "Name", Value: "txtInGroup"}, + } + raw := pageWith(accordionWithGroupContent(txt)) + if res := findBsonWidget(raw, "txtInGroup"); res == nil { + t.Fatal(`widget "txtInGroup" inside an Accordion group was not found`) + } + scope := extractWidgetScopeFromBSON(raw) + if _, ok := scope["txtInGroup"]; !ok { + t.Errorf("widget scope misses txtInGroup: %v", scope) + } +} + +// A miss must still report the addressable names — the descent must not turn a +// mistyped column into a silent no-op. +func TestSetWidgetProperty_AccordionGroupMissStillListsColumns(t *testing.T) { + m := &Mutator{rawData: accordionGridPage(), widgetFinder: findBsonWidget} + err := m.SetWidgetProperty("Qtyy", "Caption", "x") + if err == nil || !strings.Contains(err.Error(), "Qty") { + t.Fatalf("expected a not-found listing Qty, got %v", err) + } +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 1f7130441..eaafe0883 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -1557,8 +1557,31 @@ func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { } } } - // DataGrid2: search columns by derived name (stored in Objects, not Widgets) propKeyMap := buildPropKeyMap(wDoc) + // Object-list items other than DataGrid2 columns — an Accordion + // group, a Pop-up menu item — keep their widgets one level deeper, + // at Value.Objects[].Properties[].Value.Widgets. Without this a grid + // inside an Accordion group could not be addressed at all, neither + // by its name nor by its columns', while the not-found message (a + // generic walk) listed those columns as available (#891). Columns + // are left to the loop below, which matches a column's derived name + // before descending into its content. + for _, prop := range props { + propDoc, ok := prop.(bson.D) + if !ok { + continue + } + typePointerID := bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(propDoc, "TypePointer")) + if propKeyMap[typePointerID] == "columns" { + continue + } + for _, itemVal := range objectListItemValues(bsonnav.DGetDoc(propDoc, "Value")) { + if result := findInWidgetArray(itemVal, "Widgets", widgetName); result != nil { + return result + } + } + } + // DataGrid2: search columns by derived name (stored in Objects, not Widgets) for _, prop := range props { propDoc, ok := prop.(bson.D) if !ok { @@ -1619,6 +1642,33 @@ func findInWidgetChildren(wDoc bson.D, widgetName string) *bsonWidgetResult { return nil } +// objectListItemValues returns the property Values of every item in a pluggable +// property's object list (Value.Objects[].Properties[].Value) — the documents +// that hold an item's widget slots. nil-safe: a property with no Value, or one +// that is not an object list, yields nothing. +func objectListItemValues(valDoc bson.D) []bson.D { + if valDoc == nil { + return nil + } + var out []bson.D + for _, item := range bsonnav.DGetArrayElements(bsonnav.DGet(valDoc, "Objects")) { + itemDoc, ok := item.(bson.D) + if !ok { + continue + } + for _, itemProp := range bsonnav.DGetArrayElements(bsonnav.DGet(itemDoc, "Properties")) { + itemPropDoc, ok := itemProp.(bson.D) + if !ok { + continue + } + if v := bsonnav.DGetDoc(itemPropDoc, "Value"); v != nil { + out = append(out, v) + } + } + } + return out +} + // --------------------------------------------------------------------------- // DataGrid2 column finder // --------------------------------------------------------------------------- @@ -2472,6 +2522,12 @@ func collectWidgetScopeInChildren(wDoc bson.D, scope map[string]model.ID) { } if valDoc := bsonnav.DGetDoc(propDoc, "Value"); valDoc != nil { collectWidgetScope(valDoc, "Widgets", scope) + // Widgets inside object-list items: Accordion groups, Pop-up + // menu items, customContent columns — all resolvable by the + // finder, so they are in scope too (#891). + for _, itemVal := range objectListItemValues(valDoc) { + collectWidgetScope(itemVal, "Widgets", scope) + } } } // DataGrid2: add column derived names to scope for duplicate-name detection From 09901ff109d472a1c4d19c6355cb0eb913c9d3ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:41:56 +0000 Subject: [PATCH 04/11] build: give sync-mods the cp fallback sync-skills already has sync-mods called rsync unconditionally, so `make build` failed with Error 127 on a machine without rsync, while sync-skills and sync-skill-packs fall back to rm + cp -R. The mods sync also excludes tests, type stubs and tsconfig, so its fallback prunes those after the copy. Verified with rsync hidden from PATH: the fallback's tree is identical (diff -r) to rsync's, including with each excluded pattern planted in mods/, and a stale file in the target is removed as --delete would. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018d9s3aLWW7MCos2mfNnXYo --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1acb591ac..12f80861e 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,10 @@ sync-skill-packs: sync-mods: @mkdir -p cmd/mxcli/mods @rsync -a --delete --exclude='.DS_Store' --exclude='tests/' --exclude='*.test.ts' --exclude='*.test.tsx' \ - --exclude='.claude-plugin/types/' --exclude='tsconfig.json' mods/ cmd/mxcli/mods/ + --exclude='.claude-plugin/types/' --exclude='tsconfig.json' mods/ cmd/mxcli/mods/ 2>/dev/null \ + || { rm -rf cmd/mxcli/mods && mkdir -p cmd/mxcli/mods && cp -R mods/. cmd/mxcli/mods/ \ + && find cmd/mxcli/mods \( -type d -name tests -o -path '*/.claude-plugin/types' \) -prune -exec rm -rf {} + \ + && find cmd/mxcli/mods \( -name '*.test.ts' -o -name '*.test.tsx' -o -name tsconfig.json -o -name .DS_Store \) -delete; } # Sync commands from .claude/commands/mendix to cmd/mxcli/commands for embedding sync-commands: From 8d4aa86892f1824f434fee473bd423447f44bad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:43:32 +0000 Subject: [PATCH 05/11] test: regression case for describe dropping a combobox's association binding (ako/mxcli#1291) `describe page` printed an association combo box with an On change microflow without `Attribute:` or `CaptionAttribute:` on nightly-20260927-95091765. Already fixed on main by 64a38bf6 (the generic pluggable branch's OnChange term gated with !isKnownCustomWidgetType), but that fix landed in a merge resolution with no bug-test script. Adds the issue's shape as mdl-examples/bug-tests (0 errors on 11.12.1; with the guard reverted the re-executed description fails CE0642 'Entity' and 'Caption' required) and a finding on the triage route. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XmLAPLPxDSCeeF5hfTt1KH --- ...nchange-binding-fixed-in-merge-commit.json | 1 + ...combobox-association-onchange-describe.mdl | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-onchange-binding-fixed-in-merge-commit.json create mode 100644 mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-onchange-binding-fixed-in-merge-commit.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-onchange-binding-fixed-in-merge-commit.json new file mode 100644 index 000000000..12b57b963 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-onchange-binding-fixed-in-merge-commit.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-15","symptom":"`describe page` of an association combo box with an On change microflow printed Label/DataSource/OnChange but \"No `Attribute:` and no `CaptionAttribute:`\" (nightly-20260927-95091765, Mendix 11.12.1); re-executing the description writes an unbound combo box, and mx check then reports CE0642 \"Property 'Entity' is required.\" and \"Property 'Caption' is required.\"","cause":"Same as the 2026-10-02 finding: `w.OnChange != \"\"` in the generic pluggable branch's guard captured the combo box ahead of its own branch. Already fixed on main by the time the mirror was triaged, but the fix arrived inside a merge commit's conflict resolution (64a38bf6), so `git log --no-merges -S` finds nothing and the issue looked live","file":"`mdl/executor/cmd_pages_describe_output.go` (`outputWidgetMDLV3`, generic pluggable branch guard); test `cmd_pages_describe_combobox_onchange_test.go`","insight":"**When a report does not reproduce on main, build the reporter's own commit before theorising** (`git worktree add ` + `make build`, about two minutes). Here the reporter's 'Studio Pro-authored' framing led toward storage differences (EntityRef shape, PropertyTypes nesting, widget version), and every one checked out identical; the nightly binary reproduced it in one run on mxcli-authored BSON, and authoring was irrelevant. The tell was in the output itself: DataSource and OnChange survived while both mode-gated fields vanished together, which points at the emitter branch, not the reader. A fix made in a merge resolution is invisible to `--no-merges` history search, so search with merges included (`git log -S` without `--no-merges`) before concluding there is no fix. While verifying, a separate defect turned up: `OnChange: call microflow M.F(P = $dataView1)` on a pluggable widget checks clean and fails mx check CE0117, while `$currentObject` is clean. The regression script uses `$currentObject` so it does not carry that defect","refs":["ako/mxcli#1291","mendixlabs/mxcli#1236"],"ce":["CE0642","CE0117"],"test":"mdl/executor/cmd_pages_describe_combobox_onchange_test.go TestDescribeComboboxWithOnChangeKeepsItsBinding; mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl"} diff --git a/mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl b/mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl new file mode 100644 index 000000000..f43e8dca7 --- /dev/null +++ b/mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl @@ -0,0 +1,53 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#1291 (mendixlabs/mxcli#1236): describe dropped an association +-- ComboBox's Attribute: and CaptionAttribute: when it had an OnChange +-- ============================================================================ +-- +-- Symptom (nightly-20260927-95091765, Mendix 11.12.1): +-- combobox comboBox1 (Label: 'Customer', DataSource: database from +-- MyModule.Customer where [Active = true] sort by Name asc, +-- OnChange: microflow MyModule.ACT_Order_CustomerChanged, ...) +-- "No `Attribute:` and no `CaptionAttribute:`" — though both are stored, so +-- re-executing the description writes an unbound combo box, silently. +-- +-- Cause: `w.OnChange != ""` in the generic pluggable branch's has-content +-- guard (added for Slider/StarRating) captured the combo box ahead of its own +-- branch, and the generic branch never emits Content or CaptionAttribute. +-- Not specific to Studio Pro authoring: this script's own output reproduces it +-- on 95091765. The write side was always correct. +-- +-- Fix: the OnChange term is gated with !isKnownCustomWidgetType (already on +-- main; TestDescribeComboboxWithOnChangeKeepsItsBinding). +-- +-- Verify: exec, then the describe below must show `Attribute:` and +-- `CaptionAttribute:` next to `OnChange:`; describe → exec reports +-- "Unchanged page"; `mxcli docker check` 0 errors (11.12.1). With the guard +-- reverted, the re-executed description fails CE0642 "Property 'Entity' is +-- required." and "Property 'Caption' is required." on comboBox1. +-- ============================================================================ + +create persistent entity MyFirstModule.Customer ( Name: String(200), Active: Boolean default true ); +create persistent entity MyFirstModule.CustomerOrder ( Number: Integer ); +create association MyFirstModule.CustomerOrder_Customer from MyFirstModule.CustomerOrder to MyFirstModule.Customer; + +create or modify microflow MyFirstModule.ACT_CustomerOrder_CustomerChanged ($CustomerOrder: MyFirstModule.CustomerOrder) +begin +end; + +create or modify page MyFirstModule.CustomerOrder_Edit +( Title: 'Order', Layout: Atlas_Core.Atlas_Default, Params: ( $CustomerOrder: MyFirstModule.CustomerOrder ) ) +{ + dataview dataView1 (datasource: $CustomerOrder) { + combobox comboBox1 ( + Label: 'Customer', + Attribute: CustomerOrder_Customer, + DataSource: database from MyFirstModule.Customer where [Active = true] sort by Name asc, + CaptionAttribute: Name, + OnChange: call microflow MyFirstModule.ACT_CustomerOrder_CustomerChanged(CustomerOrder = $currentObject), + DesignProperties: ['Align self': 'Right'] + ) + } +}; + +describe page MyFirstModule.CustomerOrder_Edit; From 4b0242e6fd64e1c364292df6437845653008bb65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:54:03 +0000 Subject: [PATCH 06/11] test: write #1291's bug-test DesignProperties in the canonical ( ) form The bracketed spelling is MDL-DEPR125 and failed the conformance gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XmLAPLPxDSCeeF5hfTt1KH --- .../bug-tests/1291-combobox-association-onchange-describe.mdl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl b/mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl index f43e8dca7..79b1dadbd 100644 --- a/mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl +++ b/mdl-examples/bug-tests/1291-combobox-association-onchange-describe.mdl @@ -45,7 +45,7 @@ create or modify page MyFirstModule.CustomerOrder_Edit DataSource: database from MyFirstModule.Customer where [Active = true] sort by Name asc, CaptionAttribute: Name, OnChange: call microflow MyFirstModule.ACT_CustomerOrder_CustomerChanged(CustomerOrder = $currentObject), - DesignProperties: ['Align self': 'Right'] + DesignProperties: ('Align self': 'Right') ) } }; From 286bd439ee86995401976d58f128a5ede7a8a06b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:55:41 +0000 Subject: [PATCH 07/11] fix(alter page): SET Label no longer reports success while storing nothing mendixlabs/mxcli#292: "ALTER PAGE SET Title / SET Label silently succeeds but change does not persist". Title and an already-shown label were fixed earlier; setWidgetLabelMut still returned a bare nil for two shapes: - an input widget that shows no label stores `LabelTemplate: null`; the label is now created as the same Forms$ClientTemplate create page writes - a widget with no label slot (a button), or a pluggable widget whose LabelTemplate is null, is refused: "widget has no Label property" The refusal also reaches `mxcli check --references` through the setter probe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CS3jxiq2vnQdHsNSQMqUtV --- ...e-set-label-null-template-silent-noop.json | 1 + .../292-alter-page-set-label-silent-noop.mdl | 36 ++++++++ mdl/backend/pagemutator/mutator.go | 29 ++++++- mdl/backend/pagemutator/set_label_test.go | 87 +++++++++++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-set-label-null-template-silent-noop.json create mode 100644 mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl create mode 100644 mdl/backend/pagemutator/set_label_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-set-label-null-template-silent-noop.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-set-label-null-template-silent-noop.json new file mode 100644 index 000000000..ea70b4aef --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-page-set-label-null-template-silent-noop.json @@ -0,0 +1 @@ +{"area": "mdl-backend", "date": "2026-10-15", "symptom": "`alter page … { set Label = 'X' on }` prints \"Altered page\" and DESCRIBE still shows the old (or no) label — on an input widget that shows no label, and on a button", "cause": "setWidgetLabelMut fell through to `return nil` when the widget had neither a LabelTemplate document nor a legacy Label/Caption. A label-less input stores `LabelTemplate: null` (codec NullFields), so DGetDoc returns nil and nothing is written; a button has no label slot at all and got the same silent nil", "file": "`mdl/backend/pagemutator/mutator.go` (`setWidgetLabelMut`)", "insight": "The earlier half-fix (#292 Title + LabelTemplate doc) only tested a widget that already HAD a label — the shape that worked. Test the null slot and the no-slot widget, not just the populated one: every `DGetDoc(...) != nil` guard that ends in `return nil` is a silent no-op for the null case. Null LabelTemplate on a built-in input gets the same ClientTemplate create page writes (bson dump identical); on a CustomWidget it is refused, because every pluggable widget writes `LabelTemplate: null` whether or not its definition declares a Label system property. The refusal also surfaces in `check --references` via the setter probe", "refs": ["mendixlabs/mxcli#292", "ako/mxcli#1235"]} diff --git a/mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl b/mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl new file mode 100644 index 000000000..ff98ba202 --- /dev/null +++ b/mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl @@ -0,0 +1,36 @@ +mdl 1; +-- Bug: ALTER PAGE SET Label reported "Altered page" and changed nothing. +-- Upstream mendixlabs/mxcli#292: +-- +-- `ALTER PAGE SET Title` and `SET Label` (on input widgets) return +-- `Altered page` but the change is not saved. The next `DESCRIBE PAGE` +-- still shows the original value. +-- +-- Title and a label that is already shown were fixed earlier. Two shapes still +-- answered with a bare nil: +-- * an input widget that shows no label (`LabelTemplate: null`) — the label +-- is now created, the same ClientTemplate `create page` writes; +-- * a widget with no label slot at all (a button) — now refused: +-- set Label = 'X' on btnSave; +-- -> failed to set Label on btnSave: widget has no Label property +-- +-- Expected: `mxcli check` passes; after exec, DESCRIBE shows Title 'New title', +-- textBox1 Label 'New label', and tbNoLabel Label 'Added'. + +create module M292; +create persistent entity M292.Thing ( Name: string(200), Code: string(50) ); + +create or modify page M292.P_Edit (Title: 'Old title', Layout: Atlas_Core.Atlas_Default, Params: { $Thing: M292.Thing }) +{ + dataview dv (DataSource: $Thing) { + textbox textBox1 (Label: 'Old label', Attribute: Name) + textbox tbNoLabel (Attribute: Code) + actionbutton btnSave (Caption: 'Save', Action: SAVE_CHANGES) + } +}; + +alter page M292.P_Edit { + set Title = 'New title'; + set Label = 'New label' on textBox1; + set Label = 'Added' on tbNoLabel +}; diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 1f7130441..e5faac815 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -14,6 +14,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" + "github.com/mendixlabs/mxcli/mdl/backend/widgetobj" "github.com/mendixlabs/mxcli/mdl/bsonutil" "github.com/mendixlabs/mxcli/mdl/types" "github.com/mendixlabs/mxcli/model" @@ -3481,8 +3482,13 @@ func setWidgetContentMut(widget bson.D, value any) error { // Forms$ClientTemplate under LabelTemplate and writes no `Label` key at all; // the legacy shape is a Label document whose Caption is a Texts$Text. Looking // only for `Label` made `set Label = …` report success and store nothing on -// every current input widget. A widget with neither (no label shown, or a type -// that has none) still returns nil, as it always did. +// every current input widget. +// +// The two remaining shapes answered with a bare nil too, which kept +// mendixlabs/mxcli#292 open: an input whose label is not shown stores +// `LabelTemplate: null`, so the label is created — the same ClientTemplate +// create page writes; and a widget with no label slot at all (a button) is +// refused by name, as Caption is, instead of reporting "Altered page". func setWidgetLabelMut(widget bson.D, value any) error { if tmpl := bsonnav.DGetDoc(widget, "LabelTemplate"); tmpl != nil { return setTranslatableText(tmpl, "LabelTemplate", value) @@ -3492,7 +3498,24 @@ func setWidgetLabelMut(widget bson.D, value any) error { return setTranslatableText(caption, "Label caption", value) } } - return nil + // A pluggable widget writes `LabelTemplate: null` whether or not its + // definition declares a Label system property (an Accordion has none), so a + // null there is no evidence a label may be added — refuse rather than guess. + if hasKey(widget, "LabelTemplate") && bsonnav.DGetString(widget, "$Type") != "CustomWidgets$CustomWidget" { + text, ok := value.(string) + if !ok { + return fmt.Errorf("Label value must be a string") + } + if text == "" { + return nil // no label shown, and none asked for + } + bsonnav.DSet(widget, "LabelTemplate", widgetobj.BuildClientTemplateWithTextAndParams(text, nil)) + return nil + } + if bsonnav.DGetString(widget, "$Type") == "CustomWidgets$CustomWidget" { + return fmt.Errorf("widget has no Label property to set: this pluggable widget shows no label") + } + return fmt.Errorf("widget has no Label property") } func setWidgetAttributeRefMut(widget bson.D, value any) error { diff --git a/mdl/backend/pagemutator/set_label_test.go b/mdl/backend/pagemutator/set_label_test.go new file mode 100644 index 000000000..7eaee7486 --- /dev/null +++ b/mdl/backend/pagemutator/set_label_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// mendixlabs/mxcli#292: "ALTER PAGE SET Title / SET Label silently succeeds but +// change does not persist". Title and a shown label were fixed earlier; these are +// the two shapes setWidgetLabelMut still answered with a bare nil — "Altered +// page", model unchanged. + +// An input widget whose label is not shown stores `LabelTemplate: null` (the +// codec's registered null slot; Studio Pro writes the key on every input). SET +// Label has to create the template, not skip the widget. +func TestSetLabel_NullLabelTemplate_CreatesTemplate(t *testing.T) { + withAuthoringLanguage(t, "en_US") + tb := bson.D{ + {Key: "$Type", Value: "Forms$TextBox"}, + {Key: "Name", Value: "tbNoLabel"}, + {Key: "LabelTemplate", Value: nil}, + } + raw := makeRawPage(tb) + m := &Mutator{rawData: raw, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("tbNoLabel", "Label", "Added"); err != nil { + t.Fatalf("set Label on a label-less textbox: %v", err) + } + tmpl := bsonnav.DGetDoc(findBsonWidget(raw, "tbNoLabel").widget, "LabelTemplate") + if tmpl == nil { + t.Fatal("LabelTemplate still null after set Label: the label was silently dropped") + } + if typ := bsonnav.DGetString(tmpl, "$Type"); typ != "Forms$ClientTemplate" { + t.Fatalf("LabelTemplate $Type = %q, want Forms$ClientTemplate", typ) + } + if got := translationsOf(t, bsonnav.DGetDoc(tmpl, "Template")); got["en_US"] != "Added" || len(got) != 1 { + t.Fatalf("Template translations = %v, want en_US=Added", got) + } + // The shape create page writes: Fallback an empty Texts$Text, Parameters + // the marker-2 empty list. + if fb := bsonnav.DGetDoc(tmpl, "Fallback"); fb == nil || len(translationsOf(t, fb)) != 0 { + t.Fatalf("Fallback = %v, want an empty Texts$Text", fb) + } + if p, ok := bsonnav.DGet(tmpl, "Parameters").(bson.A); !ok || len(p) != 1 || p[0] != int32(2) { + t.Fatalf("Parameters = %v, want [2]", bsonnav.DGet(tmpl, "Parameters")) + } +} + +// A widget with no label slot at all (a button) used to report success with +// nothing written. It is refused by name, the way Caption is. +func TestSetLabel_WidgetWithoutLabel_IsRefused(t *testing.T) { + btn := bson.D{ + {Key: "$Type", Value: "Forms$ActionButton"}, + {Key: "Name", Value: "btnSave"}, + } + m := &Mutator{rawData: makeRawPage(btn), widgetFinder: findBsonWidget} + err := m.SetWidgetProperty("btnSave", "Label", "X") + if err == nil { + t.Fatal("set Label on a button returned nil; it stored nothing") + } + if !strings.Contains(err.Error(), "no Label") { + t.Fatalf("error = %v, want it to say the widget has no Label", err) + } +} + +// Every pluggable widget stores `LabelTemplate: null`, including ones whose +// definition has no Label system property, so the null is not license to add one. +func TestSetLabel_PluggableWithoutLabel_IsRefused(t *testing.T) { + cw := bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: "accordion1"}, + {Key: "LabelTemplate", Value: nil}, + } + raw := makeRawPage(cw) + m := &Mutator{rawData: raw, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("accordion1", "Label", "X"); err == nil { + t.Fatal("set Label on a label-less pluggable widget returned nil") + } + if v := bsonnav.DGet(findBsonWidget(raw, "accordion1").widget, "LabelTemplate"); v != nil { + t.Fatalf("LabelTemplate written on a refused set: %v", v) + } +} From a88e9f852377caa6d639c56c43c49cc26c5e7e3e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:04:22 +0000 Subject: [PATCH 08/11] fix: ALTER PAGE SET writes a textbox's Placeholder, Password and Validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `alter page … set Placeholder = '…' on ` was refused with "property \"Placeholder\" is not a property of this built-in widget", though CREATE writes `Placeholder:` and DESCRIBE prints it (mendixlabs/mxcli#414). Password, Validation and ValidationMessage — the textbox's other CREATE-only keys — fell through to the same refusal, because setRawWidgetPropertyMut had no case for any of them. setInputWidgetPropertyMut writes them into the stored shape CREATE uses (PlaceholderTemplate, IsPasswordBox, Forms$WidgetValidation), guarded on the stored key so a pluggable widget's own `placeholder`/`validation` keeps the pluggable route and a widget without one keeps the refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RJXsMAJtFtFdSC5ZqfvLE7 --- ...xtbox-placeholder-password-validation.json | 1 + docs-site/src/language/alter-page.md | 4 + ...lter-page-set-textbox-input-properties.mdl | 40 +++++++ mdl/backend/pagemutator/mutator.go | 65 +++++++++++ mdl/backend/pagemutator/textbox_set_test.go | 103 ++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 .claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-set-textbox-placeholder-password-validation.json create mode 100644 mdl-examples/bug-tests/414-alter-page-set-textbox-input-properties.mdl create mode 100644 mdl/backend/pagemutator/textbox_set_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-set-textbox-placeholder-password-validation.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-set-textbox-placeholder-password-validation.json new file mode 100644 index 000000000..0a8fa327f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-set-textbox-placeholder-password-validation.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-15", "symptom": "`alter page … set Placeholder = '…' on ` refused with `property \"Placeholder\" is not a property of this built-in widget`, though CREATE writes `Placeholder:` and DESCRIBE prints it; `Password`, `Validation` and `ValidationMessage` on a textbox refused the same way (mendixlabs/mxcli#414)", "cause": "setRawWidgetPropertyMut had no placeholder/password/validation/validationmessage case, so the keys fell through to the pluggable setter, which fails at noPluggableObjectError on any built-in widget", "file": "`mdl/backend/pagemutator/mutator.go` (setInputWidgetPropertyMut)", "insight": "The fourth instance of #1464's recipe (Image, Tooltip, RenderMode before it): diff buildTextBoxV3's GetStringProp/lookupPropCI keys against the SET switch and every CREATE-only key falls out at once — the issue named one (Placeholder), the diff found three more. Guard on the stored key (PlaceholderTemplate, a bool IsPasswordBox, a Forms$WidgetValidation), not the property name, so a pluggable widget's own `placeholder`/`validation` keeps the pluggable route and a checkbox keeps the built-in refusal. Placeholder is a Forms$ClientTemplate like LabelTemplate, so setTranslatableText covers it; the triage note's `set Label` no-op was already fixed by the LabelTemplate branch", "refs": ["mendixlabs/mxcli#414", "mendixlabs/mxcli#1464", "mendixlabs/mxcli#292"]} diff --git a/docs-site/src/language/alter-page.md b/docs-site/src/language/alter-page.md index 85c730384..c4c1c7ab0 100644 --- a/docs-site/src/language/alter-page.md +++ b/docs-site/src/language/alter-page.md @@ -41,6 +41,10 @@ ALTER PAGE Module.EditPage { |----------|-------------|---------| | `Caption` | Button/link caption | `SET (Caption: 'Submit') ON btnSave` | | `Label` | Input field label | `SET (Label: 'Full Name') ON txtName` | +| `Placeholder` | Input hint text (text box, text area, date picker) | `SET (Placeholder: 'Enter your name') ON txtName` | +| `Password` | Mask a text box's input | `SET (Password: true) ON txtPin` | +| `Validation` | A text box's validation expression | `SET (Validation: '$value != empty') ON txtName` | +| `ValidationMessage` | Message shown when the validation fails | `SET (ValidationMessage: 'Name is required') ON txtName` | | `ButtonStyle` | Button visual style | `SET (ButtonStyle: Danger) ON btnDelete` | | `Class` | CSS class names | `SET (Class: 'card p-3') ON cMain` | | `Style` | Inline CSS | `SET (Style: 'margin: 8px;') ON cBox` | diff --git a/mdl-examples/bug-tests/414-alter-page-set-textbox-input-properties.mdl b/mdl-examples/bug-tests/414-alter-page-set-textbox-input-properties.mdl new file mode 100644 index 000000000..ce726c5c3 --- /dev/null +++ b/mdl-examples/bug-tests/414-alter-page-set-textbox-input-properties.mdl @@ -0,0 +1,40 @@ +mdl 1; +-- #414: `ALTER PAGE … SET` worked for a few properties only. `set Placeholder` +-- on a text box was refused with "property \"Placeholder\" is not a property of +-- this built-in widget", though CREATE writes `Placeholder:` and DESCRIBE prints +-- it. The same held for the text box's other own properties CREATE writes — +-- Password, Validation, ValidationMessage: setRawWidgetPropertyMut had no case +-- for them, so they fell through to the pluggable setter's refusal. +-- +-- Expected: the describe below shows the text box with Placeholder: 'Enter your +-- name', Password: true, Validation: '$value != empty' and ValidationMessage: +-- 'Name is required', and the label 'Full name'. + +create module Bug414; + +create persistent entity Bug414.Person (Name: string(200)); + +create page Bug414.PersonEdit ( + title: 'Person', + layout: Atlas_Core.Atlas_Default, + params: ( $Person: Bug414.Person ) +) { + layoutgrid lg { + row { + column (desktopwidth: autofill) { + dataview dv (DataSource: $Person) { + textbox txtName (Label: 'Name', Attribute: Name) + actionbutton btnSave (Caption: 'Save', Action: save changes) + } + } + } + } +}; + +alter page Bug414.PersonEdit { + set (Placeholder: 'Enter your name', Password: true) on txtName; + set (Validation: '$value != empty', ValidationMessage: 'Name is required') on txtName; + set (Label: 'Full name') on txtName +}; + +describe page Bug414.PersonEdit; diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 1f7130441..62e3dfaaf 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -2997,6 +2997,14 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { return setWidgetLabelMut(widget, value) case "tooltip": return setWidgetTooltipMut(widget, value) + case "placeholder", "password", "validation", "validationmessage": + // A built-in input widget's own properties, which CREATE writes and + // DESCRIBE prints (mendixlabs/mxcli#414). A widget that does not store + // the key — a pluggable one above all — keeps its old route. + if handled, err := setInputWidgetPropertyMut(widget, propName, value); handled { + return err + } + return setPluggableWidgetPropertyMut(widget, propName, value) case "buttonstyle": if s, ok := value.(string); ok { bsonnav.DSet(widget, "ButtonStyle", s) @@ -3469,6 +3477,63 @@ func setWidgetTooltipMut(widget bson.D, value any) error { return nil } +// setInputWidgetPropertyMut sets one of the built-in input properties CREATE +// writes on a text box (and, for Placeholder, a text area or date picker): +// +// - Placeholder → PlaceholderTemplate, a Forms$ClientTemplate like LabelTemplate +// - Password → IsPasswordBox, a boolean +// - Validation / ValidationMessage → the Forms$WidgetValidation's Expression +// and its Message Texts$Text +// +// They all fell through to the pluggable setter, which refused them as "not a +// property of this built-in widget" (mendixlabs/mxcli#414). It reports +// handled=false when the widget does not store the key, so a pluggable +// widget's own `placeholder` or `validation` still reaches its setter and a +// built-in widget without one keeps that refusal. +func setInputWidgetPropertyMut(widget bson.D, propName string, value any) (bool, error) { + typ := widgetTypeLabel(bsonnav.DGetString(widget, "$Type")) + switch strings.ToLower(propName) { + case "placeholder": + tmpl := bsonnav.DGetDoc(widget, "PlaceholderTemplate") + if tmpl == nil { + return false, nil + } + return true, setTranslatableText(tmpl, "Placeholder", value) + case "password": + if _, ok := bsonnav.DGet(widget, "IsPasswordBox").(bool); !ok { + return false, nil + } + b, ok := editableBool(value) + if !ok { + return true, fmt.Errorf("Password on a %s is true or false, not %v", typ, value) + } + bsonnav.DSet(widget, "IsPasswordBox", b) + return true, nil + case "validation": + v := bsonnav.DGetDoc(widget, "Validation") + if v == nil || bsonnav.DGetString(v, "$Type") != "Forms$WidgetValidation" { + return false, nil + } + s, ok := value.(string) + if !ok { + return true, fmt.Errorf("Validation on a %s is an expression string, not %v", typ, value) + } + bsonnav.DSet(v, "Expression", s) + return true, nil + case "validationmessage": + v := bsonnav.DGetDoc(widget, "Validation") + if v == nil || bsonnav.DGetString(v, "$Type") != "Forms$WidgetValidation" { + return false, nil + } + msg := bsonnav.DGetDoc(v, "Message") + if msg == nil { + return true, fmt.Errorf("the validation of this %s has no Message text to set", typ) + } + return true, setTranslatableText(msg, "ValidationMessage", value) + } + return false, nil +} + func setWidgetContentMut(widget bson.D, value any) error { content := bsonnav.DGetDoc(widget, "Content") if content == nil { diff --git a/mdl/backend/pagemutator/textbox_set_test.go b/mdl/backend/pagemutator/textbox_set_test.go new file mode 100644 index 000000000..657b65491 --- /dev/null +++ b/mdl/backend/pagemutator/textbox_set_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// textBoxAsCreated is a Forms$TextBox in the shape CREATE writes for one with no +// placeholder and no validation: an empty PlaceholderTemplate, a +// Forms$WidgetValidation with an empty Expression and Message. +func textBoxAsCreated() bson.D { + return bson.D{ + {Key: "$Type", Value: "Forms$TextBox"}, + {Key: "Name", Value: "txtName"}, + {Key: "Appearance", Value: bson.D{{Key: "$Type", Value: "Forms$Appearance"}}}, + {Key: "IsPasswordBox", Value: false}, + {Key: "PlaceholderTemplate", Value: bson.D{ + {Key: "$Type", Value: "Forms$ClientTemplate"}, + {Key: "Fallback", Value: textsText()}, + {Key: "Parameters", Value: bson.A{int32(2)}}, + {Key: "Template", Value: textsText()}, + }}, + {Key: "Validation", Value: bson.D{ + {Key: "$Type", Value: "Forms$WidgetValidation"}, + {Key: "Expression", Value: ""}, + {Key: "Message", Value: textsText()}, + }}, + } +} + +// mendixlabs/mxcli#414: `alter page … set Placeholder = '…' on ` was +// refused with "property \"Placeholder\" is not a property of this built-in +// widget", though CREATE writes `Placeholder:` and DESCRIBE prints it. The same +// held for the other textbox properties CREATE writes — Password, Validation, +// ValidationMessage — none of which had a case in setRawWidgetPropertyMut. +func TestSetTextBoxInputProperties(t *testing.T) { + withAuthoringLanguage(t, "en_US") + + t.Run("Placeholder", func(t *testing.T) { + raw := makeRawPage(textBoxAsCreated()) + m := &Mutator{rawData: raw, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("txtName", "Placeholder", "Enter your name"); err != nil { + t.Fatal(err) + } + tmpl := bsonnav.DGetDoc(bsonnav.DGetDoc(findBsonWidget(raw, "txtName").widget, "PlaceholderTemplate"), "Template") + if got := translationsOf(t, tmpl); got["en_US"] != "Enter your name" { + t.Fatalf("placeholder translations = %v", got) + } + }) + + t.Run("Password", func(t *testing.T) { + raw := makeRawPage(textBoxAsCreated()) + m := &Mutator{rawData: raw, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("txtName", "Password", true); err != nil { + t.Fatal(err) + } + if got := bsonnav.DGet(findBsonWidget(raw, "txtName").widget, "IsPasswordBox"); got != true { + t.Fatalf("IsPasswordBox = %v, want true", got) + } + if err := m.SetWidgetProperty("txtName", "Password", "maybe"); err == nil { + t.Fatal("set Password = 'maybe' returned nil; a password flag is a boolean") + } + }) + + t.Run("Validation and ValidationMessage", func(t *testing.T) { + raw := makeRawPage(textBoxAsCreated()) + m := &Mutator{rawData: raw, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("txtName", "Validation", "$value != empty"); err != nil { + t.Fatal(err) + } + if err := m.SetWidgetProperty("txtName", "ValidationMessage", "Name is required"); err != nil { + t.Fatal(err) + } + v := bsonnav.DGetDoc(findBsonWidget(raw, "txtName").widget, "Validation") + if got := bsonnav.DGetString(v, "Expression"); got != "$value != empty" { + t.Fatalf("Validation.Expression = %q", got) + } + if got := translationsOf(t, bsonnav.DGetDoc(v, "Message")); got["en_US"] != "Name is required" { + t.Fatalf("Validation.Message translations = %v", got) + } + }) + + // A built-in widget with no placeholder keeps the refusal that names what + // it does take — the new case must not turn it into a silent success. + t.Run("Placeholder on a widget without one is refused", func(t *testing.T) { + cb := bson.D{ + {Key: "$Type", Value: "Forms$CheckBox"}, + {Key: "Name", Value: "chk"}, + {Key: "Appearance", Value: bson.D{{Key: "$Type", Value: "Forms$Appearance"}}}, + } + m := &Mutator{rawData: makeRawPage(cb), widgetFinder: findBsonWidget} + err := m.SetWidgetProperty("chk", "Placeholder", "x") + if err == nil || !strings.Contains(err.Error(), "not a property of this built-in widget") { + t.Fatalf("err = %v, want the built-in widget refusal", err) + } + }) +} From 592d29b16befaf0ef268f5a5c5b378d61b27acbe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:05:54 +0000 Subject: [PATCH 09/11] test(bug-tests): write the #292 regression script in canonical MDL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conformance gate refused the deprecated spellings (SAVE_CHANGES, `set X = …`, `Params: { … }`); rewritten with `mxcli fmt --upgrade`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CS3jxiq2vnQdHsNSQMqUtV --- .../292-alter-page-set-label-silent-noop.mdl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl b/mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl index ff98ba202..a78d19f43 100644 --- a/mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl +++ b/mdl-examples/bug-tests/292-alter-page-set-label-silent-noop.mdl @@ -11,7 +11,7 @@ mdl 1; -- * an input widget that shows no label (`LabelTemplate: null`) — the label -- is now created, the same ClientTemplate `create page` writes; -- * a widget with no label slot at all (a button) — now refused: --- set Label = 'X' on btnSave; +-- set (Label: 'X') on btnSave; -- -> failed to set Label on btnSave: widget has no Label property -- -- Expected: `mxcli check` passes; after exec, DESCRIBE shows Title 'New title', @@ -20,17 +20,17 @@ mdl 1; create module M292; create persistent entity M292.Thing ( Name: string(200), Code: string(50) ); -create or modify page M292.P_Edit (Title: 'Old title', Layout: Atlas_Core.Atlas_Default, Params: { $Thing: M292.Thing }) +create or modify page M292.P_Edit (Title: 'Old title', Layout: Atlas_Core.Atlas_Default, Params: ( $Thing: M292.Thing )) { dataview dv (DataSource: $Thing) { textbox textBox1 (Label: 'Old label', Attribute: Name) textbox tbNoLabel (Attribute: Code) - actionbutton btnSave (Caption: 'Save', Action: SAVE_CHANGES) + actionbutton btnSave (Caption: 'Save', Action: SAVE CHANGES) } }; alter page M292.P_Edit { - set Title = 'New title'; - set Label = 'New label' on textBox1; - set Label = 'Added' on tbNoLabel + set (Title: 'New title'); + set (Label: 'New label') on textBox1; + set (Label: 'Added') on tbNoLabel }; From 6d91f8543759b631e1a02e6380437372f964f608 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 11:10:24 +0000 Subject: [PATCH 10/11] fix(deps): upgrade mongo-driver for the October 2026 BSON advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer's release scan (Sonatype Lifecycle, blocking at CVSS >= 7) refused the published .exe over three CVEs in go.mongodb.org/mongo-driver/v2 v2.6.0. go.mongodb.org/mongo-driver/v2 v2.6.0 -> v2.9.2 go.mongodb.org/mongo-driver v1.17.9 -> v1.17.10 Reachability, checked per CVE rather than per module: - CVE-2026-81521 (Client.BulkWrite namespace confusion) is NOT reachable. mxcli imports only the BSON packages (v2/bson in 271 files, v2/x/bsonx/bsoncore in 9, v1 bson and bson/primitive); no mongo client package is imported anywhere. - CVE-2026-107324 (bson.Raw.Validate) and CVE-2026-107325 (bson.RawArray.Validate) ARE reachable. Both are called on bytes read from a .mpr: modelsdk/canon/attributeref.go:92, modelsdk/mprread/generic.go:121, modelsdk/mpr/reader_raw.go:242 and cmd/mxcli/cmd_fix_hashes.go:167, each of them defensively, expecting an error for bytes that are not a valid document. A panicking Validate turns a malformed project file into a crash of the CLI — denial of service only, no code execution. v2.9.2 is the floor for all three: CVE-2026-81521 was fixed in v2.8.2, regressed out of v2.9.0 and v2.9.1, and restored in v2.9.2. The upgrade also raises the declared github.com/klauspost/compress requirement from v1.17.6 to v1.19.2, which the same scan flagged. That module is pulled in by the driver for wire-protocol compression and is compiled into no mxcli build: it has no go.sum entry and no `go version -m bin/mxcli` line. Note that govulncheck reports "No vulnerabilities found" against the affected tree — these CVEs are absent from the Go vulnerability database (an OSV query for mongo-driver/v2@2.6.0 returns a bare {}), so a clean govulncheck here is a data-source gap, not evidence. Regression test with a control, per the CLAUDE.md evidence bar: TestRawUnitName_MalformedBSONDoesNotPanic drives the malformed bytes through rawUnitName, mxcli's own call site. Against v2.6.0 it fails with "runtime error: slice bounds out of range [:-2147483641]", the reported symptom; against v2.9.2 it passes. It therefore detects a downgrade of the driver, not merely the absence of a crash. No source change and no API change; BSON encoding and decoding are unaffected. Verified: go build ./... clean, 104 test packages pass, go vet clean (bar the pre-existing generated ANTLR parser warnings), and `go version -m bin/mxcli` shows v2.9.2 / v1.17.10. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rer1mzP69XxbXKxQqheipM --- ...mongo-driver-cve-release-scan-blocked.json | 1 + CHANGELOG.md | 4 ++ go.mod | 4 +- go.sum | 5 ++ modelsdk/mpr/reader_raw_malformed_test.go | 72 +++++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/other/2026-10-15-mongo-driver-cve-release-scan-blocked.json create mode 100644 modelsdk/mpr/reader_raw_malformed_test.go diff --git a/.claude/skills/fix-issue/findings/other/2026-10-15-mongo-driver-cve-release-scan-blocked.json b/.claude/skills/fix-issue/findings/other/2026-10-15-mongo-driver-cve-release-scan-blocked.json new file mode 100644 index 000000000..379a4d5ea --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-15-mongo-driver-cve-release-scan-blocked.json @@ -0,0 +1 @@ +{"area": "go.mod", "date": "2026-10-15", "symptom": "A customer's release pipeline (Sonatype Lifecycle, \"Security-High\" policy, CVSS >= 7 blocks) refused the mxcli .exe, reporting go.mongodb.org/mongo-driver/v2 v2.6.0 with CVE-2026-107324 (8.2), CVE-2026-107325 (8.2) and CVE-2026-81521 (7.1), plus go.mongodb.org/mongo-driver v1.17.9 and github.com/klauspost/compress v1.17.6", "cause": "go.mod pinned mongo-driver/v2 v2.6.0 and mongo-driver v1.17.9, both predating the October 2026 BSON advisories. mxcli imports only the BSON packages (v2/bson in 271 files, v2/x/bsonx/bsoncore in 9, v1 bson+bson/primitive), never the mongo client, so CVE-2026-81521 (Client.BulkWrite namespace confusion) is unreachable — but CVE-2026-107324 (bson.Raw.Validate panic on a binary element declaring math.MaxInt32) and CVE-2026-107325 (bson.RawArray.Validate panic on a zero-length array) are in code mxcli calls on bytes read from a .mpr: modelsdk/canon/attributeref.go:92, modelsdk/mprread/generic.go:121, modelsdk/mpr/reader_raw.go:242, cmd/mxcli/cmd_fix_hashes.go:167", "file": "`go.mod` / `go.sum` — mongo-driver/v2 v2.6.0 -> v2.9.2, mongo-driver v1.17.9 -> v1.17.10", "insight": "Do NOT reach for a policy waiver on a scanner report before checking two things, because both answers here contradicted the obvious reading. (1) govulncheck says \"No vulnerabilities found\" on the affected tree — these CVEs are absent from the Go vuln DB (an OSV query for mongo-driver/v2@2.6.0 returns a bare `{}`), so a clean govulncheck is a data-source gap, not evidence; the vendor's own release notes are the authority. (2) A flagged TRANSITIVE module with no go.sum entry was never compiled: klauspost/compress v1.17.6 is exactly the version declared in mongo-driver/v2@v2.6.0's go.mod and appears in no go.sum and in no `go version -m bin/mxcli` line, which proves the scanner reads the module requirement graph, not the linked binary. Upgrading the parent raised that declared requirement to v1.19.2 and cleared the finding without a waiver. Conversely, \"we only use BSON, not the client\" is NOT a blanket non-exploitability argument — two of the three CVEs are in bson.Raw/RawArray.Validate, which mxcli calls on untrusted .mpr bytes (a malicious .mpr panics the CLI; DoS only, no code execution). Reachability per CVE, checked by grepping for the named symbol, beats a per-module verdict. v2.9.2 is the floor for all three: 81521 was fixed in v2.8.2, regressed out of v2.9.0/v2.9.1, and restored in v2.9.2. Verified: `go build ./...` clean, 104 test packages pass, `go version -m bin/mxcli` shows v2.9.2 / v1.17.10", "refs": ["CVE-2026-107324", "CVE-2026-107325", "CVE-2026-81521"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2365f748c..b6fa25805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Security + +- **MongoDB driver upgraded for the October 2026 BSON advisories** — `go.mongodb.org/mongo-driver/v2` v2.6.0 -> v2.9.2 and `go.mongodb.org/mongo-driver` v1.17.9 -> v1.17.10. A customer's release scan (Sonatype Lifecycle, blocking at CVSS >= 7) refused the published `.exe` over CVE-2026-107324 and CVE-2026-107325 — panics in `bson.Raw.Validate` (a binary element declaring a length of `math.MaxInt32`) and `bson.RawArray.Validate` (a zero-length array) — and CVE-2026-81521. mxcli imports only the BSON packages, never the mongo client, so CVE-2026-81521 (`Client.BulkWrite` namespace confusion) was never reachable; the two `Validate` panics were, on bytes read from a `.mpr` (`modelsdk/canon/attributeref.go`, `modelsdk/mprread/generic.go`, `modelsdk/mpr/reader_raw.go`, `cmd/mxcli/cmd_fix_hashes.go`), so a malicious project file could crash the CLI — denial of service only, no code execution. v2.9.2 is the floor for all three: CVE-2026-81521 was fixed in v2.8.2, regressed out of v2.9.0 and v2.9.1, and restored in v2.9.2. The upgrade also raises the declared `github.com/klauspost/compress` requirement from v1.17.6 to v1.19.2, which the same scan flagged — that module is pulled in by the driver for wire-protocol compression and is compiled into no mxcli build (it has no `go.sum` entry and no `go version -m` line). No source change and no API change; BSON encoding and decoding are unaffected. + ### Added - **`filemanager` — the classic file manager (`Forms$FileManager`) in MDL** (mendixlabs/mxcli#151) — `filemanager fileManager1 (Label: 'File', Type: Upload, MaxFileSize: 10, AllowedExtensions: '.xlsx', ShowFileInBrowser: false)` inside a data view on a `System.FileDocument` specialization. Before, there was no way to author one: `pluggablewidget 'Forms$FileManager'` had no definition, the bare keyword was refused as MDL-WIDGET25, and `describe page` printed a stored one as a NOT re-executable comment, so describe → exec dropped it. Omitted properties take Studio Pro's defaults (`Both`, 5 MB, any file, false); the written BSON has the property set of a Studio Pro-authored file manager. `alter page … set` changes all four properties. A file manager in no data container is MDL-WIDGET45 at `check`, and one in a data view on an entity that is not a FileDocument is refused by `exec` — both were mxbuild's CE6810 after a clean `exec`. diff --git a/go.mod b/go.mod index ba015505f..85f9069c0 100644 --- a/go.mod +++ b/go.mod @@ -24,8 +24,8 @@ require ( go.lsp.dev/jsonrpc2 v0.10.0 go.lsp.dev/protocol v0.12.0 go.lsp.dev/uri v0.3.0 - go.mongodb.org/mongo-driver v1.17.9 - go.mongodb.org/mongo-driver/v2 v2.6.0 + go.mongodb.org/mongo-driver v1.17.10 + go.mongodb.org/mongo-driver/v2 v2.9.2 go.starlark.net v0.0.0-20260102030733-3fee463870c9 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 9c7101048..52a52b588 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,7 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -180,8 +181,12 @@ go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +go.mongodb.org/mongo-driver v1.17.10 h1:kdAgQvu8TROXZpSkJQd5wzfaNCCrMbpZyKFtQ6qkPCE= +go.mongodb.org/mongo-driver v1.17.10/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.mongodb.org/mongo-driver/v2 v2.9.2 h1:OCAdjI37pC/SVxucz/9CM8zD4jCdegtsY5QcKzYFCgE= +go.mongodb.org/mongo-driver/v2 v2.9.2/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.starlark.net v0.0.0-20260102030733-3fee463870c9 h1:nV1OyvU+0CYrp5eKfQ3rD03TpFYYhH08z31NK1HmtTk= go.starlark.net v0.0.0-20260102030733-3fee463870c9/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/modelsdk/mpr/reader_raw_malformed_test.go b/modelsdk/mpr/reader_raw_malformed_test.go new file mode 100644 index 000000000..9c7b65b1e --- /dev/null +++ b/modelsdk/mpr/reader_raw_malformed_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "encoding/binary" + "math" + "testing" +) + +// CVE-2026-107324 / CVE-2026-107325: go.mongodb.org/mongo-driver/v2 before +// v2.9.2 panics inside bson.Raw.Validate and bson.RawArray.Validate instead of +// returning an error, on a binary element declaring a length of math.MaxInt32 +// and on a zero-length array respectively. +// +// rawUnitName calls raw.Validate() defensively — it expects an error for bytes +// that are not a valid document and reports ok=false. A panicking Validate +// turns a malformed unit in a .mpr into a crash of the whole CLI, so these +// bytes are reachable from any project file mxcli is pointed at. +// +// Control: against v2.6.0 (the version a customer's release scan flagged) the +// first subtest fails with +// "runtime error: slice bounds out of range [:-2147483641]"; against v2.9.2 it +// passes. The test therefore detects a downgrade of the driver, not just the +// absence of a crash. +func TestRawUnitName_MalformedBSONDoesNotPanic(t *testing.T) { + // A document holding one binary element whose declared length is + // math.MaxInt32, which overflowed the driver's remaining-bytes arithmetic. + maxInt32Binary := func() []byte { + doc := []byte{0, 0, 0, 0} // total length, filled in below + doc = append(doc, 0x05) // element type: binary + doc = append(doc, 'a', 0) // element key: "a" + l := make([]byte, 4) + binary.LittleEndian.PutUint32(l, uint32(math.MaxInt32)) + doc = append(doc, l...) // declared payload length + doc = append(doc, 0x00) // binary subtype + doc = append(doc, 0x00) // document terminator + binary.LittleEndian.PutUint32(doc[0:4], uint32(len(doc))) + return doc + }() + + cases := []struct { + name string + contents []byte + }{ + {"binary element declaring math.MaxInt32 (CVE-2026-107324)", maxInt32Binary}, + {"empty contents (CVE-2026-107325)", []byte{}}, + {"truncated length prefix", []byte{0xFF, 0xFF, 0xFF, 0x7F}}, + {"length prefix larger than the buffer", []byte{0x40, 0x00, 0x00, 0x00, 0x00}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("rawUnitName panicked on malformed BSON: %v\n"+ + "this is CVE-2026-107324/107325 — check the "+ + "go.mongodb.org/mongo-driver/v2 version in go.mod "+ + "(v2.9.2 or later is required)", r) + } + }() + + name, ok := rawUnitName(tc.contents) + if ok { + t.Errorf("rawUnitName accepted malformed BSON as a valid document (name=%q)", name) + } + if name != "" { + t.Errorf("rawUnitName returned name %q for malformed BSON, want empty", name) + } + }) + } +} From f67a96d0ef9a11dcc0938e0275eaba54bb9a8cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 11:29:55 +0000 Subject: [PATCH 11/11] fix(check): refuse object-list item properties that would be dropped `group g1 (Caption: 'Group one')` inside an Accordion passed check, exec reported success, and the group kept the package default header text 'Header'. The group's header is `headerText`; `Caption` matches nothing. Object-list items (Accordion groups, DataGrid 2 columns, chart series, HTMLElement attributes) were exempt from both unknown-property checks, on the grounds that the object-list engine validates them. It does not: buildObjectListItem walks the item's schema and looks each key up in the MDL bag, so a key matching no schema property is never visited. validateObjectListItemUnknownProps derives the item's vocabulary from its mapping (schema keys, MDL aliases, the Params companion of a text template, a chart series' DataSource:) and reports anything else as MDL-WIDGET01. It deliberately does not reuse the top-level allowance for builtin names such as Caption and Class; that allowance is the hole. A sweep of every repo .mdl with `check -p` against a project holding the stock packages (baseline vs new) found 6 new reports, all real drops confirmed by exec + bson dump: the two #891 repros (Caption on a group), two doctype examples putting designproperties on a column (a column has none; the grid carries the same values), and the #1135 repro writing attributeValue, which HTMLElement stores as attributeValueTemplate / attributeValueExpression. Those examples are corrected here. For a truncated key like that, keys extending it are now suggested ahead of the nearest by edit distance, which had proposed attributeName. Control: with the check unwired, TestAccordionGroupCaptionIsRefused fails ("no MDL-WIDGET01 for `Caption` on an Accordion group"). Refs ako/mxcli#1274 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018d9s3aLWW7MCos2mfNnXYo --- ...ct-list-item-unknown-property-dropped.json | 1 + .../bug-patterns/silent-property-drop.md | 15 ++ .../891-accordion-group-nested-widgets.mdl | 2 +- ...alter-page-grid-inside-accordion-group.mdl | 2 +- ...5-installed-widget-without-widget-init.mdl | 2 +- .../doctype-tests/03-page-examples.mdl | 4 +- .../doctype-tests/29-datagrid-examples.mdl | 8 +- .../validate_object_list_item_props_test.go | 160 ++++++++++++++++++ mdl/executor/validate_widgets.go | 101 ++++++++++- 9 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-15-object-list-item-unknown-property-dropped.json create mode 100644 mdl/executor/validate_object_list_item_props_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-object-list-item-unknown-property-dropped.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-object-list-item-unknown-property-dropped.json new file mode 100644 index 000000000..f1bf1f1c1 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-object-list-item-unknown-property-dropped.json @@ -0,0 +1 @@ +{"area":"mdl-executor","date":"2026-10-15","symptom":"`group g1 (Caption: 'Group one')` inside an Accordion passes `mxcli check`, `exec` reports \"Created page\", and `describe` shows the group with the package default `headerText: 'Header'` — the caption is gone. Same silence for `designproperties:` on a DataGrid 2 `column` and `attributeValue:` on an HTMLElement `attribute`","cause":"Object-list items were exempt from both unknown-property checks (`validateStaticWidgetUnknownProps`, `validatePluggableWidgetProperties`) with a comment saying the object-list engine validates them, but `buildObjectListItem` only walks the item SCHEMA and looks each key up in the MDL bag — a key matching no schema property or alias is never visited and never reported","file":"`mdl/executor/validate_widgets.go` (`validateObjectListItemUnknownProps`, `extensionsOf`)","insight":"Derive the item vocabulary from the mapping — schema keys, `MdlAliases`, and `Params` for each texttemplate under either spelling, plus a chart series' friendly `DataSource:` — and do NOT reuse the top-level `isBuiltinPropName` allowance: `Caption`/`Class` being accepted universally is the hole. The Accordion definition is generated from the project's .mpk, so a test with `LoadWidgetRegistry(\"\")` has no mapping and passes vacuously; copy the .mpk from `testdata/pedapp/widgets` into a temp project. Measure false positives by sweeping every repo .mdl with `check -p` (base vs new binary) against a project holding the stock packages: all 6 new reports were real drops (verified by exec + bson dump), 3 of them in doctype/bug-test examples that taught the drop. Edit distance answers a truncated key badly (`attributeValue` -> `attributeName`); prefer candidates that extend the key. Issue #891 (ako/mxcli#1274)","refs":["#891","ako/mxcli#1274"],"rules":["MDL-WIDGET01"]} diff --git a/docs-wiki/bug-patterns/silent-property-drop.md b/docs-wiki/bug-patterns/silent-property-drop.md index d62124d78..5e42f8eb5 100644 --- a/docs-wiki/bug-patterns/silent-property-drop.md +++ b/docs-wiki/bug-patterns/silent-property-drop.md @@ -54,6 +54,21 @@ outcome into a three-way answer — an unrecognised key gets a "did you mean" warning, a recognised-but-unmapped key gets "this will be dropped, set it in Studio Pro", and a mapped key stays quiet. +**An exemption for "validated elsewhere" needs the elsewhere to exist.** +Object-list items — an Accordion `group`, a DataGrid `column`, a chart +`series` — were skipped by both unknown-property checks on the grounds that +"the object-list engine validates them". It did not: the item builder walks the +item's *schema* and looks each key up in the MDL bag, so a key matching no +schema property is simply never visited. `group g1 (Caption: …)`, +`designproperties:` on a column and `attributeValue:` on an HTMLElement +attribute all checked clean and vanished. The item's vocabulary is fully +derivable — its schema keys, their aliases, and each text template's +`Params` — and the top-level rule's universal builtin allowance must +**not** carry over, since `Caption` and `Class` being accepted everywhere is +exactly what let them through. Sweep the example corpus with `check -p` against +a project that has the packages before and after: every new report there was a +real drop already in the examples. + **Where it cannot, enumerate generously and guard the list.** Built-in widgets have no `.mpk` to subtract from, so their vocabulary is a hand-maintained union — grammar keyword properties, every key the builders consume, and every property diff --git a/mdl-examples/bug-tests/891-accordion-group-nested-widgets.mdl b/mdl-examples/bug-tests/891-accordion-group-nested-widgets.mdl index 95f26f9be..da335a1a7 100644 --- a/mdl-examples/bug-tests/891-accordion-group-nested-widgets.mdl +++ b/mdl-examples/bug-tests/891-accordion-group-nested-widgets.mdl @@ -42,7 +42,7 @@ create entity A91.Item ( Name: String(100), Qty: Integer ); create page A91.AccPage ( Title: 'Acc', Layout: Atlas_Core.Atlas_Default ) { PLUGGABLEWIDGET 'com.mendix.widget.web.accordion.Accordion' acc1 { - GROUP g1 (Caption: 'Group one') { + GROUP g1 (HeaderText: 'Group one') { DATAGRID grid1 (DataSource: DATABASE A91.Item) { COLUMN (Attribute: Name, Caption: 'Name') COLUMN (Attribute: Qty, Caption: 'Qty') diff --git a/mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl b/mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl index bf23692e6..15c7fd452 100644 --- a/mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl +++ b/mdl-examples/bug-tests/891-alter-page-grid-inside-accordion-group.mdl @@ -26,7 +26,7 @@ create entity A1274.Item ( Name: String(100), Qty: Integer ); create page A1274.AccPage ( Title: 'Acc', Layout: Atlas_Core.Atlas_Default ) { PLUGGABLEWIDGET 'com.mendix.widget.web.accordion.Accordion' acc1 { - GROUP g1 (Caption: 'Group one') { + GROUP g1 (HeaderText: 'Group one') { DATAGRID grid1 (DataSource: DATABASE A1274.Item) { COLUMN (Attribute: Name, Caption: 'Name') COLUMN (Attribute: Qty, Caption: 'Qty') diff --git a/mdl-examples/bug-tests/widgets-1135-installed-widget-without-widget-init.mdl b/mdl-examples/bug-tests/widgets-1135-installed-widget-without-widget-init.mdl index d9d7e4b52..b3cd485e9 100644 --- a/mdl-examples/bug-tests/widgets-1135-installed-widget-without-widget-init.mdl +++ b/mdl-examples/bug-tests/widgets-1135-installed-widget-without-widget-init.mdl @@ -37,7 +37,7 @@ create or modify page MyFirstModule.HtmlDemo ( layout: 'Atlas_Core.Atlas_Default' ) { htmlelement frame (tagName: 'div') { - attribute a1 (attributeName: 'data-x', attributeValue: 'y') + attribute a1 (attributeName: 'data-x', attributeValueType: 'template', attributeValueTemplate: 'y') tagcontentcontainer body { dynamictext dt (content: 'hi') } diff --git a/mdl-examples/doctype-tests/03-page-examples.mdl b/mdl-examples/doctype-tests/03-page-examples.mdl index ba51fd4ee..74d1cbc4e 100644 --- a/mdl-examples/doctype-tests/03-page-examples.mdl +++ b/mdl-examples/doctype-tests/03-page-examples.mdl @@ -563,7 +563,7 @@ create page PgTest.P010_Product_Sorted_filtered_styled_2 folder 'DataGrid' ( PagingPosition: both, designproperties: ('Compact': on, 'Striped': on, 'Hover': on, 'Borders': 'Horizontal') ) { - column (attribute: Name, caption: 'Product Name', Alignment: left, WrapText: false, designproperties: ('Compact': on, 'Striped': on, 'Hover': on, 'Borders': 'Horizontal')) + column (attribute: Name, caption: 'Product Name', Alignment: left, WrapText: false) column (attribute: Code, caption: 'SKU') column (attribute: Price, caption: 'Unit Price', Alignment: right, WrapText: false) column (attribute: Stock, caption: 'Available', Alignment: right, WrapText: false) @@ -596,7 +596,7 @@ create page PgTest.P010_Product_Microflow_DataSource folder 'DataGrid' ( PagingPosition: both, designproperties: ('Compact': on, 'Striped': on, 'Hover': on, 'Borders': 'Horizontal') ) { - column (attribute: Name, caption: 'Product Name', Alignment: left, WrapText: false, designproperties: ('Compact': on, 'Striped': on, 'Hover': on, 'Borders': 'Horizontal')) + column (attribute: Name, caption: 'Product Name', Alignment: left, WrapText: false) column (attribute: Code, caption: 'SKU') column (attribute: Price, caption: 'Unit Price', Alignment: right, WrapText: false) column (attribute: Stock, caption: 'Available', Alignment: right, WrapText: false) diff --git a/mdl-examples/doctype-tests/29-datagrid-examples.mdl b/mdl-examples/doctype-tests/29-datagrid-examples.mdl index 15db9d68c..abbed34c1 100644 --- a/mdl-examples/doctype-tests/29-datagrid-examples.mdl +++ b/mdl-examples/doctype-tests/29-datagrid-examples.mdl @@ -593,10 +593,10 @@ create page DgTest.DG14b_Styling_DesignProps folder 'DataGrid' ( PagingPosition: both, designproperties: ('Compact': on, 'Striped': on, 'Hover': on, 'Borders': 'Horizontal') ) { - column ( - attribute: Name, caption: 'Name', - designproperties: ('Compact': on) - ) + -- Design properties belong to the grid: a DataGrid 2 column has none, and + -- `designproperties:` on a column is refused (MDL-WIDGET01) rather than + -- dropped on write. + column (attribute: Name, caption: 'Name') column (attribute: Price, caption: 'Price', Alignment: right) column (attribute: Stock, caption: 'Stock', Alignment: right) } diff --git a/mdl/executor/validate_object_list_item_props_test.go b/mdl/executor/validate_object_list_item_props_test.go new file mode 100644 index 000000000..a5c9fbf08 --- /dev/null +++ b/mdl/executor/validate_object_list_item_props_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// An object-list item (an Accordion GROUP, a DataGrid COLUMN, a chart SERIES) +// is built by walking the widget's schema and looking each key up in the MDL +// property bag, so a key that matches nothing is never visited: `GROUP g1 +// (Caption: 'Group one')` passed check, exec wrote the group with the shipped +// default `headerText: 'Header'`, and the caption was gone (#891 follow-up). + +func TestValidateObjectListItemUnknownProps(t *testing.T) { + mapping := &ObjectListMapping{ + MDLContainer: "COLUMN", + ItemProperties: []ItemPropertyMapping{ + {PropertyKey: "header", Operation: "texttemplate", MdlAliases: []string{"Caption"}}, + {PropertyKey: "attribute", Operation: "attribute"}, + {PropertyKey: "visible", Operation: "expression", MdlAliases: []string{"VisibleIf"}}, + }, + ItemSlots: []ItemSlotMapping{{PropertyKey: "content", Operation: "widgets"}}, + } + item := func(props map[string]any) *ast.WidgetV3 { + return &ast.WidgetV3{Type: "column", Name: "c1", Properties: props} + } + cases := []struct { + name string + props map[string]any + wantBad string // the key the violation must name; "" = no violation + }{ + {"schema key", map[string]any{"header": "x"}, ""}, + {"schema key, other casing", map[string]any{"Attribute": "Name"}, ""}, + {"alias", map[string]any{"Caption": "x"}, ""}, + {"lowered visitor key is an alias", map[string]any{"VisibleIf": "true"}, ""}, + {"params companion of the schema key", map[string]any{"headerParams": []ast.ParamAssignmentV3{}}, ""}, + {"params companion of an alias", map[string]any{"CaptionParams": []ast.ParamAssignmentV3{}}, ""}, + {"a key the schema does not have", map[string]any{"Class": "x"}, "Class"}, + {"a typo", map[string]any{"Atribute": "Name"}, "Atribute"}, + {"params of a non-template property", map[string]any{"attributeParams": []ast.ParamAssignmentV3{}}, "attributeParams"}, + {"designproperties: a column has none", map[string]any{"DesignProperties": nil}, "DesignProperties"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vs := validateObjectListItemUnknownProps(item(c.props), mapping, "page X") + if c.wantBad == "" { + if len(vs) > 0 { + t.Fatalf("unexpected violation: %+v", vs) + } + return + } + if len(vs) != 1 { + t.Fatalf("want one violation naming %q, got %+v", c.wantBad, vs) + } + if vs[0].RuleID != "MDL-WIDGET01" || !strings.Contains(vs[0].Message, "`"+c.wantBad+"`") { + t.Errorf("violation = %s %q, want MDL-WIDGET01 naming %q", vs[0].RuleID, vs[0].Message, c.wantBad) + } + }) + } +} + +// accordionRegistry loads a registry whose Accordion definition is generated +// from the real package, as `check -p` does. The definition comes from the +// project's .mpk, not the embedded set, so with no project there is no mapping +// to check against. The package is copied into a temp project because the +// loader writes generated definitions beside the project it reads. +func accordionRegistry(t *testing.T) *WidgetRegistry { + t.Helper() + const mpk = "com.mendix.widget.web.Accordion.mpk" + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "pedapp", "widgets", mpk)) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "widgets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "widgets", mpk), data, 0o644); err != nil { + t.Fatal(err) + } + return LoadWidgetRegistry(filepath.Join(dir, "App.mpr")) +} + +// The reported shape, through the real parser and the shipped Accordion +// definition: `Caption` must be refused with the property that works. +func TestAccordionGroupCaptionIsRefused(t *testing.T) { + prog, errs := visitor.Build(`create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + pluggablewidget 'com.mendix.widget.web.accordion.Accordion' acc1 { + group g1 (Caption: 'Group one') { + dynamictext t1 (content: 'x') + } + } +}`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + registry := accordionRegistry(t) + var got []string + for _, v := range ValidateWidgetPropertiesForStatement(prog.Statements[0], registry) { + got = append(got, v.RuleID+": "+v.Message) + if v.RuleID == "MDL-WIDGET01" && strings.Contains(v.Message, "`Caption`") { + if !strings.Contains(v.Message, "headerText") { + t.Errorf("the message does not name the property that works: %s", v.Message) + } + return + } + } + t.Fatalf("no MDL-WIDGET01 for `Caption` on an Accordion group; got %v", got) +} + +// The documented spelling stays clean — the guard must not refuse what the +// examples teach. +func TestAccordionGroupHeaderTextIsAccepted(t *testing.T) { + prog, errs := visitor.Build(`create page M.P (title: 'P', layout: Atlas_Core.Atlas_Default) { + pluggablewidget 'com.mendix.widget.web.accordion.Accordion' acc1 { + group g1 (HeaderText: 'Group one', HeaderRenderMode: 'text') { + dynamictext t1 (content: 'x') + } + } +}`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + for _, v := range ValidateWidgetPropertiesForStatement(prog.Statements[0], accordionRegistry(t)) { + if v.RuleID == "MDL-WIDGET01" { + t.Errorf("unexpected: %s", v.Message) + } + } +} + +// A truncated key is answered with the keys that extend it, not the nearest by +// edit distance: `attributeValue` on an HTMLElement attribute drew "did you mean +// `attributeName`", the one key certain to be wrong. +func TestValidateObjectListItemUnknownProps_SuggestsExtensions(t *testing.T) { + mapping := &ObjectListMapping{ + MDLContainer: "ATTRIBUTE", + ItemProperties: []ItemPropertyMapping{ + {PropertyKey: "attributeName", Operation: "primitive"}, + {PropertyKey: "attributeValueType", Operation: "primitive"}, + {PropertyKey: "attributeValueTemplate", Operation: "texttemplate"}, + {PropertyKey: "attributeValueExpression", Operation: "expression"}, + }, + } + w := &ast.WidgetV3{Type: "attribute", Name: "a1", Properties: map[string]any{"attributeValue": "y"}} + vs := validateObjectListItemUnknownProps(w, mapping, "page X") + if len(vs) != 1 { + t.Fatalf("want one violation, got %+v", vs) + } + msg := vs[0].Message + if strings.Contains(msg, "`attributeName`") || !strings.Contains(msg, "attributeValueTemplate") { + t.Errorf("hint should name the attributeValue… keys, not attributeName: %s", msg) + } +} diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 81b5db49d..56c7e3c2f 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -290,7 +290,8 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc out = append(out, validateInputBindingContext(w, argCtx, locationPrefix)...) // Unknown-property warning applies only to built-in widgets; pluggable // widgets get the stricter def.json check (MDL-WIDGET01) above, and - // object-list items are validated by the object-list engine. + // object-list items get its item-level twin below + // (validateObjectListItemUnknownProps). def := lookupWidgetDef(w, registry) // #2 from the view-entity-examples findings: a child the parent has // nowhere to put. MDL-WIDGET26 above covers a container KEYWORD in that @@ -332,6 +333,9 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc out = append(out, validatePluggableSystemProps(w, def, registry, locationPrefix)...) } if mapping != nil { + // A key the item's schema does not have is never visited by the + // item builder, so it is dropped on write (#891 follow-up). + out = append(out, validateObjectListItemUnknownProps(w, mapping, locationPrefix)...) out = append(out, validateObjectListItemEnums(w, mapping, locationPrefix)...) // #931: a sub-property the widget's editorConfig hides must hold its // default; a non-default value there is CE0463. @@ -760,6 +764,101 @@ func validateObjectListItemEnums(w *ast.WidgetV3, mapping *ObjectListMapping, lo return out } +// validateObjectListItemUnknownProps refuses a property an object-list item +// (an Accordion GROUP, a DataGrid COLUMN, a chart SERIES) has no route for. +// The item builder walks the item's schema and looks each key up in the MDL +// property bag, so a key that matches none is never visited and never written: +// `group g1 (Caption: 'Group one')` passed check, exec reported success, and the +// group kept the widget's default header text (#891 follow-up). MDL-WIDGET01, +// the top-level rule's item-level twin. +// +// The vocabulary is exactly what buildObjectListItem reads: each schema key and +// its MDL aliases, the `Params` companion of a text-template property under +// either spelling, and a chart series' friendly `DataSource:`. Unlike the +// top-level check this does NOT accept the universal builtin names (Caption, +// Class, Visible, …): the item builder routes none of them on its own, and +// accepting them is what let `Caption` through. +func validateObjectListItemUnknownProps(w *ast.WidgetV3, mapping *ObjectListMapping, locationPrefix string) []linter.Violation { + known := map[string]bool{} + var names []string + add := func(n string) { + if n == "" || known[strings.ToLower(n)] { + return + } + known[strings.ToLower(n)] = true + names = append(names, n) + } + for _, ip := range mapping.ItemProperties { + spellings := append([]string{ip.PropertyKey}, ip.MdlAliases...) + for _, n := range spellings { + add(n) + } + if ip.Operation == "texttemplate" { + for _, n := range spellings { + add(n + "Params") + } + } + } + if isChartSeriesContainer(mapping.MDLContainer) { + add("DataSource") + } + + var out []linter.Violation + for _, key := range sortedPropertyKeys(w) { + if known[strings.ToLower(key)] { + continue + } + hint := "" + if ext := extensionsOf(key, names); len(ext) > 0 { + // `attributeValue` on an HTMLElement attribute: the value lives under + // attributeValueTemplate / attributeValueExpression, and edit + // distance picks attributeName, the one key that is certainly wrong. + hint = fmt.Sprintf(" — did you mean `%s`?", strings.Join(ext, "` or `")) + } else if s := nearestKey(key, names); s != "" { + hint = fmt.Sprintf(" — did you mean `%s`?", s) + } else if len(names) > 0 { + hint = fmt.Sprintf(" — its properties are %s", strings.Join(itemPropertyNames(mapping), ", ")) + } + out = append(out, linter.Violation{ + RuleID: "MDL-WIDGET01", + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "%s: %s has no property `%s`, so the value would be dropped on write%s", + locationPrefix, widgetLabel(w.Name, strings.ToLower(mapping.MDLContainer)), key, hint, + ), + }) + } + return out +} + +// extensionsOf returns the candidates that extend key (case-insensitively), +// excluding `…Params` companions — the names a truncated key most likely meant. +func extensionsOf(key string, candidates []string) []string { + lower := strings.ToLower(key) + var out []string + for _, c := range candidates { + lc := strings.ToLower(c) + if lc != lower && strings.HasPrefix(lc, lower) && !strings.HasSuffix(lc, "params") { + out = append(out, c) + } + } + return out +} + +// itemPropertyNames lists an item's schema keys, each with its first MDL alias +// when it has one — the spelling an author is most likely to want. +func itemPropertyNames(mapping *ObjectListMapping) []string { + out := make([]string, 0, len(mapping.ItemProperties)) + for _, ip := range mapping.ItemProperties { + n := ip.PropertyKey + if len(ip.MdlAliases) > 0 { + n += " (" + ip.MdlAliases[0] + ")" + } + out = append(out, n) + } + return out +} + // enumValuesContain reports whether val matches any member key case-insensitively. func enumValuesContain(members []string, val string) bool { for _, m := range members {