Skip to content

Fifty-five changes: pluggable widgets become first-class MDL, a security command that never ran, and a run of check false positives only a real project exposed - #1052

Merged
ako merged 62 commits into
mendixlabs:mainfrom
ako:main
Sep 6, 2026

Conversation

@ako

@ako ako commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fifty-five commits since the last sync. Each was reported by a real symptom, reproduced first, and carries a control showing the fix is what changed the outcome.

Two of them are worth reading even if you skip the rest: update security was inert on every MPR v2 project, and every mxcli-authored Image widget failed the build.

Pluggable widgets become a first-class MDL extension point

The largest strand, driven by #1036 and its proposal. A pluggable widget's object lists and child slots were reachable from MDL only via a hand-maintained list of nine keywords, while mxcli widget init derived documentation for all of them mechanically — two lists, and nothing comparing them. Measured on the fixture project's 33 widget defs, most documented slots were unreachable.

The consequence was self-inflicted: mxcli widget init generates widget documentation for agents to follow, and feeding it back into mxcli check verbatim failed on the first line of its own example.

  • A widget and its body are now named by the definition, not by a keyword list.
  • DESCRIBE WIDGET — a widget was the only MDL extension point with no DESCRIBE, so it needed the CLI where a microflow, nanoflow, Java action and JavaScript action all answer in-language. It reports body containers, what is authorable, and emits an MDL example that parses as written, with bindings narrowed by the widget's own rules.
  • The validator knows what a widget is — two mistakes previously passed check and failed at exec (MDL-WIDGET25, MDL-WIDGET26).
  • A widget is a reference target in CATALOG.REFS. "Which pages use this widget?" was unanswerable while the same question about a Java action was one query — and it is exactly the question an upgrade asks: a .mpk no page uses is dead weight, one used on forty pages is not swapped lightly.
  • Describe fidelity along the way: emit the widget's own name rather than losing its body silently, reconstruct pluggable containers instead of dropping them, emit property keys verbatim rather than PascalCased, and stop emitting properties the example's own configuration hides.

Two that were quietly broken

  • update security was inert on every MPR v2 project. It returned on the first module it could not reconcile, and one always fails: System, whose domain model is synthesized rather than stored, so there is no unit behind the id. The command now runs, and honours its scope.
  • Every mxcli-authored Image widget failed the build with CE0463 "The definition of this widget has changed…" on a project otherwise at 0 errors. A visibility rule was evaluated against the wrong state; it is now evaluated against what will actually be written.

Correctness in what we author

  • A page-level association datasource typed its rows as the entity it navigates away from, producing CE1613 on every column. Now resolved from the context variable.
  • drop association reported success while a message definition still exposed it, naming neither the collection nor the definition. mxbuild caught it as CE1613, and describe went on emitting the dangling member — so a describe → exec round trip carried the break forward. Now refused.
  • A ReferenceSet is a list in both directions. Cardinality was derived from traversal direction alone, which is right for a Reference and wrong for a ReferenceSet, so a set reached forward was exposed as a single object.
  • DESCRIBE → exec was destroying navigation menu icons. All three icon kinds now author and round-trip, and MDL074 warns when a menu item has none — Mendix's sidebar collapses to an icon rail, and an item without one falls back to a few characters of its caption, which rarely distinguishes "Orders" from "Order lines".
  • ALTER ENUMERATION … ADD VALUE had no idempotency guard, so a script adding a value was not re-runnable. The harm is not the message: exec halts at the failing statement, so one already-present value silently truncates everything after it. Adds ADD VALUE IF NOT EXISTS / DROP VALUE IF EXISTS.

A run of check false positives, and why they survived

Four false positives — a widget property's documented alias, a chart series' datasource routing, a sum() Decimal guess, and an object-list item counted as a widget for CE0495 — all found by running check under a real project.

The common thread is the interesting part: make check-mdl runs check without a project, so no widget definition and no domain model load and the rules are inert. A file's "check-clean" header proved nothing about them. Also fixed: a widget's property warnings depended on map iteration order.

The validator gained real capability alongside: it resolves member names rather than just the entity they belong to, resolves them inside widgets (XPath steps and template params), enforces expression kinds, refuses unqualified CREATEs and void-with-alias, and resolves an ALTER's target document rather than only its module.

Test hygiene

  • A fixture-cache trap where a gitignored directory made a test environment-dependent, now hermetic — recorded as a finding, along with the four defects the external test projects found.
  • Lint findings now name the document type they are actually about.
  • The demo capture-integrity machinery ships as code: recording both clock anchors and marking beats, so a filmed beat can be shown to have happened and the cut timestamp to point at it

A pluggable widget's object lists and child slots are reachable from MDL
only when their keyword is in a hand-maintained list of nine, while
`mxcli widget init` derives documentation for all of them mechanically.
Two lists, nothing comparing them.

Measured on the fixture project's 33 widget defs: of 46 documented
constructs, 16 parse and 30 do not — object lists 13/16, child slots
3/30. 20 of 33 widgets document at least one keyword that cannot parse.
The generated htmlelement.md fails on the first line of its own example.

The cost in the report was not the wasted sessions: unable to express the
HTML Element `attributes` list, the team fell back to `tagContentHTML`,
which executes same-origin — the risk the sandbox was for. `ALTER PAGE`
rejects the same construct (verified with a working control), so honest
documentation alone would have left them shipping that fallback.

What makes the fix small is that the write path is ALREADY def-driven:
applyObjectLists matches the AST child's Type string against whatever the
def declares, and the visitor sets that string from the token's literal
text. The nine keywords exist only so ANTLR has a token to match — an
artefact of the parser generator leaking out as a capability boundary.

Two slices. Slice 1 ships independently: the four built-in defs that
exist nowhere (events, fileuploader, googletag, markdown — all pass check
and fail exec), an error message that currently names a remedy which
provably cannot work, the missing name in generated examples, and `()`
being a parse error on every widget kind. Slice 2 is the grammar change,
contained to a body rule the two pluggable alternatives use.

Open question 1 could sink slice 2 and is written as such: the tradeoff
moves a typo from a parse error to a semantic one, which is only an
improvement if the validator fires everywhere the parser did — including
with no --project, where only built-in defs load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Revises the proposal after a design review asked whether it aligns
widgets with the other MDL extension points. It did not — it fixed one
of four gaps.

The call forms are uniform: CALL MICROFLOW / NANOFLOW / JAVA ACTION /
JAVASCRIPT ACTION all take a qualified name and named arguments. A widget
matches none of it, and the previous draft only addressed the body.

