Skip to content

Sync mxcli: MDL check↔mx-check parity + pluggable-widget write-path fixes - #800

Merged
ako merged 32 commits into
mendixlabs:mainfrom
ako:main
Jul 29, 2026
Merged

Sync mxcli: MDL check↔mx-check parity + pluggable-widget write-path fixes#800
ako merged 32 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Merges ako/mxcli:main into mendixlabs/mxcli:main.

Closes a batch of ledger findings where mxcli check passed but real
mx check / MxBuild rejected the model (verified against the bundled
mx on Mendix 11.12.1, 0 errors each).

Microflow expression / check parity

Pluggable widgets (write path + DESCRIBE read)

  • feat: ALTER PAGE SET Layout — switch page layout without full rebuild #54 — DataGrid2 columns with empty/absent captions get a valid
    header: attribute-bound columns fall back to the attribute, custom-content
    columns fall back to the column name (gated on the item template having a
    header slot). Fixes CE0463.
  • feat: diag --check-units + grammar fixes #67 — the def generator now emits action property mappings
    (onClick/onChange), so actions on pluggable widgets (Datagrid, CustomChart)
    survive to the MPR; stale defs regenerate via the generator-version stamp.
    DESCRIBE renders these actions back for both datagrid2 and generic
    pluggable widgets.
  • General unmapped-property guard: any MDL property key that resolves in the
    widget definition but has no mapping in the generated def is warned as
    "will be dropped."

Docs / tests

  • New bug-test reproductions in mdl-examples/bug-tests/ for each finding.
  • Symptom rows added to .claude/skills/fix-issue.md and
    write-microflows.md.

make build && make test && make check-mdl all green.

claude added 30 commits July 28, 2026 20:28
Ledger findings #17–19: a `set $x = <expr>` value re-serialized from the
AST silently corrupted the stored Mendix expression — `mxcli check`
passed, `mx check` gave only a generic CE0117:

- #17 a division's right operand lost its `$` (`$a / $b` → `$a/b`)
- #18 a decimal literal lost its fraction (`2.0` → `2`), breaking Decimal
- #19 a small decimal became scientific notation (`0.000001` → `1e-06`)

shouldPreserveExpressionSource now keeps the raw source whenever the
expression contains a `/` or a decimal literal (a `.` adjacent to a
digit), mirroring the existing XPath-where handling — so the exact text
survives instead of a lossy AST round-trip. (MDL division is `div`; `/`
is the member-access separator, which is what caused the `$`-loss
mis-parse — preserving source is the robust fix either way.)

Test: TestShouldPreserveExpressionSource_DivisionAndDecimals; repro
mdl-examples/bug-tests/ledger-17-19-expression-serialization.mdl.
Symptom table updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Ledger finding #16: year() and month() were in the built-in function
whitelist, so `mxcli check --references` passed, but Mendix has no such
functions and the build then failed with CE0117. Remove both so the
error is caught at check time; the correct approach is date
formatting/parsing (parseInteger(formatDateTime($d,'yyyy'))).

Flagged the sibling extraction names (dayOfYear/hour/minute/…) as
unverified-and-likely-invalid in a comment rather than removing them
blind — they need a Studio Pro build to confirm, and rejecting a valid
function is worse than the current state. Test: TestFuncReturnKind_NoYearMonth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Ledger findings #12 & #13 — the MDL spec docs contradicted the grammar
and the (correct) generate-domain-model skill:

- #12: `DELETE_CASCADE` is not a grammar keyword (the parser wants
  CASCADE / DELETE_AND_REFERENCES / DELETE_BUT_KEEP_REFERENCES /
  DELETE_IF_NO_REFERENCES / PREVENT). Replaced every `DELETE_CASCADE`
  with `CASCADE` and listed the full valid set.
- #13: the association section's property table said "from = Parent
  (owner/many)" while its examples did `from Customer to Order` — the
  reverse of reality. An association goes `from` the foreign-key owner
  (the "many" side) `to` the referenced entity (the "one" side); getting
  it backwards passes `mxcli check` but fails the build with CE0854.
  Corrected the table, the syntax template, and all examples to
  `from Order to Customer`, with a note on the counter-intuitive
  direction.

