Skip to content

Sync ako/mxcli: local test loop, OData publishing, JAR dependency resolution, and 20 fixes - #859

Merged
ako merged 55 commits into
mendixlabs:mainfrom
ako:main
Aug 7, 2026
Merged

Sync ako/mxcli: local test loop, OData publishing, JAR dependency resolution, and 20 fixes#859
ako merged 55 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Syncs ako/mxcli:main into mendixlabs/mxcli:main — 44 commits since #857.

Most of this came out of two rounds of external findings against real Mendix apps; every fix was verified against mxbuild (mx check → 0 errors) on Mendix 11.12.1, and the runtime work in a browser.

Warm local test loop

  • mxcli test --local runs microflow tests with no Docker daemon, on mxcli's own runtime and its own <project>_test database.
  • Tests are driven over a token-guarded HTTP endpoint registered by a generated Java request handler — one microflow per test, invoked by name, so a throwing test fails only itself and results are returned instead of scraped from the log. Not registered at all without MXCLI_TEST_TOKEN; loopback-only; clamped to MxTest.Test_*.
  • The handler survives reload_model, which makes --watch possible: ~30s first run, then ~2s from an edit to a verdict.
  • --attach runs against an app already up under run --local --test-endpoint, skipping the boot entirely.
  • A test path now resolves against the project directory as well as the CWD (the CWD still wins).

Published OData services

  • Set the two defaults a published service needs to build — an empty service name produced CE0729, and object-id associations need the system ID as a published key (CE7375).
  • A published entity can now turn off Countable / SkipSupported / TopSupported.
  • DESCRIBE of a published service is re-executable: entity-set exposed name, KEY instead of IsPartOfKey, byte-identical on a describe → drop → recreate → describe round-trip.
  • New check MDL-ODATA01 reports property names that were previously discarded in silence, with a did-you-mean.

Managed Java dependencies

  • ALTER MODULE … ADD JAR DEPENDENCY wrote the coordinate but nothing downloaded the jar — mxbuild does not resolve them, mx sync-java-dependencies does. That step is now wired in: new mxcli sync-java-deps [--check], run automatically by run --local before the database check, and warned about at execute time.
  • New check MDL-DB01 warns on a database-connection type Studio Pro's picker does not offer.

MDL language

  • SET is now optional: $Total = 5; parses, matching every other assignment form.
  • Keyword-named functions accepted in widget conditional expressions; word operators lowercased in preserved expression source; IF NOT EXISTS / IF EXISTS on event handlers; - reads MDL from stdin.
  • ANTLR version pinned where the failure actually happens.

New checks

  • MDL-PAGE01: a forward page reference caught without needing a project.
  • MDL053: a loop variable used outside its loop.
  • A parameterized microflow on BEFORE CREATE is refused; demo users that cannot materialise are warned about.

Fixes

  • run --local: relative project paths resolved where MxBuild is called; the app's configured Application root URL honoured; a refused sign-in says why instead of "Sign in failed".
  • mxcli new: runs the first build so a fresh clone does not go dirty.
  • mxcli init: no longer invents a project when the directory has none; the SessionStart hook survives an idle reap; seeds a lint config excluding the System module.
  • Pages: an inherited attribute is referenced against its declaring entity; conditional settings Attribute written as "" not null; DownloadFileAction on the modelsdk engine.
  • Theme: the login page is themed and link text has its own token.
  • DESCRIBE SETTINGS CONFIGURATION '<name>' reads one configuration and shows its root URL.
  • OQL takes its column set from all rows, not just the first

claude and others added 30 commits August 6, 2026 21:59
`mxcli lint` on a freshly initialised app reported ~106 issues, of which ~102
came from the System module — Mendix's own platform module, whose entities you
cannot document, give access rules, or rename members of. Every one of those
findings is un-actionable, and they buried the handful about the developer's
own code. Measured on a real project here: 170 issues, 102 of them System,
across QUAL002 (50), SEC001 (38), CONV001 (8), DESIGN001 (4), MPR003 and
SEC006.

`mxcli lint` already had -e/--exclude and a lint-config.yaml with
excludeModules, so this was solvable — but only by a user who already knew to
look. The default is what people see. `mxcli init` now writes
.claude/lint-config.yaml with System excluded, which drops that project from
170 findings to 68.

The file is written only when the project has no lint config in any location
FindConfigFile searches, so re-running init never discards edits. It is written
outside the per-tool branches because `mxcli lint` reads it regardless of which
AI tool was selected.

Also warns when --modules names a config-excluded module. LintContext
.IsExcluded checks the exclude set before the include set, so `lint -m System`
against the new default would otherwise report zero findings with no
explanation — a trap this change would have introduced.

Addresses issuetracker finding #9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The Mendix runtime omits a column from a row's JSON object when its value
is null. parseOQLFeedback took the column list from the first row only, so
a column that happened to be null in row 1 was dropped from the entire
result — `mxcli oql` rendered a narrower table than the query asked for,
with no error and no empty column to hint at the loss.

The column set is now the union of every row's keys. New keys are inserted
directly after the last key already known rather than appended, so a
column absent from earlier rows keeps its SELECT position: merging
[A, C] with [A, B, C] gives [A, B, C], not [A, C, B]. Rows are re-scanned
for key order only when they carry a key not seen yet, so the uniform case
still costs one length check.

Verified by stubbing the union back to first-row-only and watching the
reported symptom return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Referencing a loop's variable after `end loop;` passed `mxcli check` and
then failed the build with

  [error] [CE0108] "Variable 'item' is defined but not in scope at this
  location."

Both flavours reproduce against mxbuild 11.12.1: the iterator itself, and
anything the loop body introduces (a retrieve, a `$X = create …`, a call
output).

MDL053 maps each loop-scoped name to the loop whose own body introduces it
(a nested loop keeps its own names), then walks the flow tracking which
loops enclose the current position and flags any reference from outside
the owner. A name claimed by two loops is deliberately not reported — that
is the MDL052/CE0111 duplicate-name case, and without the guard the
existing MDL052 negative example started failing for the wrong reason.

