Skip to content

mxcli-todo findings: nine fixes from bootstrapping an app (check-parity, first-build cleanliness, theming, diagnostics) - #107

Merged
ako merged 9 commits into
mainfrom
claude/mxbuild-diagnostics-spike-emta6h
Aug 7, 2026
Merged

mxcli-todo findings: nine fixes from bootstrapping an app (check-parity, first-build cleanliness, theming, diagnostics)#107
ako merged 9 commits into
mainfrom
claude/mxbuild-diagnostics-spike-emta6h

Conversation

@ako

@ako ako commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Works through the findings from bootstrapping a to-do app end to end. Each item was reproduced first, fixed, then re-verified against real MxBuild (mx check → 0 errors) or in a browser where the symptom only exists at render time.

Two of the nine are deliberately not code changes — see "Investigated and documented, not fixed" below.

Check parity — accepted by check, rejected later

AND reached the model verbatim (#14b) — a condition kept as a SourceExpr (original text + parsed tree) skipped the operator lowercasing a rebuilt BinaryExpr gets, so IF … != … AND … != empty stored AND and MxBuild answered CE0117.

The reporter's nine-build probe table concluded "any != inside a conjunction fails". It doesn't — the = form of the same condition is rebuilt from the AST, and that path already lowercases. Every failing probe happened to be uppercase and every control lowercase, so the table tracked the wrong variable. The trigger is casing, and it is invisible in a lowercase script.

A parameterized microflow on BEFORE CREATE (#14a) — Mendix passes no object to a before-create handler (the object does not exist yet), so it builds as CE7247. The guard sits in buildEventHandlers, shared by CREATE ENTITY's inline handlers and ALTER ENTITY ADD EVENT HANDLER, and names the way out (AFTER CREATE, which 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.

A forward page reference (#9)check passed and exec then failed partway through, after earlier statements were already written. --references had an ordered pass for this but needs -p. The ordering is knowable from the script alone when the target is created by a plain CREATE: that CREATE would fail if the page existed, so the script asserts it doesn't and the earlier reference cannot resolve against the project either. CREATE OR MODIFY / OR REPLACE assert nothing, so those stay with --references. The message also says what the old hint couldn't — a cycle has no valid ordering; create one page without the linking widget and add it with ALTER PAGE … INSERT.

Re-runnable scripts and honest feedback

Event handlers are the one member that will not re-run (#18)ADD EVENT HANDLER errored when it existed, DROP errored when it didn't, and a defensive drop-then-add failed on whichever half didn't match. Now takes IF NOT EXISTS / IF EXISTS, reusing the existing grammar rules so the guard reads the same everywhere.

Demo users that do nothing (#15)CREATE DEMO USER succeeded and SHOW PROJECT SECURITY said Demo Users Enabled: true, while the running app had zero accounts: with Security Level Off the runtime creates none. The model was written correctly; nothing connected the two facts. Now warned at the moment the user would otherwise believe it worked, naming the one statement that fixes it. A warning, not a refusal — authoring demo users before raising the level is legitimate ordering.

"Sign in failed" that isn't about the password (#16) — the unlicensed local runtime caps concurrent sessions and reports the refusal exactly like a wrong password; the reason is only in .mxcli/runtime.log. --screenshot-user didn't even notice — it filled the form, waited, and saved whatever session it had, so screenshots silently showed the login page. Mendix re-renders the same form on a rejected sign-in, so the username field still being present after the click is the signal; the helper now reports the page's own alert text and reads the log tail.

First build no longer dirties a fresh clone (#7)

The template ships the generated action stubs in an older shape and MxBuild rewrites all of them on the first build. They're tracked files, so a fresh clone went dirty with ~50 changes nobody wrote — and mx check doesn't do it, only a build, which is why it surfaced long after the initial commit.

Fixed the timing, not the content: mxcli new runs one mxbuild --target=deploy so the settled form lands in the first commit (--skip-build opts out). Best-effort by contract — no JDK, no mxbuild, or a failed build is a warning, never a failed creation. Reimplementing the rewrite was rejected: it's MxBuild's generator and version-specific.

Theming and ergonomics

The login page (#19a) — the first screen a user sees, and the one Atlas themes least. It loads the same compiled theme CSS, so it is themeable; nothing themed it. Atlas layers a gradient over a stock photograph and makes the submit button .btn-success, so it follows the success colour rather than the brand. Replaced with a token-built gradient and pointed the button at the brand.

Link text also got its own --mxt-link token: a link needs 4.5:1 as text, while a brand used as a button fill needs 3:1 plus contrast against its own ink — darkening the brand everywhere would have been the wrong lever. Console's light teal was 3.74:1 and is now 5.47/5.10/4.92 against surface/ground/surface-alt. The project's own theme/web/logo.png is deliberately left alone — it's the app's asset to replace.

Inherited attributes on pages (#12) — an attribute declared on a generalization was stored against the specialization, which MxBuild rejects as CE1613. Both resolvers now qualify with the declaring entity, walking the generalization chain.

Smaller onesexec/check accept - for stdin (#5), so a heredoc works without a temp file; two mxcli syntax spellings the parser rejects are corrected and pinned by a test (#8); the SessionStart hook survives an idle reap and rewrites the legacy form in place rather than duplicating it (#2); theme-cache/ is gitignored; run --local honours the app's configured Application root URL.

Investigated and documented, not fixed

Clipped nav labels (#19d) — traced to Atlas's own constant: the closed sidebar is an icon rail at --navsidebar-width-closed: 48px, set in Atlas's _theme-default.scss. Reproduced against a real compiled theme in Chromium — the <a> for "All tasks" is 57px inside a 48px rail, matching the 56-in-48 measured on the 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, verified as compiling last, screenshotted — and reverted. It only helps where Atlas also sets white-space: nowrap; elsewhere the label wraps to two readable lines and the rule turns All / tasks into All / t…. The answers (an icon on the nav item, or an open sidebar) belong to the app, not to a theme that would impose them on every app. Documented in theme-styling.md with the reasoning, so the next person doesn't re-derive it.

The #999 empty state (#19b) could not be reproduced: every #999 in the compiled CSS is a Bootstrap default (popover arrows, modal border, print styles). That one needs the reporter's app.

Verification

  • Every fix has a unit test, and each was proven to detect its defect: reverting the fix (or stubbing the guard) makes the test fail with the reported symptom.
  • A/B against a pre-fix binary on Mendix 11.12.1 where behaviour is observable: --skip-build leaves 50 modified tracked files and the default leaves 0; a pre-fix binary writes the CE7247 handler and MxBuild reports it, the fixed one refuses.
  • MDL examples added under mdl-examples/bug-tests/; MDL-PAGE01 checked against every script in mdl-examples for false positives (none).
  • The login-page change is verified at the compiled-CSS layer (the overriding rule is last and carries no photo; the button and link declarations resolve), not in a browser — raising the scratch project's security level to serve a login page surfaced pre-existing model errors there that block deploy.
  • Symptom rows appended to .claude/skills/fix-issue.md for each fix.
  • make build + full go test ./... green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4


Generated by Claude Code

claude added 9 commits August 7, 2026 16:21
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
@ako
ako merged commit f3b2530 into main Aug 7, 2026
5 checks passed
ako pushed a commit that referenced this pull request Aug 7, 2026
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
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