Three gaps added, each measured:

  - The widget KEYWORD has the identical defect to its body, one level up.
    Every def.json declares an mdlName and WidgetRegistry.Get(mdlName)
    exists; cmd_pages_builder_v3.go:425 already tries it FIRST ("Try by
    MDL name first"). Only the grammar will not produce the name, so
    `htmlelement h (...)` is rejected while `combobox c (...)` parses.
    MDLName is currently read only to build error messages.

  - There is no DESCRIBE WIDGET in MDL, only a CLI command. That reframes
    the whole bug: actions need no generated docs because DESCRIBE answers
    in-language, so the widget .md is a workaround for a missing statement
    — and that is why it could drift. Fixing the generator treats the
    symptom.

  - CATALOG.REFS has 15 edge kinds and none is widget use, so "which pages
    use HTMLElement?" is unanswerable while the same question about a Java
    action is one query. Same class as the scheduled-event gap in CLAUDE.md.

Also answers the rename question. PLUGGABLEWIDGET and CUSTOMWIDGET already
take the same branch and write the same CustomWidgets$CustomWidget, so the
real win is collapsing them rather than the shorter name. Sequenced LAST:
once the keyword form works and DESCRIBE emits it, the ID form is an escape
hatch that is rarely written, which is what makes this a readability change
rather than a headline. The cost is stated rather than left to be found —
WIDGET already means "the widget named X" in ALTER/DESCRIBE STYLING.

Slice 1 is unchanged and still ships independently.

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

Slice 1 of PROPOSAL_def_driven_widget_bodies.md — the parts of
mendixlabs#1036 that need no design work. Three independent defects,
all found while reproducing the report.

`no definition for widget` always said "run 'mxcli widget init -p app.mpr'".
Studio Pro BUNDLES File Uploader, Events, Google Tag and Markdown viewer
rather than installing them, so their .mpk never reaches widgets/ — the only
place widget init scans — and re-running it can never help. Reported as the
postscript to the issue, where it cost a debugging session. The message now
branches on whether the package is really installed, using the same FindMPK
lookup the template loader makes before giving up.

`()` was a parse error on every widget kind, reported at the closing paren as
though the widget were wrong, while bare `container c` and `container c
(x: 'y')` both parsed. widgetPropertiesV3 required at least one property.

`widget init` generated child slots with NO NAME (`tagcontentcontainer { … }`),
which even a working slot rejects — so the three slots that did parse were
documented in a form that could not. Names are emitted and numbered, since two
`slot1`s on one page would collide.

Item 1 of the slice — shipping the four missing widgets — is NOT here, and the
proposal is corrected rather than left to mislead. Two things the draft had
wrong: their .mpk files are not in Mendix.Modeler.Core.dll (690 embedded zips,
zero widgets.mendix.com hits), and a .def.json alone is insufficient because
getOrGenerateTemplate derives the template from the .mpk in widgets/ — shipping
a def only moves the error to `template not found`, verified. All four ARE
Mendix-published on the Marketplace, so genuine definitions are obtainable and
must never be hand-written; the extraction was cross-checked against
FileUploader's six known action slots.

The control on the error message matters: the installed-package branch must
still recommend widget init, or the test passes against a build that simply
deleted the recommendation. Its first version was wrong — FindMPK PARSES each
.mpk rather than matching names, so an empty file named after the widget is
silently skipped — and the control caught it.

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

A review question — "could a Studio Pro user even use these without the
mpk?" — turned out to invalidate the premise, so the proposal drops item 1
rather than deferring it, and the error message stops implying mxcli might
one day ship a definition.

Measured on Mendix 11.13:

  blank project (mx create-project)      33 widgets, none of the four
  install File Uploader (module 235351)  widgets/ 33 -> 34, .mpk present
  exec a page using it, straight after   BUILDS — no widget init needed

So a widget whose package is absent is one Studio Pro cannot use either. It
is not a gap mxcli can paper over, and there is nothing to ship: the moment
the widget is usable at all, the .mpk is in the project and
initPluggableEngine refreshes the definition from it unprompted.

The error message now points at installing the widget instead of offering
to have mxcli ship one, which was advice that would have led nowhere.

Two dead ends preceded this, both recorded in the finding because both
looked settled at the time: the .mpk files are NOT in
Mendix.Modeler.Core.dll (690 embedded zips, zero widgets.mendix.com hits —
the match was a bare ID string), and a .def.json alone is insufficient
because getOrGenerateTemplate derives the template from the .mpk in
widgets/, so shipping one only moves the error to `template not found`.
Both were chased before anyone asked whether the widget was usable in
Studio Pro at all — which is the cheaper question and settles the rest.

Items 2-4 of the slice are unchanged and still shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
A message definition's association cardinality was derived from the
direction of traversal alone: forward (the element's holder is the
association's FROM entity) stored MaxOccurs 1, reverse stored -1. That is
the right rule for a Reference and the wrong one for a ReferenceSet, which
is many at both ends — so a set reached forward was exposed as a single
object.

                 forward (holder is FROM)   reverse
  Reference                1                  -1
  ReferenceSet            -1                  -1

Unlike the direction half of the rule, this half has a build error behind
it. Measured on ako/mxcli-rest's RestLab at Mendix 11.13.0, against a
0-error baseline, the forward traversal of RateSnapshot_ExchangeRate gave:

  [error] [CE6524] "... The occurrence of 'RestLab.RateSnapshot_ExchangeRate'
    has changed" at Entity message definition 'RateSnapshot'
  [error] [CE0295] "Association 'RestLab.RateSnapshot_ExchangeRate' is not
    allowed." at Object mapping element 'Rates'

and 0 errors after the fix — definition and export mapping both, which is
the nested lane that report said could not be built. The control is the
same run with the four-line branch stubbed out, which reproduces exactly
those two errors, and the Reference beside it that stays a single object in
either build.

The direction rule was measured on a corpus that is exceptionless and still
incomplete: all 927 resolvable associations in it are Reference, 526
storing 1 and 401 storing -1. That pins the direction half and says nothing
about the type half, because the corpus never varied the input the rule was
declared independent of. The proposal that recorded the census now carries
that correction, and the skill and site references state both halves.

ako/mxcli-rest FINDINGS #60

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Slice 4 of PROPOSAL_def_driven_widget_bodies.md, and the slice that
actually retires the failure mode behind mendixlabs#1036.

A widget was the only MDL extension point with no DESCRIBE. A microflow,
nanoflow, Java action and JavaScript action all answer in-language against
the live project; a widget needed the CLI. That gap is WHY `mxcli widget
init` generates markdown documentation at all — and why that documentation
could drift from what the parser accepts. Fixing the generator treats the
symptom; giving the widget a DESCRIBE removes the reason the generated file
was the only answer.

The description builder moves out of cmd/ into the executor, and both the
statement and `mxcli widget describe` call it. One code path on purpose:
two implementations would reopen exactly the drift this closes. cmd already
imports executor, so the dependency direction was already right.

DESCRIBE WIDGET works with NO project open, joining DESCRIBE FRAGMENT in the
execDescribe exemption. A widget definition is not a document in the model —
it comes from a package, or from mxcli's embedded set — and requiring a
project would make the statement useless for the "what can I write here?"
question it exists to answer. With a project the answer is better: the
installed .mpk is version-accurate and is the only place a Marketplace
widget appears.

LIST WIDGETS is deliberately NOT added. `show/list widgets` already means
widget INSTANCES on pages, and definitions are already listable as
`select * from CATALOG.WIDGET_DEFINITIONS` — which the proposal's slice 4
had not accounted for, so the slice is smaller than written.

The refactor is verified behaviour-preserving by byte-comparing
`mxcli widget describe htmlelement -p …` before and after: identical, 34
lines. Getting that comparison right took two attempts — `git stash --
<path>` silently no-ops on an untracked file, and a fresh worktree cannot
build because the generated embed dirs are absent.

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

Answers whether an agent can use DESCRIBE instead of the generated .md.
Measured, it could not quite: the .md carried child slots and object lists,
which DESCRIBE did not, so the two artifacts each held half the picture.

DESCRIBE now reports both, with each object list's item properties — and
marks which containers MDL can actually express today, which the .md never
said and is the reason it misled. On a stock project 30 of 46 documented
containers do not parse.

Authorability is DERIVED by parsing a probe through visitor.Build, never
from a list. A list here would be the same defect the proposal is about,
one layer up — and deriving it means the marks correct themselves when
slices 2-3 land, with no change to this code.

The control is inside one widget: Gallery's `template` and `filter` come
back authorable while its `emptyplaceholder` does not. Without a positive
case, "NOT authorable" everywhere is equally consistent with a probe that
always fails, which is the specific risk of deriving instead of stating.

Gallery is also an embedded definition, so the test needs no project and
cannot skip. The first version pointed at the fixture project and skipped
every run — the fixture has no extracted defs — which is the trap mendixlabs#808
recorded: a test that only ever skips proves nothing. Caught by reading
the run output rather than the exit code.

The remaining gap to the .md is the MDL example, and that is the part that
is wrong for most widgets, so it is not worth porting until slices 2-3.

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

`drop association` reported success while a message definition still exposed
the association, naming neither the collection nor the definition. mxbuild
caught it as CE1613 "The selected association 'X' no longer exists." at the
definition, and `describe` went on emitting the dangling member, so a
describe -> exec round trip carried the break forward.

A definition is a selection over the domain model held by qualified name, with
nothing keeping the two in step. The drop already reconciles the entity access
rules that named the association; this one cannot be reconciled, because
removing the member changes a published contract and that is the author's
call. So it is refused — the same posture `drop message definition collection`
already takes from the other side.

The refusal prints the statement to run, rather than saying "remove the member
first", because the advice alone leads to a second wall: `drop member` matches
the member's ORIGINAL name (for an association, the target entity's) while an
`in` path segment matches the EXPOSED one, so the name written in the
definition is the one that fails. mxcli knows both. Verified by running the
printed text verbatim on ako/mxcli-rest's RestLab, after which the drop
succeeds.

Two things one example would have hidden, both covered:

  - an association exposed in BOTH directions yields two definitions to clear,
    and clearing one leaves the other dangling, so every hit is listed;
  - the association that breaks is usually nested, so the whole element tree is
    walked rather than the root's own children.

Scope is deliberate: after the guard, dropping the same association still
leaves CE1613 on a microflow retrieve and on an object mapping element. Those
are different consumers and are not covered here.

Controls: a collection that names a different association does not block the
drop, and the pre-guard binary reproduces the CE1613 at the definition.

ako/mxcli-rest FINDINGS #60

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`alter page RestLab."RestLab_Home"` — no such page — passed
`mxcli check -p --references` and was then refused by `exec` with "page not
found". The switch in validateWithContext resolved the ALTER's MODULE and
never the document, so a misspelled module was reported and a misspelled
document was not, which is a distinction with no meaning to the author.

The finding that reported this called it harmless, and the wrong answer is.
The inverted contract is not: the check-syntax skill promises exec refuses
exactly what check rejects, and here exec was the stricter gate — so a script
passed every pre-flight and then stopped halfway through, with the statements
before the typo applied and none after.

Covers the ALTER statements whose target resolves from a listing that already
exists, each measured passing check and failing exec before it was added:
AlterPageStmt (PAGE, SNIPPET and LAYOUT — one statement type, three kinds,
set by the visitor from the keyword) and AlterEntityStmt. It is not extended
speculatively; a target that cannot be resolved is left alone, because a false
"not found" blocks a script that would have worked.

Two guards keep it from being worse than the silence it replaces, both with
controls:

  - a module the SCRIPT creates has no listing to resolve against yet;
  - an EMPTY listing means the backend could not answer, not that the project
    has no pages.

Documents the script creates earlier count, which is the common
create-then-alter shape; `scriptContext` gained the `layouts` set it lacked.
The error offers the module's real names, since a typo is cheap to fix only
when you can see what is there:

  page not found: RestLab.RestLab_Home (referenced by alter page)
    — RestLab has CallLog_Detail, Home_Web, Mapped_Overview

Controls: the tests fail with the reported symptom when the validator is
stubbed, and 476 MDL examples still check clean.

ako/mxcli-rest FINDINGS #60

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

The generated .md's "MDL Example" is the half of it that was WRONG — it
failed on its own first line. This is that section done so it cannot be.

Both halves are derived by probing the real parser through visitor.Build:
the head form (`gallery widget1` when the keyword is accepted, else
`pluggablewidget '<id>' widget1`) and each container. Whatever the grammar
cannot yet express is left OUT and NAMED, which is the information the
reporter's team never had — the .md silently included it, which is what
made it misleading rather than merely incomplete.

The whole example is then parsed before being emitted; if it does not
parse the caller is told rather than handed a broken snippet. So it cannot
promise syntax that fails, and it widens on its own when slices 2-3 land,
with no change to this code and no second list to keep in sync.

Two traps found writing it, both recorded:

  - Numbering matters. Two child slots both named `slot1` PARSE — the
    parser does not check names — but are invalid on one page. The .md
    generator had the identical bug, fixed earlier in this branch.
  - Required properties of type datasource/attribute/action/expression are
    omitted and named, never filled in. An invented entity name parses
    fine and then fails at exec, which is a worse failure than an honest
    gap.

Verified by feeding each emitted example back through `mxcli check`:
htmlelement, gallery, accordion, datagrid and combobox all parse. The
tests assert the same round trip, plus that an unauthorable container is
omitted WHILE an authorable one is included — without that pairing,
"omits things" is indistinguishable from "omits everything".

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

`alter enumeration Mod.E add value X` had no idempotency guard, so a script
that adds an enumeration value was not re-runnable.

The reportable harm is not the error message. It is that `exec` HALTS at the
failing statement, so one already-present value silently truncates the rest of
the script — the damage is in the statements that never ran, not in the one
that failed. Measured by putting a guarded add after an unguarded one and
looking for the second value afterwards: absent.

A defensive drop-then-add is not a workaround, which is what forces a guard to
exist rather than merely being convenient: the drop fails when the value is
absent and the add when it is present, so neither ordering is re-runnable on
its own. The same reasoning already put these guards on ALTER ENTITY's ADD
ATTRIBUTE and ADD INDEX, and the grammar's `ifNotExists` / `ifExists` rules
were already there to reuse.

  alter enumeration Mod.Lane add value if not exists Outbound caption 'Outbound';
  alter enumeration Mod.Lane drop value if exists Retired;

The bare forms are unchanged and still error; the ADD error now names the
guard, so the fix is in the message rather than in the docs. Controls cover
both: an unguarded duplicate is still refused, and the guard still writes a
value that is genuinely new.

One trap worth recording, since the first version of the example fell into it:
`create or modify enumeration` REPLACES the value list rather than merging into
it (an enum with three values, re-declared with one, keeps only that one). So a
script whose CREATE precedes the ALTER resets the enum on each run and the
ALTER re-adds instead of skipping — the example passed twice while never once
exercising the guard. It now declares the value in the CREATE so the guard
fires on every run, and says why.

ako/mxcli-rest FINDINGS #60

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Part 1 of the plan for bindings. A widget's properties are "required"
only where its editor shows them, and the example was using the required
list raw: Combo box asked for ELEVEN bindings across options-source modes
that are mutually exclusive. That is the .md's failure mode wearing
different clothes — not syntax that fails, but work that is not needed.

The rules to answer it were already in the output. They are now evaluated
against the configuration the example itself describes, reusing
types.WidgetVisibilityCondition.Hidden — the same predicate MDL-WIDGET10
uses, and with the same conservatism: an indeterminable condition does NOT
prune, and a nested (object-list item) rule never prunes the widget's own
property. Over-listing costs a moment; hiding a binding someone needs sends
them to a build error.

Eleven becomes six. It would become about two with full rule coverage —
combobox reports "16 of 32 editor hide-rules recognized", and the two
survivors with ZERO recognized rules (attributeEnumeration, ariaRequired)
are hidden in Mendix for reasons the extraction does not capture. So the
ceiling here is rule extraction, not the pruning, and that is its own piece
of work.

Also fixes a defect this uncovered, more general than the feature: the two
description sources spell property types differently — a project .mpk gives
"datasource", the embedded template "DataSource" — and the example's switch
matched only lowercase. Described without a project, the example therefore
emitted NO properties at all.

That had been passing its test. "The example parses" is true of a bare head
form, so the assertion was vacuous; the control that caught it asserts the
example still ASKS for a binding nothing hides. The no-project limitation on
pruning (no .mpk, so no editorConfig, so no rules) is now asserted rather
than left to be rediscovered.

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

Whether the def-driven body costs error quality. Answer: yes as written,
and the fix is a prerequisite slice rather than a risk to accept.

The question assumed the validator would have to match what the parser
catches. Measured, it catches LESS than assumed, and the gap is already
open:

  contaner c1 (…)                      parse error (the parser is the allow-list)
  pluggablewidget 'com.acme.NotAWidget' check PASSES; fails at exec
  group g1 (…) inside HTML Element      check PASSES; fails at exec

So widgetTypeV3 is currently the widget-kind validator, and the validator
has no independent notion of a real widget kind because nothing else can
parse. Slices 2-3 remove that enforcement, moving every container mistake
into the hole the last two rows already occupy.

But the detection is already computed. validateWidgetTreeIn holds the
parent's declared object lists and looks the child up in them, and
lookupWidgetDef says whether the kind is known; nothing REPORTS when both
miss — the branch routes to validateStaticWidgetUnknownProps, which checks
properties of a presumed static widget rather than questioning the kind.

That reframes it. Closing the hole is an improvement today, independent of
any grammar change: check would catch two mistakes that currently reach
exec. And once check reports them, the question is answered by
construction, because the semantic error exists before the parse error is
given up.

Adds Slice 0 — "the validator knows what a widget is" — which blocks
slices 2-3. Its own control is that a correct widget and a correct
container stay silent.

Method worth reusing: to learn what a validator really catches when the
grammar is doing the work, find an input that already bypasses the parser.
`pluggablewidget '<unknown id>'` parses today and lands on exactly the path
the generic form would create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Slice 0 of PROPOSAL_def_driven_widget_bodies.md, and the answer to its
Open Question 1. Two mistakes passed `mxcli check` and failed at `exec`:

  pluggablewidget 'com.acme.NotAWidget' w1     -> MDL-WIDGET25
  group g1 (…) inside HTML Element             -> MDL-WIDGET26

The grammar was the widget-kind validator: widgetTypeV3 is an allow-list,
so an unknown kind could not parse and the validator never grew an
independent notion of one. Neither mistake is a keyword the parser checks,
and isUniversalObjectListKeyword actively SUPPRESSED the second by
treating a container keyword as always-an-item wherever it appeared.

Worth shipping on its own — check now catches what only exec caught. It is
also what makes slices 2-3 safe: those give up the parser's enforcement, so
the semantic check has to exist first.

Two guards keep this from being a false-positive storm, and a failing
control found each:

  - LoadWidgetRegistry reads only .mxcli/widgets/*.def.json and, unlike the
    page builder's registry, does NOT refresh from installed .mpk files. In
    a project that never ran `widget init` it knows the nine embedded
    widgets and nothing else, so an id whose .mpk IS installed is treated as
    real — the same FindMPK lookup slice 1's error message uses.
  - A container is never judged against a parent whose definition could not
    be resolved, for the same reason.

isUniversalObjectListKeyword was a FOURTH incomplete keyword list — seven
against the grammar's nine, missing SCALECOLOR, CUSTOMBUTTON and
ALLOWEDFILEFORMAT. The replacement derives the set from the registry.

MDL-WIDGET23 was already taken by validate_widget_onclick.go; its own test
caught the collision, so these are 25 and 26.

Measured: both rules fire on the reported mistakes, and ZERO false
positives across the whole mdl-examples/doctype-tests/ corpus.

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

Two defects in slice 0 as pushed, both found by CI targets I had not run.

MDL-WIDGET25 fired with NO project. The registry then holds only the nine
embedded widgets, so every real project widget is "unknown" — one example
file alone produced 14 violations, and `make check-mdl` broke seven. My
measurement had used -p throughout, and `make check-mdl` deliberately runs
check WITHOUT a project, which is the common case and the one I never
tested. The guard I did write (is the .mpk installed?) cannot help there:
with no project there is no widgets/ to look in.

The rule now requires a project, scoped to the widget-id branch only — the
container rule needs a resolvable PARENT rather than a project, and a
blanket early return silenced it too.

And the repro cannot be a .fail.mdl. Both rules need a project, so under
`make check-mdl` the file passes and reports "negative test unexpectedly
passed", making a working rule look regressed. The Makefile documents this
exact case (mendixlabs#891, mendixlabs#892) and prescribes the fix: keep the repro a plain
.mdl, cover the rule with unit tests. Renamed, with the reason in the file
so the next person does not re-add the suffix.

New control: no-project must claim nothing. That is the assertion my
with-project measurement could not make, and it is what would have caught
this before the push.

Verified both ways now: `make check-mdl` exits 0 (no project), and with a
project both rules fire on the repro with zero false positives across the
doctype corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
A pluggable widget was the one MDL extension point with no edge in
CATALOG.REFS. "Which pages use this widget?" was unanswerable while the
same question about a Java action was one query — and it is the question
an upgrade asks: a .mpk in widgets/ that no page uses is dead weight, and
one used on forty pages is not something to swap lightly.

Both halves were already catalogued, so this is a projection and costs no
extra parse: widgets_data.WidgetType carries the widget ID for a pluggable
or custom widget, and widget_definitions_data is keyed by it.
buildWidgetDefinitions already runs before buildReferences.

Measured on testdata/expr-checker: 248 -> 263 refs, 14 PAGE and 1 SNIPPET
edge; IMAGE on 7 pages, DATAGRID 4, COMBOBOX 3, LANGUAGESELECTOR 1, and 38
of 42 installed definitions used by nothing.

TargetName is the MDL name, not the widget ID
---------------------------------------------

The obvious choice is wrong, and the measurement is the reason. With the
dotted widget ID as TargetName:

    graph_module_coupling   invents a module "com" carrying 14 edges from
                            three real modules
    graph_god_nodes         reports com.mendix.widget.web.image.Image with
                            ModuleName "com"

Those views take everything before the FIRST dot as the module — sound for
a qualified name, nonsense for a widget ID. The MDL name (IMAGE, COMBOBOX)
has no dot, so their existing instr(TargetName, '.') > 0 guard skips it and
graph_module_coupling comes out byte-identical to the baseline. It is also
the spelling a user has in hand after `describe widget combobox`. The ID is
not lost: it goes in TargetId, which is what that column is for.

That makes a widget the first non-dotted target in the table (measured: 0
of 248 existing rows). graph_god_nodes is amended to exclude WIDGET targets
from the asset side, since every other row there is a module-qualified
document and a widget would list with ObjectType NULL and its own name as
its module. A page's OUT-degree still counts the widgets it uses.

Only widgets that resolve to a definition
-----------------------------------------

The join is the filter. A built-in stores its BSON $Type in the same column
(Forms$DynamicText) and has no definition, so it gets no edge: an edge is a
pointer to something describable, and Forms$TextBox is a language primitive.
CATALOG.WIDGETS already answers "which pages have a text box".

DISTINCT collapses a page's seven comboboxes into one edge, matching the
four sibling projections. Per-instance rows would say nothing SHOW
REFERENCES or SHOW IMPACT could use.

Every test has a control, and each was run against the reverted fix:
dropping DISTINCT gives 3 edges where 1 is expected; reverting the
god_nodes exclusion lists 2 widgets as assets; the built-in test asserts
the pluggable widgets on that same page DO get edges, so "no built-in edge"
cannot pass by emitting nothing; and the TargetName test writes a dotted ID
into the same fixture to show the fake module does appear, so it asserts a
property of the choice rather than of the fixture.

The schema version is bumped because both changes are invisible to an
existing cache — refs are only written by REFRESH CATALOG FULL and a view
is CREATE VIEW IF NOT EXISTS — so without it a cached catalog answers `show
references to combobox` with "(no references found)".

Also corrects the REFS schema in docs-site, which described columns that do
not exist (SourceKind / TargetKind) and RefKind values in the wrong case
("Call", "DataSource"). Same drift as the STRINGS block in #386. Both new
query examples were run: the natural NOT EXISTS form does not parse in
MDL's SELECT subset, so the documented one is the anti-join that does.

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

`show references to combobox` — the spelling every MDL example uses, and
the one a page body is written in — reported "(no references found)" while
`show references to COMBOBOX` returned three pages.

That is a WRONG answer, not a missing one: a user cannot tell it from the
widget genuinely being unused, which is precisely the question the new
`widget` edge exists to answer. The widget's TargetName is stored SHOUTED
because widget_definitions_data holds MDL names that way, while MDL
keywords are case-insensitive everywhere else in the language.

SHOW REFERENCES TO and SHOW IMPACT OF match TargetName exactly. That was
right while every target was a module-qualified name copied verbatim out of
`show entities`; a widget is the first target a user types from memory.

The fallback is deliberately second, never first: an exact match returns
untouched, so no existing answer can change — only a query that would have
returned nothing gets a second chance. It declines when the loose match is
ambiguous, because two targets differing only in case are a question this
cannot answer for the user, and it prints the spelling it matched so the
result is not silently attributed to the name that was typed.

Controls, run against a stubbed fallback: the three case-variant cases fail
and the ambiguity test fails; the exact-match, genuine-typo and
unknown-widget cases pass either way, which is the point — they assert that
nothing else moved. Verified end to end on testdata/expr-checker that
`show references to Administration.Account` is unchanged and prints no
"(matched ...)" line, and that a real typo still reports nothing rather
than being rewritten to a near neighbour.

The two interpolated queries this touches now escape the target, which they
did not before. The interpolation itself is pre-existing and not addressed
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Three details of the widget edge differ from the one-line sketch, each
settled by measurement: one edge per container rather than per instance,
the MDL name rather than the widget ID as TargetName (the dotted form
invents a module called "com" in the module-derived graph views), and only
widgets that resolve to a definition.

Also records what was NOT done — a widget definition is not added to
`objects`, so an unused .mpk does not surface in GRAPH_DEAD_ASSETS — and
that the sketch's `show references to widget htmlelement` syntax was
unnecessary, since `show references to <name>` already parses a bare word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
`refresh catalog full` regenerates the per-widget skill docs when widget
definitions change (executor.RegenerateWidgetDocs, deliberate since
1eda39c and reported in the command's own output). In this fixture that
writes 43 markdown files nothing tracks, so running it against
testdata/expr-checker leaves the tree dirty and invites someone to commit
derived files.

Scoped to `.claude/skills/widgets/` rather than `.claude/`, because the
directory is only partly derived — `.claude/settings.local.json` already
has its own line above, and commands or other skills placed there should
still show up. Verified both directions: git check-ignore covers
skills/widgets/combobox.md and SKILL.md, and does not cover
.claude/skills/other.md or .claude/commands/.

Measured: with the rule, a fresh `refresh catalog full` against the fixture
leaves `git status` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
5 asked which source granularity a widget edge should use, and said to
measure both on a real app. Slice 5 settled it as per (container, widget
definition): per-instance rows say nothing SHOW REFERENCES or SHOW IMPACT
can use — both list sources, and CATALOG.WIDGETS already holds the
instances — so the extra rows buy nothing at any size. Records the
question the draft did not ask and that turned out to matter instead:
what goes in TargetName, where the dotted widget ID poisons every
module-derived graph view.

6 asked whether the four scraped widget IDs were a complete census of
Studio Pro's bundled widgets. Slice 1 established they are not bundled at
all, so there is no list to complete.

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

`mxcli check` printed the same warnings in a different order from one run
to the next. Three validators — MDL-WIDGET07, WIDGET17 and WIDGET18 —
append one violation per property key while ranging over w.Properties
directly, and Go randomises map iteration.

Nothing was wrong with the diagnostics themselves, and no message changed.
What was wrong is that a before/after diff of `check` output is not usable
as a measurement, which is how this was found: sizing the grammar change
for slices 2-3 means diffing check output across mdl-examples/ before and
after, and two runs of the SAME binary disagreed on 11 of 515 scripts —
more noise than the change under test produced signal.

    two runs, same binary, before   11 of 515 scripts differ
    two runs, same binary, after     0 of 515
    message SETS vs baseline         unchanged on all 515

Sorted by property key, so the order is not merely stable but predictable.

Two other loops over w.Properties are deliberately left alone: they do a
case-insensitive LOOKUP and break on the first hit, so they are only
order-sensitive when a widget carries two keys differing solely in case,
and picking either is equally correct.

The regression test uses eight unknown properties — with unsorted
iteration the chance of Go returning the same order twice is 1/8! — and
asserts both stability across 50 calls and that the order is sorted. It
also asserts the fixture produces 8 violations, so it cannot pass by
producing none. Control: with the sort reverted, both tests fail on the
first comparison.

This is the checklist's "map iteration is deterministic" item; it is the
same defect class in a place nothing was watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
… 2, 3)

mendixlabs#1036. `mxcli widget init` generates widget documentation
for agents to follow; fed back into `mxcli check` verbatim, the generated
htmlelement.md failed on the first line of its own example:

    line 4:4 mismatched input 'tagcontentcontainer' expecting '}'

The doc generator derived a keyword for every object list and child slot in
a definition; the grammar accepted nine hardcoded ones. Two lists, nothing
comparing them. 16 of 46 documented constructs parsed. It is 50 of 50 now.

The cost was never the wasted sessions. Unable to express the `attributes`
list, the reporter's team fell back to tagContentHTML, which executes
same-origin — the exact risk the sandbox they wanted was for.

Slices 2 and 3 land together because slice 2 alone is not a working state:
it makes every name parse, which invalidates the way slice 4 derives whether
a container is authorable, and three DESCRIBE WIDGET tests fail until slice 3
completes the picture. Splitting them would mean committing a red suite.

The grammar is two alternatives, ordered last
--------------------------------------------

`| IDENTIFIER` covers `htmlelement frame (...)`. `| keyword` covers a
container whose name lexes as a keyword token — `attribute` is ATTRIBUTE and
never IDENTIFIER, so slice 2 alone cannot reach the case that motivated the
issue. No parallel body rule was needed after all: a container and a widget
occupy the same position in a body.

The write path was already def-driven — the page builder tries
widgetRegistry.Get(ToUpper(type)) FIRST — so nothing about BSON changed.

The validator is the load-bearing half, exactly as Open Question 1 warned
-------------------------------------------------------------------------

Loosening a grammar moves a parse error into the validator, or it becomes a
wrong answer. Measured, before that half existed:

    htmlelemnt frame (tagName: 'div')   0 errors, 1 warning (about `tagName`)
    htmlelement frame (tagName: 'div')  completely clean

So the AST now records WHICH alternative matched (WidgetV3.TypeIsGeneric),
set from the parse tree in the visitor and never by comparing the type text
against a list of known names — that would reintroduce the list this change
removes. MDL-WIDGET25 gained a branch for it, MDL-WIDGET07 is suppressed for
it, and inside a resolvable parent the message routes to MDL-WIDGET26, which
names what the parent does declare:

    `attribut` is not a container of `htmlelement` — it declares: attribute,
      event, tagcontentcontainer, tagcontentrepeatcontainer

Both rules still need a project, for slice 0's reason: the set of valid
widget names IS the project's installed packages. With no -p a typo cannot
be told from a widget mxcli has never seen, so it says nothing. That is a
real cost of the change and it is stated in `mxcli syntax page.widget-any`.

The measurement, and what it could not see
------------------------------------------

Open Question 2 asked how far the ambiguity reaches, and said to measure
rather than argue. Diffing `mxcli check` across all 515 mdl-examples scripts:

    IDENTIFIER (slice 2)              0 verdict changes, 0 message changes
    + keyword (slice 3, 655 tokens)   0 verdict changes, 0 message changes

The diff was necessary and NOT sufficient, which is the more useful result.
SLOT, PLACEHOLDER and USE are all in `keyword`, and with widgetV3 ordered
first in pageBodyV3 the generic alternative swallowed them: `slot body`
became a widget of type `slot`, `placeholder Main { … }` a widget named
Main. Both still parsed and still exited 0 — the damage is to the AST, not
the diagnostics, so 515 scripts showed nothing and two visitor unit tests
caught it at once. The specific alternatives now precede widgetV3, and a
test says why so nobody reorders it for tidiness.

Item 9 of the slice (delete the nine object-list keywords) is deferred, with
the reason recorded in the proposal: they are redundancy rather than drift
risk now, and removing them would flip nine keywords to the generic branch
where a container inside an unresolvable parent would newly false-positive.

Verified: full suite, `make lint`, `make check-mdl` (479 scripts, +1 new),
`make check-skill-mdl`, `make check-findings`. Three findings appended,
including the one worth reusing — a corpus diff of check output compares
diagnostics and is blind to a construct that parses into the wrong shape.

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

Item 6 of slice 2 in PROPOSAL_def_driven_widget_bodies.md, plus a defect
that item exposed.

DESCRIBE PAGE now emits `htmlelement frame (…)` rather than
`pluggablewidget 'com.mendix.widget.web.htmlelement.HTMLElement' frame (…)`,
which is the form a person would have written and only became possible when
slices 2-3 made the keyword parse.

It falls back to the id form rather than guessing. The bar is not "shorter"
but "rebuilds the SAME widget", and the guard is a round trip through the
lookup the builder itself uses: the registry is keyed BY MDL NAME, so two
definitions claiming one name leave Get and GetByWidgetID disagreeing while
All() cannot see the collision at all. Counting definitions would have been
dead code that looked like a guard.

The round trip was silently lossy
---------------------------------

Verifying the above on a page mxcli authored itself turned up worse:

    written    htmlelement frame { attribute a1 (…)
                 tagcontentcontainer body { dynamictext t (…) } }
    stored     BSON carries tagContentContainer WITH its DynamicText, and
               attributes WITH the data — the write path is correct
    described  htmlelement frame (tagName: 'div', …)     <- body gone

So describe -> edit -> exec deleted a widget's body and said nothing. That
is the mendixlabs#965 failure class, and slices 2-3 made it reachable by making the
construct writable in the first place.

Reconstructing those containers is its own piece of work. Silent data loss
is not acceptable in the meantime, so DESCRIBE now names the gap where the
body would have been — the same rule slice 4's usage example already
follows: say what was left out rather than look complete.

Two things had to be right for that note to be worth reading:

  - getBsonArrayElements STRIPS the leading typed-array marker, so an empty
    container is length 0 here and length 1 in the raw BSON. Checking the
    raw length reports every widget as lossy.
  - It warns on CHILD SLOTS HOLDING WIDGETS only. A widget template ships
    DEFAULT entries in its object lists that are structurally identical to a
    user's, and the first version duly named `event` on a page that never
    wrote one. A note that fires on defaults is noise, and noise trains
    people to ignore the notes that matter. A widget inside a slot has no
    such ambiguity: a template never puts one there.

Measured both ways: 16 real pages in testdata/expr-checker produce 0 notes,
and the authored page produces exactly one, naming tagcontentcontainer.

Also fixes the wiring bug found on the way: GetWidgetRegistry passed
filepath.Dir(ctx.MprPath) where LoadWidgetRegistry wants the .mpr path
itself. A correct fallback hid it perfectly — output stayed valid, nothing
failed, no test went red.

Controls, each run against the reverted guard: forcing the id form fails the
name test; dropping only the WidgetID comparison fails the ambiguity test;
making the empty-container check never trigger reports `emptyslot` as lost.
Two findings appended.

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

DESCRIBE PAGE now reads back both kinds of container on any pluggable
widget, so describe -> edit -> exec stops destroying a widget's body.

    before   htmlelement frame (tagName: 'div', …)
             -- NOT SHOWN: tagcontentcontainer …
    after    htmlelement frame (tagName: 'div', …) {
               attribute attribute1 (AttributeName: 'data-x', …)
               event event1 (EventName: 'onClick', …)
               tagcontentcontainer tagcontentcontainer1 {
                 dynamictext t (Content: 'hi')
                 container "inner" (Class: 'row') { … }
               }
             }

Measured end to end on a page mxcli authored: 4 widgets survive the round
trip, and `data-x` is still in the document. CONTROL: with the child-slot
extractor removed, the same round trip leaves 1 of 4 — describe -> exec
deletes three widgets.

Child slots: read from the document, not looked up by name
----------------------------------------------------------

DESCRIBE reconstructed a Gallery's `content` and `filtersPlaceholder` by
asking for those keys BY NAME, and had nothing generic. A child slot is any
property whose Value holds a Widgets array, so reading that shape off the
document makes a widget nobody has thought about round-trip for free.

Empty slots are skipped. getBsonArrayElements strips the leading typed-array
marker, so an empty container is length 0 here and length 1 in raw BSON;
emitting them would put a `slot { }` block on nearly every pluggable widget.

Object lists: the Action branch was eating every property
---------------------------------------------------------

`attributes` and `events` were dropped while a chart's `series` described
fine, which made it look widget-specific. It was not. extractObjectListItem
tests a value's fields in order, and the ACTION branch continued
unconditionally once `value["Action"]` merely EXISTED. A widget value
carries every field it could have, and Action is always present as a
Forms$NoAction — so it consumed all six sub-properties of an item, the item
ended with zero Props, and the caller's `len(item.Props) > 0` filter dropped
it, taking the whole list with it.

Isolated by reverting that one branch: object lists go 2 -> 0. DataSource
and AttributeRef are the same latent shape and are guarded the same way;
neither is load-bearing for the measured case, and the comment says so.

They consume the property when the sub-document is PRESENT AND NON-EMPTY,
not when it merely exists and not only when it renders — a datasource that
cannot be rendered must not fall through to the scalar branches, which would
describe it as its PrimitiveValue. That distinction was caught by a control
in my own first attempt, which let exactly that happen.

Two measurement lessons, both recorded as findings
--------------------------------------------------

My first control used a string replace with no assertion, matched nothing,
and was VACUOUS — it "passed" while changing no code. The second flipped
three conditions but missed that a `continue` had MOVED, so it reverted the
wrong thing and pointed at DataSource; only `git diff -U0` with comments
filtered out showed the moved line. A confident wrong root cause in a
comment would have been worse than none.

Also worth knowing: the round trip CONVERGES rather than being a fixed point
on the first pass — the writer emits an item's properties in a different
order than the original document, so describe #1 != #2 but #2 == #3.
Describe itself is deterministic (5 identical runs). And a fixed-point
assertion alone would not have caught the original bug, since an empty
describe is also a fixed point; the assertion has to be that the CONTENT
survives.

Verified: full suite, make lint, 479 MDL examples, check-findings. Describe
output for all 16 fixture pages is byte-identical to before, so nothing that
already worked moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
DESCRIBE PAGE upper-cased the first letter of every widget schema
sub-property key. That round-tripped — MDL property names are
case-insensitive, so `StaticName` and `staticName` both resolve — but it
made DESCRIBE PAGE the only surface using that spelling:

    mxcli widget describe linechart   staticName      (read from the .mpk)
    what you write in a page          staticName
    DESCRIBE PAGE, before             StaticName

Three surfaces, two spellings, for no benefit. It was invisible while only
chart series reached this code; slice 3 put object lists on every widget,
which is what made it worth fixing.

Emitting the key verbatim also keeps a real distinction visible that
PascalCase erased. In a chart series both of these now read differently, and
should:

    DataSource: database from ChartExamples.SalesByQuarter   <- MDL's keyword
    staticXAttribute: Period                                 <- widget schema key

Measured: 9 of 26 pages in the fixture change, every one of them a page with
an object list (8 chart pages plus the HTML Element probe). Nothing else
moves. Round-tripped describe -> exec -> describe on the changed pages:
fixed point on HeatMap, Dashboard and Bubble, and the HTML Element's only
difference is the writer's own property-order convergence documented in the
previous commit.

Five tests asserted the PascalCase form and are updated. One of them,
TestObjectListMDLKey, gains an already-capitalised key so "verbatim" cannot
be mistaken for "lower-cased" — a widget is free to name a property `Foo`.

The two skills that teach this syntax are updated to match, since a skill
disagreeing with DESCRIBE is the same two-lists problem in documentation
form. Their `DataSource:` stays capitalised, which now illustrates the
distinction rather than hiding it. While there, one of them documented
`interpolation: linear | smooth`; the widget accepts `linear | spline`
(`describe widget linechart` reads it from the .mpk), so `smooth` was never
a valid value.

The example scripts in mdl-examples/ are deliberately NOT rewritten. They
are hand-authored and still correct — and keeping some in the other casing
is live evidence that case-insensitivity holds, which this change relies on.

NOT fixed here, found while round-tripping and pre-existing: exec of a
described ChartExamples.P_Line fails MDL-WIDGET08 because the stored page
carries `interpolation: 'smooth'`, which the widget does not accept. The
source example writes it at line 183. The pre-change binary fails
identically, so it is not this change; `make check-mdl` cannot see it
because that runs without a project and the enum values come from the
widget definition.

Verified: full suite, make lint, 479 MDL examples, make check-skill-mdl,
make check-findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
`Interpolation: 'smooth'` is not one of the LineChart's values. The widget
accepts `{linear|spline}` — `mxcli widget describe linechart -p <project>`
prints the enum — and `spline` is the curved one the example meant.

Why CI never saw it: `make check-mdl` runs `check` WITHOUT a project, so no
widget definition loads and MDL-WIDGET08 has no enum to compare against. With
a project attached the file failed, and since exec refuses a script with
errors, it could not be run at all.

The file's `mx check`-CLEAN header claim is re-verified rather than assumed:
exec'd into a Mendix 11.6.6 project and run through `mxcli docker check`
(mx update-widgets + mx check) on mxbuild 11.6.6 -> "The app contains: 0
errors." The control that the check is not inert on that project: a view
entity with a deliberately wrong column type in the same app fails CE6770.

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

`ValueAttribute: Total` on a PieChart or HeatMap was reported as

  widget `pieChart1` (piechart) has no property `ValueAttribute` [MDL-WIDGET01]

and, because exec refuses to run a script with errors, the page could not be
written at all. The value is not merely valid — it persists: the stored page
comes back from DESCRIBE as `seriesValueAttribute: Total`.

Root cause, measured on piechart.def.json:

  {"propertyKey": "seriesValueAttribute", "source": "Attribute",
   "operation": "attribute", "mdlAliases": ["ValueAttribute"]}

allowedWidgetProperties built its set from PropertyKey and Source only. Three
places read this field — the builder resolves through it (widget_engine.go),
the knownProperties set walks it (widget_defs.go), and the validator did not —
so the checker was the odd one out on the same def.json.

Tests carry their own controls: the schema key must STILL be allowed (a fix
that swapped one name for the other would pass the first assertion and break
DESCRIBE's own output), and the mode-scoped case uses an alias that is not a
case variant of its key, since `seriesName`/`SeriesName` collapses to one
entry once lowercased and would pass against the broken code.

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

Every chart series written the documented way drew

  series `sRegion` property `dynamicDataSource` is hidden when its own
  `dataSet` is "static" — the value will be ignored [MDL-WIDGET10]

11 of them on 34-chart-widget-examples.mdl, one per series, about a property
no script in the file ever wrote.

A chart's two datasource sub-properties SHARE the Source name "DataSource"
(measured on linechart.def.json: staticDataSource and dynamicDataSource both
declare it, neither declares an alias), so the item's dataSet mode is what
selects between them. buildObjectListItem routes on exactly that —
seriesDataSourceMatchesMode — and writes only the matching one. itemValueMap
resolved by Source alone, so one friendly `DataSource:` marked both explicit
and the hidden-property rule fired on the sibling.

The gate is scoped to a chart series' datasource pair, which is the only place
two item properties claim one Source; anything wider would blind MDL-WIDGET10
on every other object list. Three controls hold that line: the routing must
follow `dynamic` as well as `static` (not just prefer static), a non-chart
item property sharing a Source must still resolve, and an explicitly named
`dynamicDataSource:` must survive the gate, because the builder honours the
schema key before it consults the mode.

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

`check -p` reported, on a view entity whose source entity the same script
creates:

  attribute 'Units': declared as Integer but OQL expression 'sum(s.Units)'
  returns Decimal. Fix: change to 'Units: Decimal'

Following that hint breaks the build. Measured on mxbuild 11.6.6, two views
over the same `sum(s.Units)` where Units is an Integer attribute:

  declared Integer -> 0 errors     <- what mxcli flagged
  declared Decimal -> CE6770       <- what mxcli told the user to write

So the diagnostic did not merely cry wolf; its Fix: walked a working project
into a broken one. The control that makes those numbers mean something: a view
declaring a String column over `sum(s.Amount)` in the same app DOES fail
CE6770, so mxbuild really was validating view entities rather than ignoring
them.

inferAggregateType's SUM branch returned Decimal whenever the argument type
could not be resolved — which is precisely when the source entity is created
by the same script, since check skips references to script-created objects,
i.e. the common shape for a view entity. inferTypeStatic's own SUM branch
already says in as many words not to guess Decimal there; the project-aware
path contradicted it. Now both return Unknown, and an unknown inferred type is
skipped rather than asserted.

Controls: sum() must still PROPAGATE a type it can resolve (or "return
Unknown" degenerates into never checking sum() at all), and COUNT/AVG keep
their own rules, so a fix that made every aggregate Unknown would be caught.

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

Three charts on one page, each with a series the author called `s`, failed:

  duplicate widget name 's' (used 3 times) — Mendix requires unique widget
  names per page (CE0495)

mxbuild 11.6.6 reports 0 errors on that exact page. And the name is not stored
at all: author `series sRegion (…)` and DESCRIBE returns `series series1 (…)`,
because the stored WidgetObject carries no name and DESCRIBE has to synthesise
one. A name the model does not hold cannot collide — which is the same
reasoning that already excluded rows and columns in
widgetKindsWithoutStoredNames, extended to object-list items.

The container keywords are read from the parent's def.json `objectLists`
rather than listed here. A keyword table would be the second list this line of
work exists to remove, and it would drift the moment a widget ships a new one.
That needs the registry threaded into validatePageContextTree; when there is
none — `check` in CI runs with no project — nothing resolves, itemKinds is
empty, and the rule behaves exactly as before.

Controls, all of which fail if the guard is made broader than that: a genuine
duplicate widget name is still reported; two widgets sharing a name inside a
pluggable widget's CHILD SLOT (not an object list) are still reported; and
with a nil registry a plain duplicate is still reported. Stubbing the guard
fails the new test and leaves the other three green.

The registry is built in memory for the test: no embedded widget definition
declares an object list (0 of them), and the .def.json cache a project derives
from its .mpk files is gitignored, so both alternatives would skip in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
claude and others added 27 commits September 5, 2026 18:11
One record each for the .mpk/definition property split, the two-sided
hide-rule disagreement, the placeholder item value, and the unwalked
object-list items.

Three of the four are the same shape as the PR they were found in: two
readers of one fact, with nothing comparing them. The measurement notes are
the part worth keeping — a stale catalog cache made a fixed binary look
unfixed, and a growing unfiltered ref count looked like progress while the
specific question still returned nothing.

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

CI failed on 63d1614's own control:

  --- FAIL: TestGeneratedExamplesUseRealItemValues
      no widget has an authorable object list with item properties —
      nothing here would exercise the item literal

The control was right to fire and the test was wrong to depend on the
environment. .mxcli/widgets/*.def.json is derived and gitignored, so a fresh
checkout has only the hand-crafted definitions in sdk/widgets/definitions/ —
none of which declares an authorable object list with item properties. Locally
the generated cache supplies 33 more, including the charts and DataGrid2 the
test needs. Reproduced by moving the cache aside; the failure is identical.

Worth naming: the neighbouring test's own comment already says the cache is
gitignored and must not be depended on. I wrote that sentence and then wrote a
control that depends on it.

The end-to-end test now SKIPS when the environment cannot exercise it, saying
why, and four hermetic tests carry the actual guarantee on a synthetic
description: an enumeration sub-property is preferred over a free-text one and
yields a real member; the DEFINITION beats the package (the PopupMenu case,
where ParseMPKForWidget returns 0 children while the definition carries
itemType's enumValues); an all-free-text container keeps the placeholder; and
an empty container returns nothing rather than panicking.

Control: with fix 63d1614 reverted, the first two fail and the other two pass
— so they detect the defect rather than the shape of the code. The package
suite is green both with the def cache present and with it moved aside, which
is the condition CI runs in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
A test green locally and red in CI, because it read a derived .def.json cache
that is gitignored. The reproduction is the useful part: move the artifact
aside and the CI failure appears exactly, with no push needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Slice 4 added a statement and every sibling DESCRIBE had a reference page —
entity, association, enumeration, microflow, page — while this one had none.

Covers both call forms, the two things that make its output trustworthy (the
`authorable` mark is derived by parsing a probe against the live grammar, and
the MDL example is parsed before emission with real enum members rather than
placeholders), why bindings are named rather than invented, and why LIST WIDGETS
deliberately does not exist.

The sample output is copied from a real run against an installed HTML Element
1.2.2, not composed: the first draft said "Body containers (3)" from memory and
the actual widget has four — `tagcontentrepeatcontainer` was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
The page opened with "MDL supports a comprehensive set of widget types" over a
closed category table — Layout, Data, Input, Display, Action, Navigation,
Structure. That was true, and slices 2-3 made it false: every pluggable or
custom widget installed in the project is now named by its own MDL name, with a
body derived from its definition. A reader of the user manual would have
concluded `htmlelement` does not exist.

Adds an "Any installed widget" section covering the short form, where the three
parts of it come from (the keyword is the widget's MDL name, the properties use
the widget's own spelling, the containers are its object lists and child slots),
how to find them with DESCRIBE WIDGET, the explicit id form as the FALLBACK
rather than the norm, and what a name that resolves to nothing gets
(MDL-WIDGET25 with suggestions, needing -p).

Both examples were run through `mxcli check -p` before being written down, not
composed from memory — the short form and the id form in one page, `Check
passed!`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Every example in the custom-widgets skill wrote
`pluggablewidget 'com.mendix.widget.web.barchart.BarChart' chart1`. That still
works, but since slices 2-3 it is the FALLBACK, not the normal way — and the
skill is what an agent reads before placing a widget, so it was steering every
generated page to the long form.

Adds an opening section on naming a widget by its own name, with the body
containers read from its definition, and three things worth knowing before
writing one: `describe widget <name>` answers "what can I write here?" faster
than reading the file and cannot go stale; the id form is for an ambiguous MDL
name; an unknown name is MDL-WIDGET25 with suggestions rather than silence, and
both new rules need `-p`.

The frontmatter description is updated too — it is the routing mechanism, so a
capability absent from it is a capability the skill will not be loaded for.

The charts section kept its id table (it is what `describe widget` prints and
what identifies a widget unambiguously) but no longer says charts are authored
BY id, which stopped being true.

`make check-skill-mdl` passes: 205 MDL blocks checked. The embed dir under
cmd/mxcli/skills/ is gitignored and regenerated by `make sync-skills`.

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

Two small factual errors.

MDL-WIDGET01's remedy in the error appendix said `mxcli describe widget <Name>`.
That command does not exist — it is `mxcli widget describe <name>`, or
`describe widget <name>;` in MDL. Someone hitting a property-name error would
have been sent to a usage error.

The `widget describe` Cobra help described properties and dynamic rules only,
which was complete when it was written. The command now also reports body
containers with whether MDL can express each, and ends with a runnable MDL
example — the two things a reader most wants from it. The help says so, and
says why bindings appear under "omitted" rather than being invented.

Also trims the custom-widgets frontmatter description to 580 chars.
TestEmbeddedSkillsCarryAgentSkillsFrontmatter caps it at 600 and my previous
commit pushed it to 617 — skill listings truncate well before that, so an
over-long description loses the tail that says when to use the skill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
Widgets as first-class MDL: proposal and slices 0–5 (mendixlabs#1036)
Close ako/mxcli-rest FINDINGS #60: message-definition cardinality, and three gates that let a mistake through
`check --references` resolved the document and the entity a statement names
and stopped at the door. A member name inside one — the attribute on the left
of a create/change assignment — was never resolved, so a typo passed check,
passed exec, and surfaced at the far end of a build:

  CHANGE $Order ("IsArchived" = true);
    mxcli check -p app.mpr --references  -> exit 0
    mxcli exec                           -> "Created microflow"
    mx check   -> [CE1613] "The selected attribute
                  'Bench.Order.IsArchived' no longer exists."

exec does resolve the name, and on failure writes `<entity>.<member>` anyway;
that fabricated identifier is what mxbuild rejects. The check reports it where
it is cheap to fix, and names the entity's real members so the typo is visible
rather than merely asserted.

A CHECK needs three outcomes where exec needs two. exec can answer
resolved / not-resolved because it has a fallback either way; a check that
turns "I could not look" into "your attribute is missing" is a false error
that blocks a script which builds cleanly. So the walk has a third state for
"could not establish" and reports only the middle one. That covers a backend
that cannot answer — mxcli has several, and every lookup in the interface may
error — and it is also what makes an entity absent from the project silent
without a special case.

Coverage is bounded by whether the target's entity can be established: a
create (the entity is in the statement), a parameter, a database retrieve, an
association retrieve from a typed start, or a loop over any of those. A
variable bound by an activity this does not model is left unchecked rather
than guessed at. That boundary is documented and has its own test.

Verification, because three separate green results here were vacuous and only
perturbation caught them:

  - a sweep of 14 real scripts reported 0 false positives while the check
    never fired — every CHANGE target in that corpus is bound by an
    unmodelled activity. Proven non-vacuous by perturbing one character of a
    real attribute name in one of those scripts and watching it report.
  - the example passed while exercising nothing: a "skip entities the script
    creates" guard skipped the whole self-contained file. The guard was
    removed as redundant (an absent entity is already unknowable), which also
    widened real coverage.
  - the example is inert against a virgin project, since members resolve
    against the model. Its header now says exec-first and why.

Controls: stubbing the check fails the three positive tests; collapsing the
third outcome into "missing" produces exactly the false positive the design
prevents. 482 MDL examples still check clean.

mendixlabs#1048

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

Two more member positions reference checking walked past. Both passed
`check --references`, passed `exec`, and failed the build with CE1613.

XPATH CONSTRAINTS (mendixlabs#1049). The entity in `database from
Mod.Entity` was resolved and the `where […]` never looked at:

  where [Bench.Order_Status = 'Open']
  mx check -> [CE1613] "The selected association 'Bench.Order_Status'
              no longer exists." at Data grid 2 'gridOrders'

Every step is now followed with the entity it lands on, the same traversal
mdl/xpathrefs performs for renames: a bare step must be an attribute, a
Module.Name step an association or an entity.

TEMPLATE PARAMETERS (mendixlabs#1046). A parameter is evaluated against
the widget's own context object, so a variable root has nowhere to resolve and
the writer keeps it as part of the attribute name. This one needs NO project —
the answer is in the statement — so it runs in the unconditional pass as
MDL-WIDGET24 rather than under --references, where `mxcli check page.mdl`
would have stayed silent on a mistake it can see.

The template rule is not "a variable root is wrong", and reasoning rather than
measuring would have shipped a false positive. Five shapes, each executed and
built on 11.13.0:

  OrderNo                                     clean
  Assoc/Attr                                  clean
  $currentObject/Assoc/Attr                   clean   <- the prefix IS stripped
  $currentObject/Attr                         CE1613  <- only on that branch
  $Var/Attr                                   CE1613

Two things fell out of that table: the legal association form is two segments,
not the XPath three (`Assoc/Entity/Attr` also fails), and `$currentObject` on
an association path must not be flagged.

The check then found a defect in the corpus meant to validate it.
`ledger-27-consecutive-dynamictext.mdl` had shipped using
`{1} = $currentObject/Amount`, had only ever been run through `mxcli check`
with no project, and built with SIX CE1613s the first time it was executed.
Fixed here, with its header saying what happened.

One false positive was found by its own control and is worth naming: reporting
a qualified XPath step requires the BASE entity to be known first. Without
that, a page whose entity the project does not have reported a perfectly real
association as missing. The three-valued discipline has to be applied at every
reporting site, not once — the bare-member path inherited it from
resolveMemberOnEntity and the qualified path did not.

Controls: stubbing either check fails its positive tests; dropping the
base-entity guard reproduces the false positive; an unparseable constraint is
skipped rather than reported, since the XPath parse can return a tree that
omits part of its input. 483 MDL examples and 205 skill blocks check clean.

mendixlabs#1049, mendixlabs#1046

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
The command was inert on every MPR v2 project. It returned on the first module
it could not reconcile, and one module always is: System, whose domain model is
SYNTHESIZED rather than stored — there is no unit file behind the id the module
carries, so the reconcile could not load it.

  $ mxcli -p app.mpr -c 'update security'
  Error: failed to reconcile security for module System: load domain model
  00000000-…-002: …/mprcontents/00/00/…002.mxunit: no such file or directory

That is the whole of the report's first half: it was filed as "UPDATE SECURITY
does not fix the CE0066 it exists to fix", and the reason is that it never ran.
Whether it had written some modules before dying depended on where the
unreconcilable one fell in the list, so it was also non-atomic.

System is now skipped by name rather than left to fail, because reconciling it
is not merely impossible but wrong: its entities are the platform's and its
access rules are not the project's to rewrite. Naming it is an error, not a
silent no-op. Any other unreadable module is reported and stepped over, so a
project with one still gets the rest reconciled and is told what was missed.

A second defect in the same command: `update security RestLab` — without IN —
reached the parser's error recovery, which consumed the module name silently.
The statement parsed as ONE statement with no error, `mxcli check` reported
"Syntax OK", and the run went project-wide. That is a silent scope escalation,
which is worse than a parse error: the author asked to touch one module and the
command touched all of them. IN is now optional before the NAME rather than
before the whole clause, so both spellings scope. A typo is refused too —
matching no module used to reconcile nothing and print "All entity access rules
are up to date", a success message for a run that did nothing.

Worth recording, because I conflated them once already this session: there are
TWO System failures in this codebase. GetModuleByName/GetDomainModel answer for
System perfectly well — System.FileDocument loads with its 6 attributes, which
is why inherited-member resolution works — while loadDomainModelGen, which
reads the unit by id out of mprcontents, cannot. Same module, opposite answers,
different call path.

What this does NOT claim: the report's precondition does not reproduce.
Measured on 11.13.0, `alter entity … add attribute` on both engines and a
whole-entity `create or modify` rewrite each left the project at 0 errors,
because every mxcli write path already reconciles — so no stale rule could be
produced to repair end to end. The integration example says that outright
rather than implying coverage it does not have; the repair itself is covered by
unit tests, which can construct the stale state.

Control: restoring the abort and removing the System skip reproduces the
reported error verbatim and fails three of the six tests. A project-wide run
writes zero units when nothing is stale.

mendixlabs#1047

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

Two defects that passed check, passed exec, and arrived as CE0117 "Error(s) in
expression" at the far end of a build.

A BARE WORD as a member's value (mendixlabs#1044):

  CHANGE $Order (Status = Closed);

Mendix expressions have no bare identifiers — a value is a literal, a
$variable, a qualified name or a function call — but the parser reads one as a
variable reference, it resolves to nothing, and the kind comes out Unknown.
Unknown is tolerated by every rule by design, which is exactly how this slipped
through. VariableExpr now records whether it came from a plain identifier or
from `$Name`, and E013 reports one standing alone as a member's value.

The scoping is the design rather than caution: a bare name NESTED in a
list-operation predicate is legal MDL — `FILTER($L, Status = 'Open')` resolves
`Status` against the item under test — so the obvious rule ("no bare
identifiers") rejects working scripts, and the report's own second example is
one of them. A member's value is the one position where the bare word is the
entire expression and can only be a mistake. Consequence, stated rather than
glossed: the report's `FILTER($All, Status = Open)` is NOT covered, because the
bad half is nested.

A NON-STRING LOG TEMPLATE PARAMETER (mendixlabs#1043) turned out to be
two gaps in series. The adapter walked LogStmt.Message and never the template
parameters; and exprcheck's slot-expectation table had existed with NOTHING
READING IT — slotKind() was defined and never called — so the pre-existing
`LogStmt.Message: {Kind: KindString}` constrained nothing either, and
`LOG WARNING 42` passed too. That second one surfaced only because adding the
new slot changed nothing.

Enforcement is limited to CONCRETE expectations. An entry carrying ResolveBy
names a kind resolved per call site, and the adapter encodes that by appending
the target to the slot path ("ChangeItem.Value:Sales.Order.Status"), which does
not match the table at all — so those stay unenforced rather than being checked
against a placeholder kind.

Which kinds actually fail, measured on 11.13.0 by executing four microflows and
building them — three errors for four parameters:

  String    clean        Integer   CE0117
  Decimal   CE0117       Boolean   CE0117
  DateTime  CE0117       Object    CE0117
  toString(anything)     clean

So the report's diagnosis ("the writer's template/parameter emission is wrong,
not the caller's type") does not hold: toString(...) around the identical value
builds cleanly, so the writer is fine and Mendix simply does not coerce. The
check now agrees with mxbuild exactly — 3 reported for those 3 failures, String
untouched.

Controls: stubbing the two new checks fails three tests; a bare name nested in
a predicate, an unmapped slot, a ResolveBy slot, `empty`, and every real
expression spelling all stay silent. 484 MDL examples and 205 skill blocks
check clean, and 14 real scripts from ako/mxcli-rest report nothing — proven
non-vacuous by unquoting one string literal in one of them and watching E013
fire.

mendixlabs#1044, mendixlabs#1043

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

A datagrid whose datasource is an association path at PAGE level typed its rows
as the entity it navigates AWAY from:

  datagrid gA (datasource: $Customer/Bench.Order_Customer) { … OrderNo … }
  mx check -> [CE1613] "The selected attribute 'Bench.Customer.OrderNo'
              no longer exists." at Columns (1/1) of data grid 2 'gA'

The destination is resolved as "the end opposite the context", and the context
passed was pb.entityContext — the ENCLOSING data container's entity. At page
level nothing encloses the widget, so it is empty, neither end matches, and the
resolver's last-resort fallback ("default to the child (TO) side") returns the
entity the grid started from. Every column then binds against it.

The report's own diagnosis is what localised this and deserves the credit: it
noticed the same path INSIDE a data view was correct. That rules out the
association logic and points at the context. A named context variable answers
the question directly — `$Customer/…` traverses from whatever $Customer holds,
enclosed or not — and pb.paramEntityNames already knew it. The enclosing-entity
path is untouched, so `$currentObject/Assoc` inside a data view behaves exactly
as before.

Worth generalising: a resolver taking an ambient context is correct exactly
where that context exists, and its fallback is what runs everywhere else. This
one carried a plausible comment ("matches the common FROM=context pattern") and
was silently wrong for the whole page-level case.

Two measurement notes. check-mdl cannot catch this — it only runs `mxcli check`
and the defect is in what the WRITER stores — so the regression net was exec +
mx check over the six examples that use an association datasource: five clean,
and the sixth failing identically before and after on an unrelated CE0106. And
the reverse-direction control first failed for the wrong reason: traversing a
Reference from its FROM end yields ONE object, so a grid over it is CE8812 "A
grid association path must result in a list" — a cardinality complaint, not a
resolution one. It is a data view now, so it tests what it claims to.

Controls: reverting to pb.entityContext fails the page-level test with the
reported symptom ("rows typed as Bench.Customer, want Bench.Order"); the
data-view case, an unknown variable, and the reverse direction all stay green.

mendixlabs#1045

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
… parse error a code

Three things check accepted that exec or the build refused.

AN UNQUALIFIED CREATE (mendixlabs#1050) is now MDL074, with no project
needed. exec always refused it — "module name is required" — and the
reportable cost is not the message: exec is NOT TRANSACTIONAL, so the
statements before the failure are already applied and re-running then hits
"already exists" on them. Driven off the existing stmtCreateInfo walker, so it
covers every document type rather than just associations; a module is excluded,
having nothing to be qualified by. validate_association_module.go had already
documented this exact gap in a comment, and closing it association-only would
have left every other document open.

`RETURNS void AS $x` (mendixlabs#1041) is now MDL075, also project-free.
An alias names the variable a flow returns, so pairing it with void is a
contradiction — and mxcli believed the alias, writing `return $x` into a flow
with no such variable:

  mx check -> [CE0109] "Undefined variable 'result'." at End event

Refused rather than repaired. Emitting a bare `return` would also build, but an
author who wrote an alias meant to return something, and silently dropping it
leaves the flow returning nothing while the source still says otherwise.

THE TRAILING-TOKEN PARSE ERROR (mendixlabs#1042) carried no code —
printed as `[]` — no document and no microflow, and its fix line named only
glued keywords. The reported input was `empty($Orders)`, where `empty` is a
Mendix KEYWORD rather than a function, so the parser consumed it and stopped at
the '('; people were sent hunting for an 'emptyor' that was not there. It now
carries code E014, a registry entry, the microflow it is in, and a fix line
naming that cause first. The line and column stay offsets into the expression
fragment rather than into the file, so the flow name is the locator — a real
limit, stated rather than implied away.

A tier-ordering fact worth knowing when reproducing any E0xx, and now in the
skill: the reference check runs BEFORE expression checking and exits on its
first error, so one unrelated mistake anywhere in a file hides every expression
hint in it. That cost real time here — a control microflow in the new example
had `set $out = …` without declaring $out, and the resulting reference error
made the E014 perturbation look like it was not firing. I guessed twice (needs
no project; a duplicate-CREATE conflict) before bisecting to the real cause,
and the example header records it.

Controls: stubbing each rule fails its own test, and the stubbed E014 prints
literally the `[]` the report complained about. A qualified CREATE, a bare
`create module`, `RETURNS void` alone, a typed return with an alias, and both
correct emptiness idioms all stay silent. 487 MDL examples, 205 skill blocks
and 14 real ako/mxcli-rest scripts report nothing new.

mendixlabs#1050, mendixlabs#1041, mendixlabs#1042

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
The perturbation note still said E014 needs "an app that does NOT already have
this module, or the duplicate-CREATE check exits first". That was my second
wrong guess at why the perturbation looked inert; bisecting showed the real
cause, which the tier note a few lines above already records — the reference
check runs first and exits on its first error, and the file had one. Leaving
both in meant the example asserted two incompatible things about itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Every mxcli-authored Image widget failed the build. On a project at 0
errors, one image widget on one new page:

  [error] [CE0463] "The definition of this widget has changed…"
    at Image 'imgProbe'

A field-level diff of Atlas' own brand image against a describe → rename
→ exec copy of it differs in one line of 1480: `maxHeight` = '0' where
Atlas stores '250' (mxcli-ledger FINDINGS §142).

ee29546 named that property and did nothing, because it was verified
against an input that does not exist: its defaults fixture declared
`maxHeightUnit`'s default as "none", and Image 1.6.0 declares "pixels" —
the one value at which the rule does not fire.

The defect is which configuration the rule is asked about.
`maxHeight` is hidden when `maxHeightUnit` is "none". `maxHeightUnit` is
unmapped, so widgetValueMap cannot answer, and the fallback read the
DECLARED default "pixels" → condition false → maxHeight read as visible →
no reset → the template's captured 0 stood. But being unmapped is exactly
why the document gets the template's "none" and never "pixels": nothing
writes an unmapped property. The question is whether the property will be
hidden in the document about to be written, and the declared defaults
describe a document that will not be.

So the template's captured configuration goes into that fallback chain,
between the script's own values and the declared defaults —
builder.PrimitiveValues(), read before any mapping is applied.

Ground truth for the value, measured across the 69 Image widgets of a real
11.14.0 project: all 65 carrying a `maxHeight` store 250, at every
combination of heightUnit and maxHeightUnit; mxcli's was the sole outlier.
Proven both ways on the real package by patching the stored 0 to 250
(1 error → 0) and restoring it (0 → 1), and end-to-end: the same MDL
authored by the old binary and the new one, side by side in one project,
gives exactly one CE0463 — the old one. The §142 layout-copy recipe now
produces a build-clean layout whose only remaining difference from Atlas'
is the Forms$SidebarToggleButton describe already flags.

The test's control is the reverted state: with the template's values
dropped, the rule falls back to the declared defaults and writes nothing,
which is what the shipped binary did.

Note for anyone reproducing this: use `mx check` directly, not `mxcli
docker check` — the latter runs `mx update-widgets` first, which
reconciles the widget and reports 0 errors while the stored value is
still 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Microflows, nanoflows and rules share one catalog table, so
LintContext.Microflows() yields all three. Eleven rule call sites
hardcoded DocumentType: "microflow", four of them also putting the word
in the message, so `mxcli lint` announced

  ⚠ Microflow 'Rule1' has no activities [MPR002]

about a rule, and the same about a nanoflow. The wrong noun also reached
the JSON and SARIF documentType field, where it is not cosmetic.

The noun is now derived from the catalog's MicroflowType by one shared
helper rather than written literally at each site, so the next rule to
iterate flows gets it right by default. An unrecognised type falls back
to "microflow": a finding with an imprecise label beats no finding.

Only the labelling changes. Which documents each rule applies to is
untouched — that is a separate question from what the report calls them.

Two things worth knowing for the next change here. Widening a format
string is not a compile error, so `go vet` is the gate that catches the
missing argument (it caught two). And the existing fixtures in
empty_test.go spell the type title-case where the catalog writes
uppercase; harmless while nothing read the column, but a fixture with the
wrong case now exercises the fallback instead of the mapping, so the new
test uses the catalog's own spellings.

Control: revert the two lines in empty.go and
TestEmptyMicroflowRule_NamesTheDocumentType reports the symptom verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
narrate.js makes a take watchable. Nothing made it true — that the beat
you filmed happened, and that the timestamp you cut on points at it.
These two files close that, generalised from the recorder and cutter in
ako/mxcli-intro-video (videos/sudoku-demo), where each guard cost a take.

take.js records both clock anchors, marks beats, paces actions to the
runtime rather than the script, guards the error dialog, applies the zoom
fix, and asserts per beat. cut-clips.js maps marks onto file time with
both anchors, refuses implausible ones, and writes a contact sheet.

The four findings behind them:

  - The recorder's clock is not the video's clock, and is wrong twice: an
    offset (recording starts at context creation, before the first
    navigation settles) AND a scale (~1.065 measured — capture drops
    frames while the page is busy). Correcting only the offset is the
    trap: a constant right at the start was four seconds wrong by the end.
  - A click can be swallowed while the previous request is in flight, so
    the payoff never arrives and the beat is worthless. Assert the state
    the beat is about. This is a check on the recording, not a verdict on
    the app — the demo still never gates the build.
  - Acting faster than the runtime commits deadlocks two microflows in
    Postgres; the resulting modal swallows every later click. Hence the
    pacing floor and the dialog guard.
  - recordVideo.size pads a smaller viewport rather than scaling it. The
    fix is viewport = video size plus CSS zoom.

Verified end to end against ffmpeg and a real headless Chromium, not just
parsed: a synthetic take with a burned-in timer cuts to 00:00:07.300
where the map predicts 7.294s, and all three refusals fire (implausible
scale, short clip without freeze, dead beat).

Testing found one bug worth recording: the dialog guard tested presence,
and Mendix ships the error-dialog container in the DOM hidden, so it
fired on every click — 800ms of dead time each and a meaningless count.
It now tests visibility, with a visible dialog as the control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
The skill covered pace, the compositor and the audio pipeline, but not
what makes a take usable: seeded data, asserted beats, an anchored clock.
All of this is measured on ako/mxcli-intro-video's films.

Added:

  - Seed the data before recording. The first ContactBook capture was an
    empty grid reading "0 to 0 of 0" with a header rendering as
    colActions — it reads as a broken app, not a new one. The journey
    passed and the recording was still worthless.
  - Hold = max(caption read, screen read), floor 2.5s. narrate.js already
    computes the caption term; the screen term is the one authors
    underestimate, and it has no way to know it.
  - About two events per ten seconds.
  - Zero Mendix vocabulary, with the sharper test: if the visual needs
    those words to make sense, the visual is wrong.
  - Do not invent a persona for a single-player product.
  - The four capture-integrity findings, and the scripts that carry them.

Also a boundary note. video-system/ in ako/mxcli-intro-video already says
this skill owns the capture and it owns the framing and cut; saying so
from this side keeps them from drifting, and keeps a film to one caption
system rather than two layered in the edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
Fix mxcli-ledger §142: every authored Image widget failed the build (CE0463)
Mendix's navigation sidebar collapses to an icon rail, and that is the state
most users leave it in. A collapsed item shows its icon; one without falls back
to the first few characters of its caption, which is rarely enough to tell
"Orders" from "Order lines". Nothing said anything: the icon is optional in the
grammar, the model builds, `mx check` passes, and the only symptom is a column
of truncated words in a browser.

Covers every item at every depth, in BOTH statements that carry menu items —
`create navigation`'s `menu (...)` block and `create menu`. They share one AST
node (NavMenuItemDef) precisely so the two cannot diverge, and the rule walks it
once rather than twice.

Uniform rather than top-level-only. A sub-item renders in the flyout the
collapsed rail opens, and a submenu's PARENT sits directly on the rail, so it
needs one most of all. If a deep menu proves noisy, narrowing the walk is a
one-line change — reporting too little is the failure that is hard to notice.

A WARNING, not an error: the project builds and runs, `exec` refuses only on
errors, and a rule that blocked the script would turn a design opinion into a
gate.

It needs no project. Only the icon's TARGET has to be resolved, and MDL-ICON01
already does that separately — so this runs in the project-free pass and fires
under `make check-mdl`, rather than being inert in CI the way four of the widget
rules were.

Controls, because "flag every item" would pass the first assertion: a fully
iconed menu stays silent (asserted both as a unit test and end-to-end on the new
example script, which checks clean); an iconed sub-item beside two iconless ones
is not reported; a statement with no menu, and an unrelated statement type,
produce nothing. Verified the rule really fires in the corpus rather than being
absent — `make check-mdl` reports pass/fail only, so its zero MDL074 lines mean
nothing on their own; running check directly on an existing navigation script
shows the warnings.

The example is a POSITIVE one. MDL074 is a warning, so `check` still exits 0 and
a `.fail.mdl` would be reported by CI as "negative test unexpectedly passed" —
the trap the Makefile documents for mendixlabs#891/mendixlabs#892.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
DESCRIBE -> exec was destroying menu icons. Measured on testdata/expr-checker,
whose Home item carries a glyph icon:

  describe   menu item 'Home' page …;
             -- icon a numeric glyph code (Forms$GlyphIcon) is not reproducible …
  exec       Navigation profile 'Responsive' updated.
  describe   menu item 'Home' page …;        <- the icon was gone

Exit 0, success message, silent loss — the same shape as the pluggable-widget
body loss in mendixlabs#1036, and reachable the same way: describe -> edit
-> exec is the editing loop for a project whose rule is "never hand-edit the
.mpr".

The giveaway was already in the output and read as harmless. That comment was
written to make the loss visible, and it did — in the DESCRIBE output, not in the
exec that then acted on it. A "cannot reproduce this" note beside a
FULL-REPLACEMENT statement is a note saying "running this deletes it".

Mendix stores THREE icon elements, and they are not spellings of one value:

  Forms$IconCollectionIcon   a name in an icon collection
  Forms$GlyphIcon            a numeric character code, and NO name
  Forms$ImageIcon            a name in an IMAGE collection — a different document

Every layer handled only the first. The reader captured the $Type and the Image
but never the glyph's Code, so mxcli knew a glyph had been there and not which
one; the AST and the write spec carried a bare name with no kind at all; and both
engines' writers emitted IconCollectionIcon unconditionally.

Now each variant has its own MDL form, and the BARE form still means the
icon-collection icon, so every existing script keeps its meaning:

  icon Atlas_Core.Atlas.home        Forms$IconCollectionIcon
  icon glyph 57377                  Forms$GlyphIcon
  icon image MyModule.Images.logo   Forms$ImageIcon

Keyword-led rather than widening the bare form, because a bare name written for
an image icon would rebuild it as a COLLECTION icon — a silent variant swap, the
failure mode the original code avoided by emitting nothing. Nothing is inferred:
the kind comes from the author or from what the reader saw in storage. The two
keyword alternatives are ordered FIRST in the grammar, since qualifiedName
accepts a keyword as a name segment and `ICON IMAGE …` also matches the bare form.

NOT preserve-when-silent, which is what I proposed before writing the syntax.
Preserving is right only while a construct is INEXPRESSIBLE — an omission cannot
be a choice if the author had no way to say it. Once `icon glyph N` exists,
omitting it is as deliberate as omitting a collection icon, and preserving would
make it impossible to remove a glyph icon from MDL and make omission mean
different things for different kinds. The stale-script path is covered by MDL074
instead: verified, an old-style script against the fixture warns per item rather
than silently destroying anything.

Fixes MDL074 in passing, and it was a real false positive: the rule tested
`Icon == ""`, and a glyph has a code and no name, so it reported items that
plainly have icons — the fixture's own Home item among them. It asks the kind now.

Controls: with the describe fix stubbed, the collection case still passes while
glyph and image fail, so the tests detect the defect rather than the shape of the
code. A bare name with no kind must still write a collection icon, or every
script predating this silently loses its icons. A glyph with no code, and an
unknown $Type, are still flagged rather than guessed at — without a case that
still declines, "emits everything" and "reproduces everything" are
indistinguishable. Visitor-level tests assert the AST, per the mendixlabs#1036 lesson that
a corpus diff of check output is blind to a construct that parses into the wrong
shape.

Round trip on the fixture is now lossless: `icon glyph 57377` before, exec,
`icon glyph 57377` after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ
feat(demo): capture integrity for record-narrated-demo, from the intro-video films
Navigation menu icons: stop destroying them, and make all three authorable
@ako
ako merged commit e6c103c into mendixlabs:main Sep 6, 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