MDL052 is the sibling rule: names are unique across the whole microflow,
but visibility stops at the loop body. The write-microflows skill now
states both and shows the carry-out idiom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The loop-variable-scope check (MDL053) was filed against sudoku finding
#40, which is actually an app bug in that project ("roughly one dealt
board in twenty is not solvable by forced logic") and explicitly marked
"not an mxcli bug". The check is a real, mxbuild-verified check-parity
gap, so it stays — but it is not that finding, and the repro file and
symptom row said otherwise.

The OQL column fix does match sudoku #39, but only its first half; the
second (ORDER BY on a DateTime attribute being ignored) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`mxcli test` got all the way through parsing, runner generation, model
injection, after-startup wiring and the full mxbuild build with no
container, then died on `docker up` — so microflow tests were unavailable
in exactly the environments mxcli targets, where /usr/bin/docker exists
with no daemon behind it.

Only the last step needed a container. --local boots the same standalone
runtime `mxcli run --local` uses (new docker.StartLocalApp: cache mxbuild
+ runtime, ensure the database, mxbuild --serve build, boot) and reads the
runner's output from the runtime log. The Docker path moves to
runDockerAndCapture; both share parse, inject, parse-results and cleanup.

Local runs use their own ports (8081/8091) and a <project>_test database,
so a warm `run --local` loop can keep serving the same project and test
data never lands in the database the developer is looking at.

Two things only running it could have taught:

  - The runner reports through an after-startup microflow, so its LOG
    output happens during the start action, before the runtime attaches
    its log subscriber. Registering the subscriber early is not possible —
    the runtime answers LoggingException pre-start. The JVM console tee,
    live from spawn, is what carries it; confirmed by A/B running with the
    early attach removed, and it is documented where it matters.
  - A failing test makes the runner return false, which fails the
    after-startup action, which makes `start` return an error. The first
    version reported that as a broken run and printed a stack trace
    instead of the test report.

Verified end-to-end in a container with no Docker daemon: two tests
passing, then one passing and one failing with exit 1, project restored
both times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Sudoku findings: OQL column union, Docker-free `mxcli test --local`, loop-scope check
The prompt hardcoded "App" as the application name, so every repo
bootstrapped from it ended up with App.mpr regardless of what was being
built — and the name is awkward to change afterwards: it is the .mpr file
name, the Studio Pro app name, and the path baked into the SessionStart
hook.

A Step 0 now asks for the app name, what the app is for, what it keeps
track of, who logs in, the theme and the Mendix version — all in one
message with defaults, so "defaults" is a valid answer and the agent is
told not to block twice. The answers become the brief, written to
README.md and committed, so a session resuming after an idle reap knows
what it is building. The provisioning steps take <AppName> throughout,
and a closing step has the agent propose the model from the brief and
wait rather than inventing one.

Two corrections while rewriting, both verified rather than assumed:

  - "Create the app at the repo root: mxcli new App" cannot work. mxcli
    new refuses a directory that is not empty and .git alone trips it,
    and with no --output-dir it creates ./App/, leaving every later
    `-p App.mpr` wrong by one directory. The prompt now creates in a
    subfolder and moves the contents up, which is what the SessionStart
    hook's relative path needs.
  - The blank template's module is MyFirstModule, not the app name, so
    the closing step asks for a module name explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The prompt assumed one app at the repo root, which is most of what people
want but silently wrong for a solution — a backend that owns the data and
publishes OData, plus a frontend that consumes it.

The interview now opens by asking the shape, and a new section carries the
deltas that a second app actually needs. They are deltas, not a rewrite:
per-app subfolders instead of moving to the root, explicit ports for the
second app (8180/8190/6643 — 8081/8091/6544 belong to `mxcli test
--local`), and `--hub-solution` so previews group. Databases need no
action, since the name is derived from the .mpr file name.

Two things the agent could not have inferred, both verified:

  - `mxcli init` dedupes the SessionStart hook on the command, not on the
    project, so a second app never gets its own entry. Claude Code reads
    the root `.claude/settings.json`, which has to be written by hand,
    one line per app.
  - `CREATE ODATA CLIENT` fetches the $metadata at creation time and
    caches it (warning, not failing, when unreachable). So the producer
    must be published and running before the consumer is wired, and
    ServiceUrl belongs in a constant since it will not stay localhost.

Default Mendix version 11.6.3 -> 11.13.0, with a note on what "newest
supported" means: both mxbuild-<v> and mendix-<v> must be on the CDN, and
a solution's apps should share a version. Confirmed 11.13.0 serves both
tarballs and 11.14.0 serves neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Separating a solution's apps by port alone does not separate their
sessions: cookies are keyed on host name and ignore the port, so
localhost:8080 and localhost:8180 share one jar and a login to one can
replace the other's XASSESSIONID.

Distinct hostnames fix it and need no mxcli change, which is worth
stating explicitly because the obvious alternative — one loopback IP per
app on a shared port — is not possible today: the runtime binds 127.0.0.1
and there is no listen-address flag.

Verified against a booted app on 11.12.1: a foreign Host header is served
200 (via /etc/hosts, nip.io and localtest.me alike), and the client uses
relative URLs, so no ApplicationRootUrl is needed for this. /etc/hosts is
recommended over public wildcard DNS for locked-down containers, where
localtest.me resolves to ::1.

Also notes the one thing the hostname alone does not cover:
ApplicationRootUrl is only set by --hub, so absolute-URL cases (OIDC/SAML
redirect URIs, deep links) still advertise the listen address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The project's own configuration (App Settings -> Configurations in Studio
Pro, `alter settings configuration '…' ApplicationRootUrl = …` in MDL) is
where a custom host name belongs — versioned with the app, per
configuration, no flag to repeat. `run --local` ignored it: the boot
payload's ApplicationRootUrl came only from a --hub registration, and
since update_configuration replaces rather than merges, nothing else
could supply it.

It is now read at boot and used when no hub URL was assigned (a hub
assignment wins, being the URL actually serving the app). Without it,
serving a solution's apps under their own host names still works — the
runtime accepts any Host and the client uses relative URLs — but the
absolute URLs Mendix generates for OIDC/SAML redirects and deep links
kept naming the listen address.

The trap: a blank Mendix app already ships
ApplicationRootUrl = http://localhost:8080/, so "is set" does not mean
"was chosen". Honouring every value would change behaviour for every
existing project and advertise the wrong port under --app-port. Only a
non-loopback host is passed through, and a port disagreeing with
--app-port warns.

