Skip to content

Sync ako/mxcli: widget icon elements (#1059), catalog graph analysis, DESCRIBE NORMALIZED - #1095

Merged
ako merged 17 commits into
mendixlabs:mainfrom
ako:main
Sep 14, 2026
Merged

Sync ako/mxcli: widget icon elements (#1059), catalog graph analysis, DESCRIBE NORMALIZED#1095
ako merged 17 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Ten commits from ako/mxcli:main, in five groups. Upstream issues closed: #1059, #1060, #1056, #923.

A widget's icon is one of three elements, not one string (#1059)

describe pagecreate or replace page rebuilt a button's icon as the wrong element, or deleted it. Mendix stores three icon elements and two of them put a qualified name under the same BSON key:

$Type payload points into
Forms$IconCollectionIcon Image CustomIcons$CustomIcon
Forms$ImageIcon Image Images$Image — a different document
Forms$GlyphIcon Code (int) a font code point, no name at all

The page describer read Image off whichever element was there and discarded the $Type; the builder wrote Forms$IconCollectionIcon unconditionally. So an image icon came out as a bare Icon: '<qn>' and went back in as a custom-icon reference — [CE1613] "The selected custom icon … no longer exists." A glyph icon was worse: describe emitted nothing for it at all, and since CREATE OR REPLACE PAGE is a full replacement, replaying that output removed it at exit 0 with a success message. On a stock marketplace project's FeedbackModule pages, three glyph icons per page went from zero mentions in describe output to three.

This is not a limit on third-party collections, as the report read it — a third-party icon collection round-trips fine, and CE1613 naming the reference is itself the evidence the name was never a custom icon.

  • fix(describe) — the describer tells the variants apart and flags what it cannot author, the unrecognised $Type included.
  • feat(pages)Icon: image <qn> and Icon: glyph <n> become authorable, so the round trip is lossless rather than merely honest. The bare form stays the icon-collection icon, so no existing script changes meaning. Same vocabulary and same ANTLR ordering trap as navigation's icon image / icon glyph; pages.Icon carried its own three-value enum for the same three elements, which is the drift that produced the bug, so it now uses the shared types.MenuIconKind.

Three further defects surfaced by the fix: the legacy engine never wrote a button icon at all (Icon: nil, hardcoded, so under --engine legacy it was dropped on every write, silently); check --references had the mirror-image bug waiting, resolving image references against the icon collections and reporting a correct name as a typo; and MDL078 walked the statements itself and knew only about menus, so every widget glyph would have passed in silence.

Verified on 11.13.0: all three authored, mx check 0 errors, mxbuild --target=deploy BUILD SUCCEEDED, right $Type on disk. Controls reproduce CE1613 (image written without image) and the glyph build failure — the latter corrected the rule's own message, since mxbuild names the page, not a layout.

Catalog: an empty graph table vs one that was never computed (#1060)

  • fix(catalog)CATALOG.GRAPH_CYCLES answered 0 rows on a project whose GRAPH_MODULE_COUPLING listed mutual module pairs, so it read as "no circular dependencies" from a table that had never been built. Coupling is a plain view over refs that any full catalog answers; cycles/communities/layers/centrality are written only by REFRESH CATALOG COMMUNITIES — and six of those were in neither fullOnlyTables nor sourceOnlyTables, so nothing warned. A full rebuild also dropped them silently while the recorded mode stayed full.
  • feat(catalog) — the case where the pass has run and the answer still is not the one asked. GRAPH_CYCLES is SCCs of the asset graph; two modules referencing each other through different documents form no cycle among those documents, which is the ordinary shape of a module cycle. Adds CATALOG.GRAPH_MODULE_CYCLES and CATALOG.GRAPH_ANALYSIS_SCOPE, which makes the edge scope visible instead of implicit.

DESCRIBE MICROFLOW: two ways a description disagreed with the model

  • feat(describe) Mode 3 — DESCRIBE MICROFLOW … NORMALIZED (roundtrip for @merge does not work #923) — nested if/then/else is single-entry/single-exit; a Mendix microflow is an arbitrary digraph. Where an inner split's branch lands on an outer split's, DESCRIBE walked the graph as a tree and emitted the conjunction along one path: the reporter's activity ran on not(c1) or c2 and was described as c1 and c2 — a program that always logged, described as one that never did, with mx check clean throughout. NORMALIZED folds the branch guards into one equivalent condition and duplicates nothing.
  • fix(describe) — DESCRIBE walked straight through an ExclusiveMerge with a single incoming path, so describe → exec deleted the node, silently: no warning, no MDL-FLOW01 (the graph is reducible, so the detector is right to stay quiet), mx check clean either way. Measured across 21 microflows on 11.14: in-degree 1 deleted 7 of 7, everything else survived with its $ID. VAL_Feedback loses 5 of its 10 merges.
  • docs — settles four open questions on the microflow-description proposal, including the short-circuit measurement that unblocked Mode 3. The probe is integer division by zero in the right operand, with positive controls that force the right operand to be reached and must therefore throw — without them, the tests passing is equally well explained by 1 div 0 being harmless.

Widget properties: two shapes that checked clean and built broken

Test hygiene

  • test(widget) — the three new MDL-WIDGET27 tests passed locally and failed in CI on the same commit. They resolved the widget through .def.json files under a gitignored directory, so they exist for anyone who has run mxcli widget docs against the fixture and never on the runner. The fixtures are now derived rather than read.

Every item carries its measurement in the commit body, and each silent-drop fix carries the control that reproduces the loss.

claude and others added 17 commits September 9, 2026 19:53
`widget describe` said nothing about 23 properties Combo box's editor hides,
and its MDL example offered two database-only properties for a combobox with
`source: 'context'`, where the editor shows neither.

hideTargetKeys read a hide call's property list by trimming a leading `[` and a
trailing `]`. Combo box builds its biggest lists by concatenating module-level
arrays onto the literal one:

    "context" === t.source && hidePropertiesIn(e, t, [ …3 literals… ].concat(N))

where N is ten database properties. The trim never found the `]` (the argument
ends in `)`), so the split stopped at the first element and every concatenated
name was dropped — silently, because the call still counted as recognized. The
counter reported full coverage of a list it had read a third of.

Parse the array with a balanced scan and resolve the trailing `.concat(...)`
against a map of module-level string-array bindings. Resolution withholds
rather than guesses: an argument that is neither an inline string array nor a
known identifier — `.concat(n(b.static))`, a call on a computed key — makes the
chain unresolvable and the literal keys stand alone; an identifier bound twice
to different arrays is dropped, since the minifier reuses short names.

Measured over the 42 describable widgets (population from the .mpk files, not
the 33 .def.json — Combo box has none):

  displayed rules      332 -> 355  (+23, all Combo box)
  rules lost                    0  (set comparison; a line diff reports five
                                    false losses from re-ordering)
  per-widget regressions        0
  example blocks changed        1  — Combo box, losing exactly the two wrong
                                    bindings
  recognized counter     180 -> 180, correctly: this changes what a recognized
                                    call contributes, not which are readable

Each gain traces to a concat list: 10 = N under `source === "context"`,
9 = W+z under the enumeration/boolean branch, 2+2 = z under the two caption-type
else branches. 14 concat sites exist in the fixture; 6 resolve, 8 are computed
at runtime and keep today's literal-only behaviour.

Controls: the .mpk regression test fails without the fix naming all eight
properties; each of the three refusals fails its own case when stubbed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
mendixlabs#1056. `customAllSelected: []` on a pluggable widget passed
`mxcli check -p` with zero diagnostics, exec printed "Created page", the stored
page carried 0 CustomWidgets$WidgetProperty entries, and mxbuild then reported
3x CE0642 "Property 'All selected' is required."

mendixlabs#999 built `[(k: v)]` into its own AST type so MDL-WIDGET27 could report it, but
keyed the rule on that type — which the visitor only produces when the brackets
contain a parenthesised entry. An empty `[]` falls through to the generic
`[expr, …]` branch and becomes an empty []string no writer claims: mendixlabs#999's silent
drop reached by a different spelling. A scalar never looked like a list at all.

The rule now covers all three, with deliberately different gating:

  p: [(k: v)]   shape   error (unchanged)
  p: []         shape   error — writes nothing under every current writer, so it
                        needs no project, which matters because check-mdl runs
                        .fail.mdl files without one
  p: 'x'        type    error ONLY when the widget resolves — without a
                        definition this is the ordinary property form and
                        flagging it would be a guess

Keyed on EMPTINESS, never on the brackets: `visible: [$x != '']`, `editable:
[true]` and a filter's `attributes: [Name]` are how MDL spells those properties,
and a guard test pins that they stay silent.

A child slot and an object list spell their remedy differently — `kw name { … }`
against `kw name (…)` — so containerKeyword reports which, or the message would
print an example that does not parse.

The printed remedy is verified end to end: pasted verbatim it checks, execs, and
adds 0 errors to an mx check (11.6.6) where the page written before this rule
existed still contributes its 3 CE0642 — the control pair in one build.

The reporter only reached the wrong spelling because the right one was rejected;
that half was already fixed by bca5466, four days after their build. Confirmed
by building their exact commit in a worktree: it reproduces their error verbatim
and HEAD parses, execs and writes the same five WidgetProperty entries Studio
Pro does. A guard test pins that the slot form `widget docs` emits parses, so
the two halves cannot regress independently — a parser that rejects the right
spelling would turn this error into a dead end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
…tignored ones

The three MDL-WIDGET27 tests added in d614807 passed locally and failed in CI
on the same commit: two got the fallback remedy instead of the container
keyword, the third found 0 violations.

They resolved the widget through LoadWidgetRegistry(fixtureProject(t)), which
reads .def.json files from testdata/expr-checker/.mxcli/widgets/. That
directory is GITIGNORED — the definitions are derived, not tracked — so they
exist for any developer who has run `mxcli widget docs` against the fixture (I
generated them earlier in the same session, investigating the issue) and never
exist on the runner. With no definition, containerKeyword returns "" and the
two definition-dependent branches degrade exactly as designed.

fixtureProjectWithDefs copies the fixture to a temp dir, removes any .mxcli the
developer's tree carries, and derives the definitions from the tracked .mpk
files with RefreshWidgetDefinitions. Local and CI now see identical inputs, and
the generated files stay out of the fixture other tests copy.

Also tightens the mendixlabs#999 assertion, which is why the suite did not catch this:

    strings.Contains(msg, "attribute")

on a widget whose property is named `attributes` is satisfied by the property
name alone, so it passed with no definition loaded. It now asserts the remedy
shape, which only a resolved definition produces.

Reproduced by moving .mxcli aside — same three failures, same messages. Control:
with the derivation stubbed, all FOUR fail, where before only the three new ones
did. Restored, full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Widget describe reads concatenated hide lists; check refuses a container property written as a value
Two runtime/storage measurements and the bookkeeping Phase E earned.

SHORT-CIRCUIT (was Q1, blocked Mode 3). Both `and` and `or` short-circuit on
Mendix 11.14.0, so folding a guard cannot introduce an evaluation the original
avoided and Mode 3 is unblocked — the contemplated fallback ("restrict to
conditions proven total, or drop") is not needed. The purity/ordering
precondition is untouched.

The probe is integer division by zero in the right operand, an expression's only
observable given Mendix expressions are pure. What makes it a measurement is the
pair of POSITIVE CONTROLS that force the right operand to be reached and must
therefore throw: without them, the short-circuit tests passing is equally well
explained by `1 div 0` being harmless. Both operands read microflow parameters
rather than literals so mxbuild cannot constant-fold the expression, which would
have measured its folding instead of the runtime's evaluation order. Shipped as
a fixture pair, verified end to end on a clean project: 4/4.

TRANSPLANTIDS ON UNNAMED MERGES (was Q4). IDs survive; positional matching does
not churn them. Measured four ways — re-exec of identical MDL (elided), a FORCED
write under MXCLI_ALWAYS_WRITE=1, a describe -> exec round trip, and an edit
inserting an activity before the merges so every position shifts. The forced
write is the load-bearing one: without it a pass only shows the write was
skipped, not that anything was preserved.

Also struck: fall-through (Q2), verb choice (Q5) and loops (Q6), all settled when
Phase E shipped.

KNOWN LIMITATION, recorded not fixed. labelRejoinMerges labels only error-rejoin
merges; the nested describer represents an if/else join implicitly and walks
through a single-input merge without representing it, so such a merge is deleted
by a describe -> exec round trip with no warning. Measured: two authored merges
describe to one, and executing that leaves one. It predates merge/join (DESCRIBE
never emitted merges) but Phase E makes it reachable from MDL. It does not touch
the Phase E fixpoint claim — D1 = D2 = D3, the loss is on the first step from the
authored graph. Remedies weighed, undecided.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
DESCRIBE walks straight through an ExclusiveMerge with a single incoming path
without emitting anything for it, so describe -> exec DELETES the node. Silently:
no warning, no MDL-FLOW01 (the graph is perfectly reducible, so the
irreducibility detector is right to stay quiet), and mx check clean either way.

Measured on a blank 11.14 app plus FeedbackModule and Administration, comparing
merge $IDs across a round trip over 21 microflows: in-degree 1 deleted 7 of 7,
everything else survived with its $ID intact. It is not a corner case —
VAL_Feedback loses 5 of its 10 merges and SUB_Feedback_SendToServer 3 of 5.
Behaviourally harmless (a one-input merge is a no-op) but it deletes a node the
user drew, which is what guard-don't-drop exists to prevent.

The obvious rule is wrong on real code. "Represented" cannot just mean the join
point findSplitMergePoints pairs with a split: findMergeForSplit needs a join
common to ALL branches, so a split where one branch returns pairs with nothing —
and yet its merge is emitted as the continuation after `end split` and survives.
Administration.ManageMyAccount is that shape and was a false positive until the
in-degree clause went in; a warning that fires on ordinary Marketplace code is
worse than no warning. Four negative controls pin it, one per quiet shape.

Deliberately out of scope: a merge lost to the flattening of an IRREDUCIBLE
graph (one two-input merge of SUB_Feedback_SendToServer goes that way). Those
microflows already carry MDL-FLOW01, which says the whole description is not
equivalent and must not be re-executed — strictly stronger than this warning,
and the right owner. After the fix, every microflow in the corpus that loses a
merge is flagged and no microflow that does not is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Nested if/then/else is single-entry/single-exit; a Mendix microflow is an
arbitrary digraph. When an inner split's branch lands where an outer split's
branch lands, DESCRIBE walks the graph as a tree and emits the conjunction along
one path: the reporter's activity ran on `not(c1) or c2` and was described as
`c1 and c2` — with their expressions, a program that always logged described as
one that never did, with mx check clean throughout (mendixlabs#923).

NORMALIZED folds the branch guards into one condition instead, which is
equivalent and duplicates nothing. On the reporter's graph it emits
`if $B or not($A) then` where the default emits `if $A then if $B then`.

Not a second describer. normalizeCollection rewrites a COPY of the graph into
the equivalent properly-nested one and hands that to the describer that already
exists, so Mode 3 inherits every activity renderer, annotation and layout rule
and cannot drift from Mode 1. The folded split keeps its $ID, position and
annotations; its stored Caption is cleared, because it labels the ORIGINAL guard
and leaving it puts a second, wrong condition in the more prominent place. The
entry merge is spliced out: after folding it has in-degree 1, which describe ->
exec would delete anyway, so removing it here makes Mode 3's output describe
exactly the graph its own re-execution builds.

Opt-in is load-bearing, not a convenience: the output re-executes to a DIFFERENT
graph — same behaviour, fewer nodes, different layout — and reshaping someone's
canvas because they asked to read it is its own guard-don't-drop violation. The
output says so in a NOTE.

Refuses per-decision, rendering the rest as-is and saying why: an activity in
the region (folding moves a side effect), a rule-based decision (a rule call
cannot go inside a Mendix expression), and interleaved branches (nesting those
needs a duplicated activity or an invented boolean — Boehm-Jacopini — and
neither is a description).

Folding is sound because `and`/`or` short-circuit, measured yesterday on 11.14.0
rather than assumed; had they been eager the folded form could evaluate a guard
the original skipped, and this would have had to be restricted to total
conditions or dropped.

VERIFIED BEHAVIOURALLY, not just algebraically:
- mdl/microflownorm's simplifier against truth tables over 3000 generated
  formulas, plus idempotence (DESCRIBE must be a fixed point). Control: a
  deliberately broken rewrite rule fails it at the first assignment.
- the whole pipeline against a real runtime — the original graph and the
  microflow rebuilt from its normalized description return the same value for
  all four inputs. The two `$A = false` rows are exactly the ones the flattened
  rendering gets wrong.
- a properly nested graph is left byte-identical, which is the regression risk
  of the whole feature.

NORMALIZED goes in `keyword` as well as the lexer, so an element called
"normalized" still parses — the treatment MERGE needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
…ng in it

CATALOG.GRAPH_CYCLES answered "0 rows" on a project whose
CATALOG.GRAPH_MODULE_COUPLING listed mutual module pairs, so it read as
"no circular dependencies" from a table that had never been computed
(mendixlabs#1060).

The two tables are built by different things. graph_module_coupling is a
plain SQL view over refs, so any full-mode catalog answers it.
graph_cycles_data is written only by the graph-analysis pass, which runs
only from REFRESH CATALOG COMMUNITIES — and GRAPH_CYCLES, COMMUNITIES,
COMMUNITY_SUMMARY, GRAPH_LAYERS, GRAPH_CENTRALITY and
GRAPH_INTEGRATION_SURFACE were the only graph tables in neither
fullOnlyTables nor sourceOnlyTables, so nothing warned. Measured on
ako/TestApp: after `refresh catalog communities`, communities/layers/
centrality hold 150 rows each; after one `refresh catalog full force`
all three hold 0, with the recorded mode still reading "full".

The mode ladder cannot carry this bit. fast/full/source is a level —
each contains the one below, which is what makes a rank comparison
decide everything — and the graph pass is orthogonal to it, augmenting
whichever level is cached. Folding it in would make "source" and
"full+graph" incomparable. So it is recorded as a flag
(MetaGraphAnalysis) plus the resolution it ran at.

With that bit available:

  - a query against one of the six says "requires refresh catalog
    communities (not run for this catalog)" instead of returning 0 rows;
  - SHOW CATALOG STATUS reports "Graph analysis: ✓/✗" separately from
    the build mode, because it IS separate;
  - DESCRIBE CATALOG.GRAPH_CYCLES names the command that populates it;
  - a full rebuild over a catalog that had the pass re-runs it at the
    same resolution rather than dropping it — the mendixlabs#1081 shape, where a
    build silently replaces something richer than itself. A fast build
    is deliberately NOT promoted: it never overwrites a full cache, so
    it has nothing to preserve.

The drift guard is a measurement, not a second hand-written list that
would drift in lockstep with the first: run the pass over a synthetic
refs graph and diff which tables went from empty to non-empty, then
compare that set to the classification map. The fixture is shaped so
every one of them fills — two clusters for the community split and the
cross-community edge, one mutual pair for cycles — since otherwise the
guard would skip the very table this was reported about. Removing
graph_cycles from the map fails it with exactly the original bug.

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

mendixlabs#1060 reported GRAPH_CYCLES empty while
GRAPH_MODULE_COUPLING listed the same module pair in both directions.
The previous commit made "never computed" say so. This one addresses the
case where the pass HAS run and the answer is still not the one asked
for.

Two separate reasons the tables disagreed, both now addressed.

**Granularity.** GRAPH_CYCLES is SCCs of the ASSET graph. Modules A and
B reference each other through DIFFERENT documents, which form no cycle
among themselves — the ordinary shape of a module cycle — so the asset
table is correctly empty for a genuinely circular pair. There was no
module-level cycle table at all; GRAPH_MODULE_DEPENDENCIES exposes the
edges and nothing computed SCCs over them.

CATALOG.GRAPH_MODULE_CYCLES is not a rollup of GRAPH_CYCLES but its own
Tarjan run over the module graph. A row carries RefKinds: the kinds on
that module's edges INTO THE REST OF THE CYCLE, so a reader is sent at
the reference keeping it alive rather than everything the module points
at. module_cycles() joins the Starlark builtins, so "no circular module
dependencies" is enforceable.

**Scope.** graphRefKinds admits ten structural kinds and drops the
navigational ones so they do not blur clustering; GRAPH_MODULE_COUPLING
reads every kind straight off refs. On ako/TestApp that is 110 of 316
edges — a third of the graph — with nothing recording the fact.

GRAPH_MODULE_CYCLES therefore uses every kind, matching the table it is
read beside. That is the load-bearing choice: on a blank Mendix app
Administration -> Atlas_Core is a `layout` edge and nothing else, so a
module-cycle table built on the structural subset would ship green and
still answer "none" for exactly the pair that gets reported. And
CATALOG.GRAPH_ANALYSIS_SCOPE makes the split answerable from SQL — one
row per kind, its edge count, and whether it reaches the asset graph —
with the IN list generated from graphRefKinds so the view describing the
filter cannot drift from it.

Measured end to end on a 16-module 11.14 app. GRAPH_ANALYSIS_SCOPE
reproduces the 110/316 split exactly. TestApp has no mutual module pair,
so GRAPH_MODULE_CYCLES is empty — the negative control; injecting one
reverse `show_page` edge into refs produces Administration/Atlas_Core at
size 2, naming `layout` one way and `show_page` the other, while
GRAPH_CYCLES stays at 0 throughout. That contrast is the report.

One bug the tests caught before it shipped: buildModuleCycles first sat
after the `len(edges) == 0` early return, which is computed on the
STRUCTURAL edge set — so a project whose only cross-module references
are navigational got nothing, which is the reported case exactly. The
test with `layout`-only edges fails without the reordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Graph analysis: say when the pass has not run, and answer the module-cycle question
feat(describe): Mode 3 normalized DESCRIBE, and a warning for merges it silently deleted
…1059)

`describe page` -> `create or replace page` rebuilt a button's icon as the
wrong element, or deleted it.

Mendix stores three icon elements and two of them put a qualified name under
the same BSON key:

    Forms$IconCollectionIcon{Image} -> CustomIcons$CustomIcon
    Forms$ImageIcon{Image}          -> Images$Image (a different document)
    Forms$GlyphIcon{Code}           -> a font code point, no name at all

The page describer read `Image` off whichever element was there and discarded
the `$Type`, and the page builder writes Forms$IconCollectionIcon
unconditionally. So an image icon came out as a bare `Icon: '<qn>'` and went
back in as a custom-icon reference:

    [error] [CE1613] "The selected custom icon
    'DesignSystem.Icons_SVG.edit' no longer exists."

A glyph icon was worse: DESCRIBE emitted nothing for it at all, and since
CREATE OR REPLACE PAGE is a full replacement, replaying that output removed
the icon at exit 0 with a success message.

This is not a limit on third-party collections, as the report read it — a
third-party icon collection round-trips fine, and CE1613 naming the reference
is itself evidence the name was never a custom icon.

The same three-way split was already understood in navigation (mendixlabs#1008,
types.MenuIconKindOf); the page path never received it. That resolver is now
shared rather than duplicated. DESCRIBE emits `Icon:` only for the variant
mxcli can author and flags every other kind — the unrecognised `$Type`
included, so the next variant Mendix adds is reported rather than dropped —
with a `-- NOT re-executable` note naming the reference and its element.

Measured on the FeedbackModule pages of a stock marketplace project: three
glyph icons per page went from zero mentions in describe output to three
notes; icon-collection clauses are unchanged (10 on that project) and the
emitted script still parses (the one parse failure there is a pre-existing
nameless-statictext defect, confirmed identical against the base binary).

Authoring the other two variants (`Icon: image <qn>` / `Icon: glyph <n>`, as
navigation spells them) is not part of this: the loss is now visible, not yet
reversible. NavigationList item and DropDownButton icons are still not read at
all — noted in the finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
…#1059)

`Icon: image <qn>` and `Icon: glyph <n>` on page widgets, completing the round
trip the previous commit could only make honest.

    actionbutton btnEdit (Caption: 'Edit', Icon: 'Atlas_Core.Atlas_Filled.pencil')
    actionbutton btnLogo (Caption: 'Logo', Icon: image MyModule.Images.logo)
    actionbutton btnHome (Caption: 'Home', Icon: glyph 57377)

The bare form stays the icon-collection icon, so no existing script changes
meaning; a name may also be written unquoted, as every other reference into the
model is. The keyword is the whole point: the first two forms are spelled
identically and point into DIFFERENT documents, so nothing else separates them.

Same vocabulary as navigation's `icon image` / `icon glyph`, and the same ANTLR
ordering trap (qualifiedName accepts a keyword as a name segment, so the
keyword-led alternatives must come first).

One vocabulary, not a third. `pages.Icon` carried its own three-value enum for
the same three Mendix elements, which is exactly the drift that produced the
bug; it now uses types.MenuIconKind, and both writers dispatch on it instead of
inferring from a payload that cannot distinguish the two named kinds.

The legacy engine wrote `Icon: nil` on every action button unconditionally. A
button icon has been authorable since mendixlabs#602 and only the modelsdk engine ever
wrote one, so under `--engine legacy` it was dropped on every write — silently,
because a null Icon is what an iconless button stores. Both engines now write
all three.

Validation caught up with the kinds:

- `check --references` resolves each kind against its OWN collection. Before, an
  image reference was checked against the icon collections and reported as a
  typo — the same conflation, pointed the other way. When the kind is wrong the
  message names the remedy instead of leaving a correct name looking misspelt.
- MDL078 (undefined glyph code) now covers widget glyphs. It walked the
  statements itself and knew only about menus, so every widget glyph would have
  passed in silence. Both it and the reference check now consume the package's
  single icon walk.

Measured on 11.13.0 against a real project: all three authored, `mx check` 0
errors, `mxbuild --target=deploy` BUILD SUCCEEDED, and the three elements on
disk with the right $Type and payload. The controls pin it — the same page with
`image` dropped gives exactly `[CE1613] "The selected custom icon
'MyFirstModule.Images.workflow' no longer exists." at Action button 'btnImage'`,
and a widget `Icon: glyph 57562` fails the deploy build with
`System.InvalidOperationException: Sequence contains no matching element`,
naming the PAGE — so MDL078's message no longer promises "layout". Corpus sweep
clean at 531 scripts.

Not covered: NavigationList item and DropDownButton icons are still not read at
all, so they are dropped as silently as a widget glyph used to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
fix(pages): a widget's icon is one of three elements, not one string (mendixlabs#1059)
@ako
ako merged commit 9905dd7 into mendixlabs:main Sep 14, 2026
6 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