Files: docs/05-mdl-specification/{03-domain-model,01-language-reference}.md.
Verified CASCADE + corrected direction parse via `mxcli check`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Ledger finding #24: on the modelsdk engine, a navigationlist item was
stored with an empty name, and the caption's generated DynamicText had
no name either — Studio Pro rejected the project with CE7247 "name
cannot be empty" and CE0495 "duplicate name ''".

navListItemToGen now (1) writes the item Name — the gen
NavigationListItem type has no typed Name setter, so it's added as a raw
property via addStr, mirroring the legacy writer's `Name` key — and (2)
names the caption's DynamicText `text_<item>` (same as legacy) and sets
its render mode.

Test: TestNavListItemToGen_WritesNames (encode → both names present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…ecimals

Corrects the earlier ledger #17-19 fix, which over-broadly preserved raw
expression source whenever it saw a '/'. In MDL '/' is the member-access
separator (`$obj/Attr`), not division (that is `div`), so preserving on '/'
source-froze every association-navigation path and regressed
TestAssociationNavParsing.

- shouldPreserveExpressionSource: drop the '/' trigger; keep the decimal-literal
  trigger (findings #18/#19 — `2.0`->`2`, `0.000001`->`1e-06` are genuine
  AST-serializer losses that raw-source preservation fixes).
- New MDL045: walk the microflow expression tree for a BinaryExpr with operator
  '/' (`$Dec / 2`, `(...) / $x`) and reject with an actionable "use div" message,
  instead of silently writing an invalid expression that fails the build (CE0117).
  The bare `$a / $b` form degrades to a member path and is caught by
  `check --references`.

Tests: TestShouldPreserveExpressionSource_Decimals (renamed),
TestValidateMicroflow_SlashDivision. Repros: ledger-17-19 (passes) +
ledger-17-slash-division.fail.mdl (MDL045). Skill + symptom-table updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…MDL-WIDGET14)

A template-parameter slot is a data binding (an attribute path), not a
client-side expression: mxcli stored any unquoted contentparams/captionparams
value as an attribute path, so a function call like
`formatDateTime($obj/LastImport, 'd MMM yyyy')` became a bogus attribute name and
Studio Pro rejected the page with CE1613 "attribute no longer exists".

Add MDL-WIDGET14 (validateTemplateParamExpressions, called from
validateStaticWidget): for each ContentParams/CaptionParams value, skip quoted
string literals, then flag a function call or arithmetic/comparison operator as
an expression — an attribute path never matches. The message tells the user to
precompute the value onto the bound entity (a calculated attribute) and bind
that attribute instead.

Test: TestValidateTemplateParamExpressions. Bug-test:
contentparam-expression-rejected.fail.mdl. Symptom-table updated. Ledger #26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…otchas