Verified end-to-end on 11.12.1: with backend.local configured, the boot
prints the configuration it came from and the app answers 200 on both the
host name and the listen address.

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

A widget bound to an attribute the context entity inherits rather than
declares passed `mxcli check --references` and `mxcli lint`, and then the
real MxBuild rejected the project:

  [error] [CE1613] "The selected attribute 'TaskBoard.Person.FullName'
  no longer exists."

Mendix stores the reference against the entity that DECLARES the
attribute; mxcli qualified it with the entity in context, leaving a
dangling reference. The message reads as a deletion — it never existed
there.

Two independent resolvers had the bug and both are fixed: the direct
binding (resolveAttributePath) and the final attribute of an association
path (resolveAssociationAttributePath). The reporter's own table showed
both failing, and the association case still failed after the first
patch — a probe of the direct case alone would have shipped half a fix.

Unknown names keep their previous context qualification rather than being
re-pointed, and a cyclic generalization chain terminates. Attribute
resolution now consults the domain models, so the lookup bails out when
there is no backend and nothing cached — several unit tests build a
pageBuilder with neither.

A/B on Mendix 11.12.1: pre-fix binary gives CE1613 for the inherited
column and 0 errors for the own column; fixed binary gives 0 errors for
both shapes, direct and over an association.

Repro: mdl-examples/bug-tests/todo-12-inherited-attribute-on-page.mdl

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The generated hook was `test -x ./mxcli && ./mxcli run --local --setup
--ensure-db -p App.mpr || true`, and .gitignore excludes that binary
(~85 MB) on purpose. In an ephemeral container the two combine badly: the
container is reclaimed, the repo re-cloned without the binary, the guard
fails, and the hook silently no-ops through `|| true`. The next session
has no mxcli, no MxBuild cache and no database — exactly the state the
hook exists to prevent — with nothing said about it.

`mxcli init` now writes a committed `.claude/bootstrap-mxcli.sh` that
resolves OS/arch, fetches the binary when it is missing (MXCLI_TAG pins a
version, default nightly) and then runs the setup; the hook is reduced to
`sh .claude/bootstrap-mxcli.sh || true`. A hook line cannot reasonably do
OS/arch detection, but a committed script can — and the binary stays out
of git.

That changes the hook command, which is what dedupe matched on, so
addSessionStartHook now recognises any known marker and rewrites the
entry in place. An existing project migrates on the next `mxcli init`
instead of ending up with two hooks that both run.

Also adds /theme-cache/ to the generated .gitignore: MxBuild regenerates
the compiled theme on every build, so tracking it means a fresh clone
goes dirty the first time anyone builds.

Verified by reproducing the reap: moved ./mxcli out of a project, ran the
hook command verbatim, and watched it re-download the binary (88 MB, new
mtime) and finish with "Setup complete … database ready".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A heredoc is the natural way to drive MDL from an agent or a shell
script, and `-` is how every other Unix tool spells stdin. It was taken
literally as a filename:

  Error reading file: open -: no such file or directory

so every ad-hoc script needed writing to a temp file first.

One helper now backs both commands, so `check` gained the same spelling
rather than only the reported one; it reports the source as <stdin>
instead of a bare dash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`mxcli syntax` is the reference an agent reads before writing MDL, and
nothing checked it against the parser. Two entries had drifted, each
costing a build round-trip to discover:

  - TEXTBOX/TEXTAREA/COMBOBOX/DATEPICKER/CHECKBOX were documented with
    `Binds:`, which the parser rejects outright ("'Binds:' is no longer
    supported, use 'Attribute:' instead").
  - `DataSource: MICROFLOW Module.MF()` — a zero-argument microflow
    DATASOURCE takes no parentheses, unlike RETRIEVE/CALL where they are
    normal. The parser errors at the `)`.

Both verified against the parser before and after.

A table-driven test now fails if either spelling reappears in any topic's
Syntax or Example field. It is a spelling guard rather than a parse: the
snippets are fragments (a DATAVIEW body, a property line) that do not
stand alone as statements. Proven by reintroducing `Binds:` and watching
the test name the topic and field.

A third claim in the same report did not reproduce — a CONTAINER with
`OnClick: SHOW_PAGE M.Page(Param: $currentObject)` parses fine on current
main — so it was left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Bootstrap prompt: interview first, multi-app solutions, and honour the configured app host name
Measured on Mendix 11.13.0. Registering one Java custom request handler at
boot gives the test runner an entry point it can invoke repeatedly, instead
of triggering tests from the after-startup microflow (a boot hook, so every
re-run needs a full runtime restart).

Core.getMicroflowNames() lets the handler resolve microflows by name at
request time, so the Java is written once and never regenerated as tests
change.

Measured: unchanged suite re-run 30.55s -> 0.084s; edit-then-re-run
30.55s -> 4.29s (almost entirely the existing --watch rebuild).

The load-bearing finding is that the handler survives reload_model: the
runtime JVM PID is unchanged, after-startup does not re-run, and the
already-registered handler resolves the new model's microflows. Verified in
one reload with an edited test and a test created after boot.

Also records what is not settled: the endpoint is unauthenticated and
executes arbitrary microflows under a system context, and there is no
Core.removeRequestHandler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
A decision written with uppercase keywords —

  IF $Task/Status != M.Status.Done AND $Task/CompletedOn != empty THEN

passed `mxcli check` and then failed the build with CE0117, quoting the
expression back with `AND` still uppercase. Mendix requires its word
operators in lowercase.

A rebuilt BinaryExpr already gets strings.ToLower on the operator. A
condition kept as an ast.SourceExpr — original text plus the parsed tree —
returned the raw source and skipped it. The `=` form of the same condition
parses to a BinaryExpr and the `!=` form to a SourceExpr, which is why
this looked like "`!=` cannot be an operand of `AND`" rather than a
casing problem: every failing probe was uppercase and the control was not.

Preserved source now has and/or/not/div/mod lowercased on the way to the
model, with everything else byte-identical: the scanner tracks
single-quoted literals (including '' escapes) and skips any word preceded
by `.`, `/` or `$`, so 'AND', M.Enum.And, $Task/Mod and $Android are
untouched.

Reproduced and fixed against mxbuild 11.12.1: stored `AND` gives CE0117,
stored `and` gives 0 errors.

Repro: mdl-examples/bug-tests/todo-14b-uppercase-and-operator.mdl

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`ADD EVENT HANDLER` errored when the handler already existed and `DROP
EVENT HANDLER` errored when it did not, so a script containing either
could not be re-run — and the obvious workaround, a defensive
drop-then-add, fails on whichever half does not match the current state.
Attributes got idempotency guards in findings #10; event handlers, which
have no other re-run route, did not.

Both clauses now accept the existing ifNotExists/ifExists grammar rules,
so the guard reads the same everywhere, and the errors name the flag so
the fix is discoverable from the failure.

Verified by running one script twice: first run skips the drop (absent)
then adds; second run skips both, exits 0, and the project still builds
with 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`CREATE DEMO USER` reported success and `SHOW PROJECT SECURITY` reported
"Demo Users Enabled: true", while the running app had zero accounts, no
login page and no row-level enforcement. A blank mxcli template ships
with Security Level Off, and with it off the runtime creates no accounts
at all — so the demo users sit in the model and never appear.

The model said yes and the app said nothing; nothing connected the two.
The create path has both facts in hand, so it now says so and names the
one statement that fixes it. A warning rather than a refusal: authoring
demo users before raising the level is legitimate ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Mendix passes no object to a before-create handler — the object does not
exist yet — so a microflow with parameters wired there builds as

  [CE7247] "Microflow should not have parameters" at Event handler of
  entity 'TaskBoard.Task'

`mxcli check` had no way to see it, and the model was written before
anyone found out.

The guard sits in buildEventHandlers, which CREATE ENTITY's inline
handlers and ALTER ENTITY ADD EVENT HANDLER both go through, so one check
covers both paths. It refuses before the write, and the message carries
the build code and the way out: AFTER CREATE does receive the object.

A microflow created earlier in the same script is not readable back yet,
so an unreadable microflow is skipped rather than refused — mxbuild still
catches the real case, and failing on the read would break legitimate
scripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
"Contrast is low and not everything uses the dark theme" — and switching
theme did not help, because all three themes rendered the same defects.

The login page is the bulk of it. It is served from theme/web/login.html,
which loads the SAME compiled theme CSS, so it always was themeable —
nothing themed it. Two Atlas rules did the damage:

  - `.loginpage-image` layers a brand-tinted gradient over Atlas's stock
    photograph (url("./resources/work-do-more.jpeg")), so a dark app opens
    on a large, bright, unrelated picture. It is now a gradient built from
    the theme's own tokens, with no photo.
  - The submit button is `.btn-success`, so it followed the SUCCESS colour
    rather than the brand — a green button two screens from a teal one on
    console. It now points at the brand.

The project's own theme/web/logo.png is deliberately left alone: it is the
app's asset to replace, and hiding it would strip a real logo from apps
that have one.

Separately, --link-color was mapped straight to the brand. Link text needs
4.5:1 as body text, while a brand used as a button fill only needs 3:1
plus contrast against its own ink — so darkening the brand everywhere
would have been the wrong lever. Link text now has its own --mxt-link
token defaulting to the brand, and console's light variant sets it to
#0f766e (5.47 / 5.10 / 4.92 against surface / ground / surface-alt, versus
3.74 on white for the brand teal it replaces).

Verified at the compiled-CSS layer: the overriding .loginpage-image is
last in the cascade and carries no photo, and the button and link
declarations resolve. NOT verified in a browser — raising the scratch
project's security level far enough to serve a login page surfaced
pre-existing model errors there that block deploy.

The reported #999 empty-state label is not reproduced: every #999 in the
compiled CSS is a Bootstrap default (popover arrows, modal border, print
styles), so that one needs the reporting app to pin down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The local runtime is unlicensed, and an unlicensed runtime caps concurrent
sessions. Past the cap it refuses the sign-in and the login page reports it as
a plain "Sign in failed" — indistinguishable from a wrong password. The reason
is written only to .mxcli/runtime.log:

  Maximum number of sessions exceeded! (You are currently using a trial license)

mxcli held both halves and joined neither. --screenshot-user filled the form,
waited, and saved whatever session it had, so a rejected sign-in produced
screenshots of the login page with no error at all.

Mendix answers a rejected sign-in by re-rendering the same form, so the
username field still being present after the click is the signal that login did
not complete. On that signal the login helper now reports the page's own alert
text and reads the tail of the runtime log, naming the session cap and the way
out when it finds it.

Verified against two served pages: a form that stays put exits 2 carrying the
alert text, one that navigates away exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The template ships the generated action stubs in a slightly older shape and
MxBuild rewrites every one of them on the first build — banner, big.js import,
`async function X()` becoming `export async function X()`. They are tracked
files, so a fresh clone of a project created by `mxcli new` goes dirty the first
time anyone builds it, with ~50 changes nobody wrote. `mx check` does not do
this; only a build does, which is why it first shows up long after the initial
commit.

Run that build while the project is still being created, so the settled form is
what gets committed. Best-effort by contract: no JDK, no mxbuild beside the
resolved mx, or a failed build is a warning telling the user what the first
build will rewrite — never a failed creation. `--skip-build` opts out.

A/B on 11.12.1, both git-init'd and then built: --skip-build leaves 50 modified
tracked files, the default leaves 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A page whose button targets a page created further down the same script passed
`mxcli check` and then failed partway through `exec` — and `exec` is not
transactional, so the statements before the failure were already written to the
.mpr. `check --references` had an ordered pass for this, but it needs -p, and
plain `check` is what gets run.

The ordering is knowable from the script alone when the target is created by a
plain CREATE: that CREATE would fail if the page already existed, so the script
itself asserts the page does not exist yet and the earlier reference cannot
resolve against the project either. CREATE OR MODIFY / OR REPLACE assert nothing
of the kind, so those stay with --references, which can look at the project.

The message also says what the hint could not: a cycle has no valid ordering —
create one page without the linking widget and add it with ALTER PAGE … INSERT.

Checked against every mdl-examples script for false positives: none.

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

A sidebar item reading "All task" is Atlas's closed sidebar — an icon rail at
--navsidebar-width-closed: 48px, set in Atlas's own _theme-default.scss.
Reproduced against a real compiled theme in a browser: the <a> for "All tasks"
is 57px inside a 48px rail, matching the 56-in-48 measured on a live app. No
mxcli theme sets a navigation width, which is why all three themes and both
variants render it identically.

An ellipsis rule was written and then reverted rather than shipped: it only
helps where Atlas also sets white-space: nowrap. Where it does not, the label
wraps to two readable lines and the rule turns "All / tasks" into "All / t…" —
a worse truncation than the one being fixed. The answer is an icon on the nav
item, or an open sidebar, and neither is a theme's call to make for every app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
'mxcli test --local' triggered tests from the project's after-startup
microflow. That is a boot hook, so a re-run needs a full runtime restart,
a failing test manifests as a failed boot, and results have to be scraped
out of the runtime log.

Local runs now go through the request handler spiked in 2952c2a: startup
registers an HTTP endpoint and runs nothing, each test is its own
MxTest.Test_<id> microflow invoked by name, and the verdict comes back in
the response. A throwing test therefore fails only itself, and because each
test has its own variable scope the suffix-renaming the monolithic runner
needed is gone.

The endpoint executes microflows under a system context, so it is gated
four ways, each verified against a live 11.13.0 runtime:

  - no MXCLI_TEST_TOKEN in the environment => the handler is not registered
    at all, so a project that kept the MxTest module through a failed
    cleanup exposes nothing when deployed elsewhere
  - missing/wrong X-MxTest-Token => 401, compared in constant time
  - non-loopback caller => 403
  - an mf outside MxTest.Test_* => 403

The token is minted per run and reaches the runtime through its environment
(new LocalRuntimeOptions.Env), never written into the project. Probing the
live runtime also turned up an unfiltered /list returning every microflow in
the app; it is now clamped to the test namespace.

Docker keeps the after-startup mechanism, which needs no secret and no
loopback assumption; --legacy-runner selects it for a local run too. Cleanup
additionally removes the generated javasource/mxtest tree, which
DROP JAVA ACTION leaves behind.

Each gate test was verified to fail against a stubbed guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
`alter page … set Editable = [expr]` (and `set Visible`) produced a project
Studio Pro refused to open:

    StorageLoadException: Conditional editability settings has an invalid
    value '' for property Attribute

`Attribute` on Forms$Conditional{Visibility,Editability}Settings is a BY_NAME
AttributeIdentifier, so its unset value is the empty string, not null. The
CREATE path already encodes it that way — codec.RegisterTypeDefaults with
EmptyStringFields: {"Attribute"} in mdl/backend/modelsdk/widget_write.go,
whose comment records this exact StorageLoadException from mendixlabs#627. The ALTER
path builds the node by hand in setWidgetConditionalSettingMut and never got
the same treatment, so a widget authored through ALTER was unloadable while
the identical widget authored through CREATE was fine.

Neither `mxcli check` nor `mx check` inspects the stored value, so both
reported success on the broken project.

SourceVariable stays nil: it is a BY_ID reference, where null is the absent
value. "null is wrong here" is per-field, not a blanket rule.

Verified by dumping both encodings (`mxcli bson dump --type page`): a
CREATE-authored and an ALTER-authored widget now produce identical key sets
and values for both settings types.

Fixes mendixlabs#851

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

`visible: [trim($currentObject/Slug) != '']` and `[length(...) > 0]` silently
dropped the entire property. `xpathFunctionName` enumerated only IDENTIFIER,
HYPHENATED_ID, NOT, TRUE, FALSE and CONTAINS, so a call to a function whose
name is also an MDL lexer keyword never matched xpathFunctionCall. The
enclosing `[...]` then failed to parse as an xpathConstraint and matched the
generic property-value alternative instead, so the visitor set `Visible` to an
array rather than `VisibleIf`, and the builder — which reads only a bool or a
string from that slot — never fired.

Nothing reported it: `mxcli check` passed, `mx check` passed, and a dropped
Visible defaults to "always visible", so the only symptom was a widget that
should have been hidden appearing in the running app. toUpperCase/isMatch (plain
IDENTIFIERs) and contains (enumerated) were unaffected, which is what made the
failure look arbitrary.

Define the rule as `xpathWord | NOT` instead. xpathWord is the negated token set
already used for name parts, so it self-maintains as the lexer gains keywords —
an enumerated list would reacquire this bug with the next function name promoted
to a token. This cannot swallow a path: xpathFunctionCall requires a following
LPAREN and no xpathStepValue may be followed by one, so bare `empty` still
parses as a word, which is what keeps `[Name = empty]` working. NOT is spelled
out because xpathWord excludes it.

The grammar deliberately does not enumerate a valid function set, because
xpathConstraint serves two contexts with different ones: `Visible:`/`Editable:`
is a Mendix client expression (trim/length/toUpperCase/find), while a datasource
`where` is real XPath (contains/starts-with/ends-with/string-length/not, where
`length()` means list length, the aggregates are Java-API-only, and empty/NULL
are keywords rather than calls). One rule cannot encode both, so mxbuild
adjudicates — it reports a wrong-context call as CE0117 against the real
version's rules, which no table here could track.

Also add MDL-WIDGET19 as the general guard, per the issue's request that
encoding failures be loud: a `Visible`/`Editable` value that is neither routed
to VisibleIf/EditableIf nor a recognized static form is the residue signature of
any conditional the visitor could not build. It now fails `check` instead of
disappearing on write. Verified by reverting the grammar fix and confirming the
rule fires on the reported MDL.

Verification, on a blank Mendix 11.6.6 app via `mxcli run --local` + Playwright
(the repro script carries the Bug852.Verify page for this; Slug is three spaces
so trim() changes the outcome):

  pre-fix   all 5 markers render — TRIM_HIDDEN and LEN_HIDDEN should not
  post-fix  only the 3 that should be visible render

`mx check` reports 0 errors both ways, so this could only be confirmed in a
browser. XPath regression checked too: `[Name = empty]`, `[Name = NULL]`,
not(), contains(), starts-with() and string-length() all still parse and check
clean.

Behaviour change worth noting: `count(…)` and `empty(…)` are also keyword tokens
and now parse, but they are not client-expression functions, so mxbuild now
reports CE0117 where it previously saw nothing — the property having been
dropped before reaching it. Loud beats silent, but anyone who had written one
will newly see an error.

Fixes mendixlabs#852

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
`download file $Doc;` was accepted by the grammar, the visitor, the flow
builder and `mxcli check`, and `mxcli exec` reported "Created microflow" — but
microflowActionToGen had no *microflows.DownloadFileAction case, so the action
hit `default: return nil` and the enclosing ActionActivity was serialized with
no Action at all. DESCRIBE then rendered "-- Empty action" and `mx check`
failed with CE0008 "No action defined."

The statement passed every stage that reports something and disappeared at the
one that reports nothing. This is the same silent-drop mechanism as the
microflowObjectToGen default branch in mendixlabs#791.

Add the case, setting FileDocumentVariableName, ShowFileInBrowser and
ErrorHandlingType (Rollback default). The storage key is ShowFileInBrowser, not
ShowInBrowser; the gen setter binds the right one (legacy's
parseDownloadFileAction reads the wrong key — a latent legacy bug the existing
reader test documents).

Tested at the round trip rather than the reader. A reader-only test cannot
catch this class: it starts from BSON the writer never had to produce, which is
why TestActionFromGen_DownloadFile stayed green the whole time. The new test
asserts the round-tripped ActionActivity has a non-nil Action — the CE0008
shape itself — and covers both the plain and `show in browser` forms.

Fixes mendixlabs#850

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
'mxcli test -p app.mpr --local' failed with MxBuild's "the project file
path should be an absolute path" and a page of Windows sample requests,
which tells a user who typed a relative -p nothing about what to do.

'mxcli run' already handled this (findings #17), but the fix lived in
cmd_run.go rather than in the code that talks to MxBuild, so the next
caller — StartLocalApp, via the test runner — re-hit the same error.
Absolutizing in ServeServer.Build covers every caller, present and future.

LocalAppOptions.applyDefaults and testrunner.Run resolve the path too, so
DeployDir, the runtime log path, and the paths named in error messages are
not derived from a relative value.

Tested by pointing a ServeServer at an httptest fake and asserting on the
request body actually sent — something the CLI-layer fix could not be
tested for, which is part of why it did not generalise. Both new tests
were verified to fail against the reverted fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
ako and others added 25 commits August 7, 2026 19:08
mxcli-todo findings: nine fixes from bootstrapping an app (check-parity, first-build cleanliness, theming, diagnostics)
'mxcli test --local' booted the app once per invocation, so every re-run
paid the ~30s cold boot even for a one-character edit. --watch keeps the
runtime and the mxbuild serve server up and re-runs the suite on every
change, to a test file or to the project's model.

Measured on an 11.13.0 app: first run ~30s, then ~2.0s from editing a test
to a verdict on screen and ~2.1s from editing the microflow under test.
Every re-run in the session applied via reload, not restart.

Two hazards this loop has that the run --local dev loop does not:

  - The runner writes to the project it is watching, so injecting the test
    microflows moves the very mtime being polled. Baselines are taken after
    the injection and rebuild settle; getting this wrong is an infinite
    rebuild loop, verified absent by idling a session.
  - The injected set changes during the session, so cleanup drops what is
    currently injected rather than what was injected at boot — otherwise a
    test added mid-session is left behind in the user's project. A deleted
    test's microflow is dropped explicitly, since CREATE OR REPLACE says
    nothing about removal and a lingering flow would keep reporting a stale
    pass.

Ctrl-C stops the runtime, restores the project, and says so. --watch is
rejected with --legacy-runner, without --local, and with --skip-build:
those paths can only re-run by restarting.

Verified live across a session that edited, deleted and added tests and
changed the microflow under test, then confirmed the project was fully
restored. Each new test was checked to fail against a stubbed guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Fix silent drops of conditional expressions with keyword-named functions
'mxcli test --local --watch' keeps a runtime warm within a session, but a
session still opens with a ~30s cold boot. --attach removes even that by
running against an app already up: 2.83s for the first attached run and
2.30s for a repeat, with the dev app still serving throughout.

Attaching cannot be unilateral, and the reason shapes the design. The
endpoint's handler is registered by the after-startup microflow, which runs
only at boot, and its token comes from the runtime's environment — neither
can be added to an app that is already running. So the dev loop opts in with
'mxcli run --local --test-endpoint', which is also where the decision
belongs: hosting the endpoint means the developer's own app carries it, and
tests write to the database they are looking at (said on every attach, not
just in the docs).

What a second process can drive is the dev loop's serve and admin APIs, both
plain loopback HTTP. So an attach applies its own injections deterministically
rather than waiting on someone else's watcher, and does not require --watch.

The host publishes .mxcli/test-endpoint.json (0600, written-then-renamed)
with the ports, token, admin password and its PID; the PID check turns a
handshake left behind by a SIGKILLed dev loop into one clear message instead
of a confusing connection error later. The project's own after-startup
microflow is chained rather than displaced, so the app still boots normally,
and the endpoint is removed on exit.

Ownership is strict: an attach adds and removes only its own test
microflows. The endpoint, the after-startup setting and the MxTest module
belong to the hosting loop. A change needing a runtime restart is refused
rather than half-applied.

Running it live caught a bug review had not: the M2EE admin API and the test
endpoint are different secrets, and passing the endpoint token to the admin
API failed with "Authentication failed" after the test microflows had already
been injected. The handshake now carries the admin password separately, taken
from the resolved value so an override still works.

Help, syntax topics, skills and site docs updated throughout; the
running-tests page also no longer claims Docker is required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Brings the branch up to date with main (PRs #107, #108 and the commits
behind them) so PR #109 merges cleanly and CI runs against the current base.

No conflicts. The fix-issue.md symptom table merged via the union driver as
intended — both this branch's rows and main's survive, with no duplicates.
Full suite green on the merged tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Add test endpoint for warm test re-runs without restart
A published OData service created purely from MDL passed `mxcli check` and then
failed the build on two counts, neither of which the author could see coming.

CE0729 "The service name should not be empty": `Name` (the document) and
`ServiceName` (the name in the OData metadata document) are different
properties, and CREATE set only the first. The consumed path has defaulted the
same field to the document name for CE0339 all along.

CE7375 "Attribute ID ... must be published and be the key when associations are
exposed as an associated object id", firing even with no associations exposed:
PublishAssociations defaults to false, which is object-id mode, and Mendix only
allows that when the system ID is published as the key — while MDL's
`expose (Attr (KEY))` publishes an ordinary attribute.

The finding framed the second as a non-persistable-entity problem. It is not:
measured on 11.12.1, the identical service over a PERSISTENT entity with a
unique key builds 0 errors with true and CE7375 with false. The default broke
every published service; non-persistable is only where no workaround exists,
because publishing the ID of a non-persistable entity is forbidden.

So default an unspecified PublishAssociations to true. That is not a preference,
it is the only value that can build from the MDL people write. An explicit false
is still honoured (tracked separately from "absent" so it survives), warned
about when a published entity is non-persistable, and `create or modify` no
longer flips a stored value the script never mentioned.

Nothing in-repo caught either default: doctype-tests/10-odata-examples.mdl sets
both properties explicitly. Both bug-test scripts now build 0 errors on 11.12.1
with no workarounds — no ServiceName, no follow-up ALTER.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The grammar accepts any `name: value` pair in an OData property list and the
visitor's switch had no default, so `ReadMicroflow:` or `ServiceNam:` parsed
cleanly, checked cleanly, executed "successfully" — and left the model without
the property. The ALTER path has always answered "unknown OData service
property"; CREATE, PUBLISH ENTITY, the OData client and the external entity had
nothing.

The name is lost in the visitor, so the visitor is where it has to be recorded:
each switch grows a default that appends to UnknownProperties, and a check-time
validator reports them as MDL-ODATA01 before anything is written. The message
guesses the intended property (prefix/substring, then one edit) rather than only
listing the known ones — with a list alone the reader is still diffing two
spellings by eye.

One correction to the report: `Pagesize:` is not among the casualties. The
visitor lowercases before matching, so casing is never a typo. A test pins that,
so the rule cannot start flagging it later.

No false positives across every script in mdl-examples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The three OData query options were written as literal true in the BSON writer,
with no MDL above them. Countable is not a cosmetic default: it forces every
read-microflow-backed resource to declare a System.ODataResponse parameter and
compute a count, and over a full CSV scan that count is the expensive part of
the request.

They are now publish-entity properties. Tri-state is load-bearing — they default
to true, so "unset" and "false" cannot share a representation or every existing
script would quietly turn them off. Unset stays nil, the writer resolves nil to
true, and the reader maps a stored true back to nil so DESCRIBE prints what the
author wrote rather than three defaults on every resource.

Verified on 11.12.1: `Countable: No` with a read microflow that takes no
$Response parameter builds 0 errors — the combination that could not be
expressed before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Three slips in one emit block, each of which broke the DESCRIBE-roundtrip
property the review checklist asks for:

- `ReadMode: CallMicroflow:Module.Read` is the backend's storage spelling and
  matches no MDL value. It now prints `microflow Module.Read`, the form the
  author wrote.
- `expose (...)` takes a bare member name; the stored name is fully qualified,
  so `Module.Entity.Attr` was emitted and did not parse. `IsPartOfKey` becomes
  `KEY` for the same reason — both parse, but only one is documented.
- `as '<name>'` printed the entity TYPE's exposed name where the entity SET's
  belongs. That one is invisible until the two differ (Studio Pro exposes the
  type singular and the set plural), and then a describe -> exec cycle silently
  renames the set the $metadata serves.

Proved by round trip rather than by eye: describe -> check parses, then drop the
service, exec the describe output, describe again — byte-identical, and mxbuild
reports 0 errors on the rebuilt model.

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

`ReadMode: microflow Module.MF` has worked through the whole pipeline —
grammar, visitor, AST, BSON — and was documented nowhere, so the only way to
find it was to read the Go source. Without it, "publish persistent entities" is
the only shape the docs describe, which sends anyone with data outside Mendix
down a materialise-into-the-database path they do not need.

The syntax topic now carries ReadMode/InsertMode/UpdateMode/DeleteMode in their
microflow form, ServiceName and PublishAssociations with their defaults, and the
three query options. The skill gains a worked non-persistable example — read
microflow, no copy of the data, no refresh job — with the two things that bite
first: the $Response parameter Countable requires, and why PublishAssociations
must stay at its default there.

The skill's existing example also carried `PublishAssociations: No`, which
cannot build: object-id mode requires the system ID as the published key, and
that example publishes an ordinary attribute. Removed, with the reason.

The new example was executed against a real .mpr and builds 0 errors on 11.12.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The skill's table listed `Redshift` and `SQLServer`. Neither is in Studio Pro's
connector picker on any version available here — read out of the shipped bundle
at modeler/ide-client/database-connector-editor/, identical on 11.10.0, 11.12.1
and (per the report) 11.13.0: MSSQL, MySQL, Oracle, PostgreSQL, Snowflake, BYOD.

mxcli writes the type string through unchanged and mxbuild does not validate it
either — `type 'Redshift'` builds 0 errors — so a wrong value hides behind a
green build and only shows up as a connection that does not connect. `check` now
says so. A warning rather than an error: the set is version-specific and mxcli
cannot prove a string wrong on a version it has not seen.

The table was also missing the entry that matters most. `BYOD` ("Other") forces
connection-string configuration and skips the driver-presence check, which makes
any JDBC driver Mendix ships no picker entry for usable by dropping the JAR in
userlib/. That is what the finding needed for DuckDB, and it read as impossible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Run from a solution root — one folder per app, no .mpr at the root — `mxcli
init` reported success and wrote tooling pointing at `project.mpr`, a file that
does not exist. The hardcoded default made a missing project look like a found
one.

It now looks one level down and lets the candidate count decide: none, warn that
the generated paths are placeholders; exactly one, say which project it is and
initialise that directory; more than one, refuse, list them, and print the
command naming a specific app. A solution repo should not get one app's tooling
by coin flip.

One level only. A Mendix app keeps its .mpr at its own root, so walking deeper
would start finding deployment copies and backups.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`alter settings configuration 'Default' …` is the write form; the read form was
a parse error. `describe settings configuration '<name>'` now parses and prints
that one configuration as re-executable MDL, naming the configurations that do
exist when the name is wrong. Where MDL has `alter X <selector>`, reaching for
`describe X <selector>` and getting a parse error teaches the wrong lesson.

`show settings configurations` also gains ApplicationRootUrl. It decides the
host the app answers on, so "did my root URL land?" is the obvious question to
ask that command, and the summary could not answer it — `describe settings |
grep` was the only way. An empty DatabaseUrl is skipped too, rather than
rendering as a bare comma that reads like a bug in the reader.

Both dumps go through one emit helper, so the whole-settings output and the
single-configuration output cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`make build` needs an `antlr4` launcher, and the error when it is missing said
only "install ANTLR4" — while the version is load-bearing. CI pins
antlr4-tools 0.2.2 with generator 4.13.2 against the 4.13.1 runtime in go.mod,
and a generator/runtime mismatch is a classic ANTLR failure mode that surfaces
as a generated parser that will not compile. That pin lived only in the workflow
YAML, where nobody hits it.

The pin now lives in mdl/grammar/Makefile, the error message quotes it, and
`make -C mdl/grammar bootstrap` does the pip install. `generate` also notes when
ANTLR4_TOOLS_ANTLR_VERSION is unset, since the default is whatever antlr4-tools
last downloaded.

The README build-from-source recipe gains the bootstrap step and says what the
build actually needs — network and a JVM, not only Go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Step 6 was documented as "downloads the correct mxcli binary", and on Linux it
does not download: it hard-links the mxcli you ran into the project, sharing the
inode. That is the right behaviour for a from-source build — the app folder gets
your binary, not the nightly — but the help text described the opposite, which
is what led one report to delete the linked binary before moving files around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Published OData services could not build from MDL — plus init/settings/database-type fixes (formula1 findings)
…y skipping

`ALTER MODULE X ADD JAR DEPENDENCY (…)` wrote the coordinate, `list jar
dependencies` reported it, the build went green — and the runtime threw "No JDBC
driver found in app for URL". Nothing between the author and the runtime said
the jar was never fetched.

The write was never wrong. Declaring and resolving are separate steps:
`mx sync-java-dependencies <project.mpr>` downloads into vendorlib/, Studio Pro
runs it when you edit Module Settings, and nothing ran it headless. Measured on
11.12.1: a full `mxbuild --target=deploy` emits a build.gradle with no
dependencies block and downloads nothing; the sync command then fetches the jar.
(The open question in the report was whether mxbuild skips Maven resolution or
mxcli writes somewhere MxBuild cannot read — neither.)

Wired at three levels so the gap cannot stay silent: the executor says so the
moment it writes a coordinate that is not in vendorlib/, `mxcli sync-java-deps`
does the resolution on demand (`--check` exits non-zero as a build gate), and
`run --local` vendors anything missing before boot, so the warm loop works from
a fresh clone. Resolution needs network, so each call site is best-effort and
names the command to retry.

Falling back to another version's mx is now stated rather than silent — a
version mismatch otherwise surfaces as an mpr-format complaint that reads like a
corrupt project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`$Total = 5;` failed with "no viable alternative at input '$Total=5'" while
`DECLARE $Total Integer = 0;` worked — and so did every other assignment in MDL:
`$X = HEAD($List)`, `$X = create M.E (…)`, `$X = execute database query …`.
Assignment existed only as a prefix on specific activity statements, plus a
`SET $Var = expression` statement, so a plain value needed a keyword that
nothing in the error or the surrounding syntax suggested.

Rather than improve the error, make the form people reach for work. SET is now
optional; both spellings produce the same MfSetStmt and the same
ChangeVariableAction, and the keyword form is untouched.

Checked for regressions with a control binary rather than by reading the
grammar: stash the .g4, regenerate, build bin/mxcli-control, and sweep every
script in mdl-examples with both. Thirteen fail — the same thirteen, all
pre-existing. ANTLR's adaptive prediction picks the activity-prefixed
alternatives on its own; no reordering was needed. Executed against a real .mpr,
mxbuild reports 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
`mxcli test tests/ -p app/App.mpr` failed with "no such file or directory" for a
tests/ sitting right next to the .mpr. Resolving against the process CWD is
defensible on its own, but mxcli otherwise encourages naming the project rather
than standing in its directory, so the two conventions collide and the failure
reads as a missing directory.

The fallback applies only when the CWD-relative path does not exist. A tests/ in
both places still resolves to the one the user is standing in — silently
preferring the project's copy would run the wrong suite — and a path that exists
in neither is passed through unchanged, so the error names what was typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A `dynamic` SQL override still requires a value for every parameter the query
*definition* declares, including ones the replacement SQL never mentions — the
parameter list belongs to the definition, not to the string. And a `{param}`
placeholder can be concatenated into a path, which is what keeps an absolute
path out of the model: bind the data directory as a constant and build the file
name around it.

Both come from a DuckDB read path verified end to end on Mendix 11.13, which
also settles the open question about `type 'BYOD'` — the runtime accepts it, not
just the editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
JAR dependencies never reached the classpath; `$Total = 5;` now parses (formula1 findings §11–§13)
Making SET optional put `$X = <expr>` in front of every
`VARIABLE EQUALS <function-call>` statement in the grammar. ANTLR picks the
lowest-numbered alternative that matches, so `$Sum = sum($List.Price)` stopped
reaching aggregateListStatement and fell through to the generic SET conversion,
which joined the list and the attribute into one name. mxbuild rejected the
result on both engines:

  [CE0109] "Undefined variable 'ProductList.Price'."
  [CE0015] "Aggregate function must specify a valid attribute."

Move setStatement last so it only claims what no dedicated rule parses, and make
the SET conversion agree with the canonical one — it also dropped the per-item
expression of `sum($List, $currentObject/Price * 0.21)`, which
buildListAggregateAsFunction never appended in the first place. That half was
wrong before the bare form ever reached it, so `set $X = sum($List.Price)` was
broken too.

Proved by reverting both halves: the new tests fail with the reported symptom,
and the same script executed against a blank 11.12.1 project reports 8 errors on
a control binary and 0 with the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Aggregates broke when SET became optional (upstream CI regression)
@ako
ako merged commit a8bc729 into mendixlabs:main Aug 7, 2026
4 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 8, 2026
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