Three check-time hints for constructs that pass `mxcli check` but surprise at
build or render time (ledger findings #21/#25/#27):

- MDL046: dateTime()/dateTimeUTC() with a non-literal argument (CE0117 — these
  build from hardcoded constants only). Hint: step off a literal anchor with
  addDays()/addMonths(), which take variables.
- MDL047: an association compared to `empty` in a retrieve constraint
  (`[Module.Assoc = empty]` → CE0161 — `= empty` tests attributes, not
  associations). Hint: `[not(Assoc/Target)]`. A bare attribute and an
  attribute-over-association test are correctly not flagged.
- MDL-WIDGET15 (info): two or more adjacent dynamictext siblings, which Mendix
  renders inline (concatenated, no separator) regardless of RenderMode. Info
  severity — advisory only, never fails the build.

Finding #20 (division operand typing) is covered by the existing MDL041
(div→Decimal) and the new MDL045 (`/`-as-division) rather than a separate rule
that would contradict CLAUDE.md's documented "integer div yields Decimal".

Tests: TestValidateMicroflow_DateTimeLiterals, _XPathAssociationEmpty,
TestValidateConsecutiveDynamicText. Repros in mdl-examples/bug-tests/. Skills +
symptom table updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Re-test of PR #52 by the ledger project surfaced three gaps:

- #17 (MDL045): the variable/variable division form `$Dec / $Dec2` was not
  caught — it parses as a member-access path, so the BinaryExpr walk missed it.
  The visitor now narrowly preserves source when a `/` is immediately followed by
  `$` (a real association path never has `$` after `/`, so legit navigation is
  untouched), and MDL045 flags a source-preserved AttributePathExpr whose `/ $`
  source matches (`exprIsSlashDollarDivision`). The `$` guard on the RHS is the
  reliable division-vs-navigation discriminator the tester suggested.

- #25 (MDL047): the check only saw microflow `retrieve` constraints, so an
  association `= empty` in a page/widget datasource where-clause slipped through.
  Now `validateDatasourceXPathAssociationEmpty` inspects `DataSourceV3.Where` on
  every widget, sharing the detection via `xpathAssociationEmptyMatches`.

- #27 (MDL-WIDGET15): the advisory false-positived on heading+subtitle pairs.
  Only `Text`/unset RenderMode is inline; H1–H6/Paragraph are block-level and do
  not concatenate. `inlineDynamicText` now excludes block modes, so a heading
  breaks the run.

Tests updated (variable/variable division, page-datasource MDL047, heading
exclusion). Repros: ledger-17-slash-division.fail.mdl (+ var/var form),
new ledger-25-page-datasource-assoc-empty.fail.mdl. Symptom table refreshed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Verification round found that the #27 narrowing wrongly treated `Paragraph`
render mode as block-level. On Mendix 11.12.1 + Atlas a Paragraph-mode
dynamictext renders as an inline `<span>`, so two adjacent Paragraph widgets fuse
(`PARA-ONEPARA-TWO`) exactly like Text-mode ones — and the advisory was silently
missing them.

`inlineDynamicText` now treats ONLY H1–H6 as block-level (`headingRenderModeRe`);
Text/unset and Paragraph are both inline. The advisory message is corrected to
recommend a heading RenderMode (not Paragraph, which does not fix the problem).

Tests updated: Paragraph+Paragraph and Paragraph+Text now flagged, heading pairs
still excluded. Repro ledger-27 extended to show all three cases. Symptom table
notes the lesson: verify Mendix render behavior empirically — a name like
"Paragraph" does not imply `display: block`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
- #37: write-oql-queries.md RULE 2 said "NEVER use ORDER BY or LIMIT in a view",
  but mxcli's MDL030 *requires* ORDER BY to be paired with a LIMIT (ORDER BY alone
  → CE0174; ORDER BY + LIMIT builds clean). Reworded RULE 2 + the Mistake 8,
  Step 9, and checklist entries to match the tool, and documented UNION/UNION ALL
  as a supported construct (it worked end-to-end but was undocumented).
- #32: MDL001 (nested-loop) reworded — it now distinguishes a key LOOKUP (use
  FIND) from intentional aggregation that must visit every element (correct as
  written), so it stops reading as "your loop is wrong" on report pivots.
- #31: documented that a variable first created inside an if/else arm (including
  by `$Var = call ...`) is scoped to that arm — declare before, assign in each.
- #34: run-local.md notes the Dart Sass `rgba()` comma-list trap (a theme compile
  error fails the build step pre-runtime).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A pass-through view column (a bare source-attribute ref like `select c.Name as
X`) inherits the source attribute's length verbatim, and Mendix requires the
declared length to match EXACTLY — declaring an unbounded `string` (or a
different `string(N)`) against a `string(100)` source fails the build with CE6770
"View Entity out of sync". MDL031's length check previously covered only DERIVED
columns (cast/case → 200); the references-mode validator compared pass-through
strings with `typesCompatible`, whose `>=` rule only guards truncation, so the
mismatch slipped through.

`validateViewEntityTypes` now checks pass-through string columns with
`passthroughStringLengthMismatch` (exact match), reporting the source entity/attr
and inherited length. `col.SourceAttr` is set only for a bare attribute ref, so
aggregates/derived expressions are unaffected.

References-mode only (needs the domain model to resolve the source length).
Test `TestPassthroughStringLengthMismatch`; example
mdl-examples/bug-tests/ledger-36-view-passthrough-length.mdl. Verified on a real
Mendix 11.12.1 project: `string`→CE6770 flagged, `string(100)`→clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Two entity-correctness fixes from the ledger project, both verified end-to-end on
Mendix 11.12.1.

#41 (CE6771 check gap): `create association from/to <ViewEntity>` passed
`mxcli check --references` and exec, but `mx check` rejects it — associations
to/from view entities are impossible. The reference checker now resolves both
endpoints and rejects when either `isViewEntity` (new helper), naming it and
pointing at a non-persistent alternative. Both directions caught.

#39 (mx check crash): a `create or modify entity` dropping an indexed attribute
left the index orphaned (its column referenced a removed GUID), and `mx check`
CRASHED loading the project with an unhandled AggregateException. Two parts:
- executor `reconcileDroppedIndexes`: when the statement lists no indexes, carry
  existing ones forward, pruning columns for dropped attributes (attr IDs survive
  by name) and dropping empty indexes — fixes partial drops (index(A,B)→index(A)).
- backend `UpdateEntity`: an untouched empty Indexes PartList is "clean", so the
  codec passes the raw orphan through; force it dirty (add+remove) so the codec
  re-emits an empty list, clearing the raw — fixes the all-removed case.
Verified: `mx check` → 0 errors (previously crashed).

Tests: TestIsViewEntity, TestReconcileDroppedIndexes. Examples + symptom table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Two check gaps confirmed with mx check on Mendix 11.12.1 — both pass mxcli check
but fail the build:

- MDL048 (#42): `retrieve … where [id = $Var]` → CE0161. Mendix XPath has no id
  operator reachable from a microflow expression. Regex a word-boundaried `id`
  before a comparison (a user attribute can't be named `id`). Hint points at a
  GUID marketplace action OR exposing the id as a String on a view entity via
  `cast(id as string)` and constraining on that column.
- MDL049 (#43/#44): a call argument bound to an association-object path
  (`B = $Edit/M.Edit_Budget`) → CE0117. An association path isn't a value; it must
  be retrieved first. Flags an AttributePathExpr whose final segment is
  module-qualified (an association → object); an attribute value over the same
  association (`…/Name`) is not flagged.

Tests: TestValidateMicroflow_XPathIdConstraint, _AssociationObjectArg. Repros +
symptom table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
#45 (alter-page.md): document three ALTER PAGE limitations the ledger project hit,
with the recommended "bind at page creation" pattern — SET cannot rewire a
button's action; REPLACE cannot reuse a widget name from the subtree being
replaced (built before the old is removed → collision); a footer is a marker and
is not addressable by its author-given name (serialized as footer1). These are
design constraints, so the guidance is to define save/reset microflows before the
page and bind buttons at creation rather than altering the page afterward.

#46 (theme-styling.md): DataGrid2 renders role=grid/row/gridcell <div>s, not
table/tr/td — CSS and Playwright must target the ARIA roles; and column `Size` is
a flex weight, not pixels — set a min-width on [role='grid'] with overflow-x:auto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
… ledger cases

Wave 1 (MDL-WF01/02/03, MDL-BUTTON01 — cases 1/2/4/5) is shipped. Record the
~10 ledger-driven checks added on the same pattern (MDL045-049, MDL-WIDGET13/14/15,
MDL031 pass-through, view-entity association CE6771) plus the two write-path parity
fixes (#24 navlist names, #39 orphaned index), and note the two originally-
cataloged gaps that remain: case 3 (CE7412) and the standalone case-6 warn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The CE7412 user-task-page-context check is FP-prone and needs the real trigger +
FP boundary verified against `mx`. No current test app uses workflows, so record
the deferral rather than implement it blind. Pick up when a workflow app enters
the test rotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Ledger finding #48 reported "association navigation must be the whole RHS or it
fails CE0117". Verification against mx check (11.12.1, ~15 isolation cases)
disproved that rule — `'x' + $obj/Assoc/Attr`, `toString(..) + $obj/Assoc/Attr`,
and a bare `set $x = $obj/Assoc/Attr` all build clean. The real trigger is
narrower: a RENDER function (formatDateTime/formatDecimal/…) that takes, or
co-occurs with, an association-navigated value:

  formatDateTime($obj/Mod.Assoc/Date, 'd MMM')             -> CE0117
  formatDateTime($obj/Date, 'd MMM') + $obj/Mod.Assoc/Name -> CE0117

while `formatDateTime($obj/Date, …)` (plain member access) is fine.

MDL050 flags a set/declare/return value that contains BOTH a `format*`-family
call AND an AttributePathExpr with a module-qualified (association) segment. The
curated function set (formatDateTime/DateTimeUTC/Decimal/Float/Boolean) means no
false positives — toString/length + assoc-nav build clean and are not flagged;
an untested render fn is a conservative miss. Fix: materialize the associated
value into a variable first. `make check-mdl` shows no existing example regressed.

Test: TestValidateMicroflow_FormatWithAssociation. Repro:
ledger-48-format-with-association.fail.mdl. Symptom table records the corrected
rule + the lesson (reproduce the passing neighbours, not just the failing case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A `break` nested inside an if/case within a loop serializes a dangling
sequence-flow reference — `mx check` then CRASHES loading the project with an
unhandled System.AggregateException ("key … not present in the dictionary"), an
unrecoverable failure (same severity class as #39). A break placed directly in
the loop body serializes fine; only the useful `if <cond> then break` form is
affected.

The underlying flow-graph serialization bug is still open (a write-path fix).
MDL051 is the interim guard: on a LoopStmt, flag a break nested in an
if/case/inheritance-split (not descending into nested loops, which trap their own
break). Since the pattern currently CRASHES, a check-time rejection is a strict
improvement. Fix for the user: a guard variable (declare $Done Boolean = false; …
set $Done = true) — verified clean on mx check (11.12.1).

Test: TestValidateMicroflow_ConditionalBreak. Repro:
ledger-52-break-in-conditional.fail.mdl. Symptom table notes the real fix is the
write-path serialization; MDL051 is the interim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`contains` is overloaded — the LIST operation contains(list, object) and
the STRING function contains(haystack, needle) — but the visitor
unconditionally rewrote `set $x = contains(...)` into a ListOperationStmt,
dropping literal arguments entirely. String contains was therefore
unusable from MDL: a pre-declared Boolean output collided (CE0111
"Duplicate variable name"), and a String input to a list operation is
otherwise rejected (CE0023/CE0097).

Two-part fix:
1. Visitor (buildSetStatement): a literal or computed argument means the
   string function unambiguously — fall through to MfSetStmt (a Change
   Variable value expression) instead of a lossy list operation. Only the
   both-plain-variables form stays a ListOperationStmt (kind unknown at
   parse time).
2. Flow builder (addListOperationAction) + validate pre-pass: when the
   input variable is a declared String, emit a Change Variable action
   carrying `contains($In, $Second)` rather than a ContainsOperation, and
   validate the target as a pre-declared SET (no CE0111 duplicate).

The genuine list form (list variable + object variable) is unchanged.

Verified with `mx check` on a fresh Mendix 11.12.1 project: 0 errors for
the literal-arg, two-String-variable, and genuine-list forms.

Tests: TestContainsOverloadParsing (visitor), TestBuildContains_StringVsList
(builder). Example: mdl-examples/bug-tests/ledger-53-string-contains.mdl.
Symptom row added to fix-issue.md; string-vs-list contains documented in
write-microflows.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A datagrid column with an explicit empty caption (`Caption: ''`) passed
`mxcli check` but `mx check` rejected the page with CE0463 "The definition
of this widget has changed" on the Data grid 2. Omitting the caption, or
giving it a non-empty string, both built cleanly.

Root cause: the pluggable widget engine's column-header fallback
(applyColumnHeaderFallback) treated a present-but-empty header property as
"has header" and skipped the attribute-name default — so an explicit
`Caption: ''` emitted an empty header (which Studio Pro rejects), while an
absent caption got the fallback. The keyword datagrid path already handled
this via `if caption == "" { caption = col.Attribute }`.

Fix: detect an empty header (a texttemplate op with empty TextTemplate and
no Parameters) and treat it like an absent one — fill it in place with the
bound attribute's leaf name rather than leaving it empty or appending a
duplicate `header` property. A header with params (`Caption: '{1}'`) or a
column with no bound attribute is left untouched.

Verified with `mx check` on a fresh Mendix 11.12.1 project: 0 errors
(previously CE0463). Test TestApplyColumnHeaderFallback (cases 5/6);
example mdl-examples/bug-tests/ledger-54-empty-column-caption.mdl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…ledger #57)

A plain `commit $Obj;` updates committed attribute values in a grid, but a
grid backed by a database datasource does not re-run its sort — so after a
reorder that rewrites a sort key, the row keeps its old position until
`commit $Obj refresh;` re-queries the datasource. Added the specific gotcha
to the commit best-practice note in write-microflows.md; the generic
refresh note didn't capture the re-sort case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
MDL045 flagged `$a / $b` division only when it was the entire set-value
expression. When the division was embedded in a larger expression —
`$a / $b + 1`, `round($a / $b)`, `$a / $b * 100` — the `$a / $b` part
degrades to a member-path AttributePathExpr nested under a BinaryExpr or
FunctionCallExpr, and the check silently missed it (mxcli check passed but
mx check fails with CE0117). Only the bare form and a chained
`$a / $b / $c` were caught.

Root cause: exprIsSlashDollarDivision matched only when the source-preserved
SourceExpr *directly* wrapped an AttributePathExpr, so a division nested one
level down slipped through.

Fix: replace it with exprHasSlashDollarDivision, which walks the whole
expression tree for any SourceExpr and scans its raw source for a `/ $`
(slash before a variable) using a string-literal-aware scanner. A real
member/association path never writes `/$`, and the scanner skips `/$`
inside quoted literals (e.g. 'path/$x') to avoid false positives. The
division is now caught wherever it appears — in additions, multiplications,
function arguments, return values, and if-conditions.

Tests: TestValidateMicroflow_SlashDivision extended with embedded cases and
the string-literal guard; ledger-17-slash-division.fail.mdl exercises the
embedded forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…edger #48 root cause)

`$T/Mod.Assoc/Attr` in a microflow expression (Change Variable, if-condition,
return) passed mxcli check but mx check rejected it with CE0117. Mendix
expression syntax needs the target-entity step in the path
(`$T/Mod.Assoc/Mod.Entity/Attr`), which the builder inserts via
resolveAssociationPaths — but two bugs skipped that insertion:

1. shouldPreserveExpressionSource treated a `.` preceded by a digit as a
   decimal literal, so a module/entity name ending in a digit (`L48.`,
   `Account2.` — very common) froze the whole expression's source and
   bypassed association resolution, writing `$T/L48.Assoc/Attr` with no
   entity step.
2. A legitimately source-frozen expression (a real decimal literal or
   preserved whitespace) also bypassed resolution, so an association nav
   co-occurring with a decimal (`$T/Mod.Assoc/Bal + 2.0`) broke even for
   non-digit modules.

Fixes:
1. A `.` is a decimal point only when its adjacent digit run is a standalone
   number — not when the preceding token contains a letter or underscore (a
   qualified-name segment). New dotIsQualifiedNameSeparator distinguishes
   `L48.Transaction` (name) from `2.0` (decimal).
2. For a source-preserved expression, rewrite association paths inside the
   raw source: for each AttributePathExpr, compare its rendered form before
   and after resolution and ReplaceAll in the source when they differ. This
   keeps decimal/whitespace fidelity while inserting the entity step.

Verified with mx check on Mendix 11.12.1: 0 errors for plain
attribute-over-association navigation, an if-then-else enum comparison over
an association, and an association nav mixed with a decimal literal — all
previously CE0117.

Example: mdl-examples/bug-tests/ledger-48-assoc-nav-digit-module.mdl.
Test: TestShouldPreserveExpressionSource_Decimals extended with
digit-ending qualified-name cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
MDL050 flagged expressions combining a format function with an association
navigation (e.g. formatDateTime($obj/Mod.Assoc/Date, …)) as CE0117. Once the
real #48 root cause was fixed — association navigation dropped its
target-entity step — re-verification against mx check on 11.12.1 showed the
rule's premise was wrong on both of its cases:

- formatDateTime($obj/Assoc/Date, …) now builds CLEAN (it only failed because
  the entity step was missing, which affected ALL association navigation, not
  just inside format calls). MDL050 was rejecting valid code.
- formatDecimal($x, 2) fails CE0117 even on a PLAIN local decimal with no
  association — a formatDecimal-signature bug, unrelated to associations.

So the "format-function + association" correlation was an artifact of two
independent bugs, neither about format functions. Removed the check, its call
sites, its test, and its bug-test.

The remaining real issue — formatDecimal($x, precision) failing CE0117
regardless of associations — is a separate open finding (likely a wrong
function signature/arity) to investigate and fix in the executor or the
function-name checker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`set $At = find($Raw, '"id":"')` — the string function find(haystack,
needle) returning a substring index — was serialized as a List operation
activity 'Find by expression'. A list op creates its output variable, so a
pre-declared `$At` collided: mx check reported CE0111 "Duplicate variable
name". String find() was unusable from MDL — the same overload trap as #53
(contains).

Two-part fix (mirrors #53):
1. Visitor: a string-literal second argument is unambiguously the string
   function (you never filter a list by a bare string literal) — fall
   through to MfSetStmt. A boolean condition or both-plain-variables stays a
   ListOperationStmt.
2. Flow builder + validate pre-pass: when the input variable is a declared
   String, emit a Change Variable carrying `find($in, <arg1>)` instead of a
   list FindOperation, and validate the target as a pre-declared SET (no
   CE0111). The contains/find String-input handling now shares one branch.

The genuine list form find(list, condition) is unchanged.

Verified with mx check on Mendix 11.12.1: 0 errors for the string-literal,
two-String-variable, and genuine-list-condition forms.

Tests: TestFindOverloadParsing (visitor), TestBuildContains_StringVsList
find cases (builder). Example:
mdl-examples/bug-tests/ledger-63-string-find.mdl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Two loops in the same microflow reusing the same iterator name
(`loop $R in … end loop; loop $R in … end loop`) passed mxcli check but
mx check failed with CE0111 "Duplicate variable name 'R'." at Loop. A Mendix
loop iterator is scoped to the whole microflow, not to its loop, so the
second loop re-creates an existing variable.

New MDL052: walk the microflow body (recursing through if/case/while/loop
bodies) collecting LoopStmt iterator names and flag any name used by a
second loop. Catches sequential and nested reuse. Distinct iterators, a
single loop, and the same name across different microflows are fine.

Test: TestValidateMicroflow_DuplicateLoopVariable. Repro:
mdl-examples/bug-tests/ledger-64-duplicate-loop-variable.fail.mdl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
An action property on a pluggable widget — e.g. `onClick: microflow …` on a
DataGrid2 — passed mxcli check --references and mx check, built and ran, but
was silently discarded: DESCRIBE omitted it and the model round-tripped
minus the action, with no error or warning.

Root cause: the .def.json generator never emitted an `action` operation for
`type="action"` properties (across the generated defs, no action operation
existed at all). The writer reads only propertyMappings from the def, so
with no action mapping it had nothing to write. The .mpk correctly declares
the action slot — the gap is purely in generation.

Fix: emit an `action` PropertyMapping for the action slots MDL can author —
`onClick` → source OnClick (the widget's Action property / onClick: alias)
and filter `onChange` → source OnChange (new resolveMapping case). The
engine's existing applyOperation "action" → SetAction path (nil-guarded)
then serializes the ClientAction with its parameter mapping. Non-authorable
action slots (DataGrid2 onSelectionChange/onConfigurationChange) return ""
from actionSourceForKey, so no mapping is emitted. WidgetDefGeneratorVersion
bumped 12→13 so existing projects regenerate their defs.

Verified end-to-end with mx check on Mendix 11.12.1: 0 errors, and the
DataGrid2 persists the onClick microflow action with its Thing:
$currentObject mapping. (The DESCRIBE read path still omits pluggable-widget
actions — a separate read gap like #57/#58; the write bug is fixed.)

Test: TestGenerateDefJSON_ActionMapping. Example:
mdl-examples/bug-tests/ledger-67-pluggable-widget-action.mdl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…s#67 read gap)

After the mendixlabs#67 write fix, DESCRIBE PAGE still omitted a DataGrid2's onClick
action, so a describe round-trip silently lost it. The datagrid2 describe
path read datasource + columns + paging but never read the widget-level
action, and the param-aware renderClientActionMDL reader (used for
button/onchange) was never called for a pluggable widget's action.

New customWidgetPropertyActionMap returns the raw Forms$*ClientAction map
for a CustomWidget property (NoAction → nil). The datagrid2 parse branch
renders it via renderClientActionMDL (param-aware) into rawWidget.OnClick;
the output branch emits `onClick: <action>`.

Verified end-to-end: describe → re-exec → mx check 0 errors, and the
round-trip preserves the full `onClick: microflow
L67.OnRowClick(Thing: $currentObject)` including the parameter mapping.

Test: TestCustomWidgetPropertyActionMap. (Gallery and other pluggable
widgets with an action can be wired the same way — datagrid2 is the
verified case.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
…xlabs#67 CustomChart)

Finding mendixlabs#67 was reported on the Charts CustomChart widget. My earlier
describe read-gap fix wired only the datagrid2 branch; CustomChart is
described through the generic `pluggablewidget '…'` path
(!isKnownCustomWidgetType), so its onClick was still omitted by DESCRIBE
even though the write path persisted it.

Wire the onClick read into the generic pluggable branch too: read the
action map via customWidgetPropertyActionMap, render it with
renderClientActionMDL (param-aware) into rawWidget.OnClick, and emit
`onClick: <action>` in the generic pluggablewidget output. The output guard
also fires on w.OnClick != "" so an action-only widget still gets its
pluggablewidget header.

Verified end-to-end on the actual CustomChart widget (Mendix 11.12.1):
mx check → 0 errors; the CustomChart persists the onClick microflow action
with its Data: $currentObject mapping; and describe → re-exec → mx check
round-trips the onClick (the param name re-parses as a quoted identifier).

Example: mdl-examples/bug-tests/ledger-67-customchart-action.mdl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
 guard)

A real widget property the generator doesn't map to a write path could be
silently dropped (or, via an alias, slip through) with no diagnostic — the
same failure mode as mendixlabs#67 for any unmapped property type (expression, icon, a
future type, an action slot with no MDL surface).

The WidgetDefinition.KnownProperties field and the MDL-WIDGET06 "recognized
but not persisted" check already existed, but the generator never populated
KnownProperties, so the warning never fired: an unmapped property either
false-errored as MDL-WIDGET01 "no such property" or slipped through silently
via an alias.

Populate KnownProperties purely from the two artifacts mxcli already has:
every .mpk-declared property key with no mapping in the generated def
(subtract PropertyMappings + aliases + ChildSlots + ObjectLists + mode
mappings from the full key set). No per-widget knowledge. The existing
check then warns "recognized but not persisted — the value will be dropped."

Three-way discrimination verified on DataGrid2 (11.12.1): a mapped property
(onClick) is silent, a real-but-unmapped one (rowClass) warns MDL-WIDGET06,
a truly-unknown one (totallyBogusProp) still errors MDL-WIDGET01.
WidgetDefGeneratorVersion bumped 13→14 so existing projects regenerate.

This is the tester's suggested general check — it catches the whole class
without guessing which widgets support what; the action mapping remains the
specific fix for onClick.

Test: TestGenerateDefJSON_KnownPropertiesUnmapped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
claude and others added 2 commits July 29, 2026 09:55
… round 2)

The first #54 fix handled attribute-bound columns (fall back to the
attribute name), but left a custom-content column — a column with child
widgets / an actions button and NO bound attribute — untouched: it had
nothing to derive a header from, so an empty `Caption: ''` OR an absent
caption still tripped CE0463. A custom-content column requires a non-empty
header.

Fix: applyColumnHeaderFallback now falls back to the column's own NAME when
there's no bound attribute, and the whole fallback is gated on the item
template actually having a `header` slot (checked via mapping.ItemProperties)
so header-less object-list items (chart series, accordion groups) are never
given a spurious header.

Verified on Mendix 11.12.1: mx check → 0 errors for a custom-content column
with an empty caption AND with no caption (both previously CE0463); attribute
columns and chart series are unaffected.

Test: TestApplyColumnHeaderFallback (cases 1–8, incl. custom-content).
Example: mdl-examples/bug-tests/ledger-54-custom-content-column.mdl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Fix ledger project findings: expression/XPath/widget checks + doc fixes
@ako
ako merged commit d86fb69 into mendixlabs:main Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants