Skip to content

Two lint rules a real project asked for, and a describe → check round trip that was failing on 6 of 14 workflows - #1066

Merged
ako merged 10 commits into
mendixlabs:mainfrom
ako:main
Sep 7, 2026
Merged

Two lint rules a real project asked for, and a describe → check round trip that was failing on 6 of 14 workflows#1066
ako merged 10 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Four commits: two new lint rules, one round-trip fix, one proposal.

describe workflow emitted MDL that mxcli check refused

6 of the 14 workflows across the 9 demo apps in mx-test-projects/ failed describe → check. Two independent defects behind one symptom.

MDL-WF03 refused the enumeration value Studio Pro actually stores. The rule required a bare identifier, but every Workflows$EnumerationValueConditionOutcome in the corpus holds the qualified Module.Enum.Value form — 7 of 7 non-empty — so the describer emitted what was stored and the checker rejected mxcli's own output. The rule exists to catch free text like 'Confirmed closed'; rejecting the dot was collateral, and spaces are still refused. The stored document is what settled which side to change: emitting the last segment instead would have quieted check and written a document unlike every real one.

MDL could not name the activities a jump to targets. Mendix stores JumpToActivity.TargetActivity as an activity name string, not a pointer, and Studio Pro names activities by type and ordinal (decision1, split1, callMicroflow1) with no relation to the caption. MDL had a name slot only on user task; every other builder did act.Name = act.Caption, so the stored name had nowhere to be emitted to, two decisions sharing a caption collided on one name, and jump to decision1 resolved to nothing. Reaching MxBuild it became a jump to itself and surfaced as CE6681"not possible to jump to end activities or jump-to activities" — an error naming a different fault.

That was not a describer bug, so the fix runs the full width: grammar → AST → visitor → builder → describer. decision, parallel split, wait for timer and wait for notification now take an optional name; call microflow / call workflow take AS <name>. Without one the caption-derived name is unchanged, so existing scripts are unaffected.

Two lint rules, both narrowed by measurement

CONV018 — a module never organised into folders. A project with 200 documents loose in one flat module scored clean across all three rule sets. The rule fires only when both halves hold: more than max_root_documents (default 20) sit directly in the module root, and not one document in the module is in a folder.

The second half is what stops it nagging. A module that has started to organise itself — even one folder — is never reported however much remains at its root: the team has evidently made a choice, and a linter guessing at the rest is noise.

Only kinds Studio Pro actually lets you file in a folder are counted, as an allow-list rather than a deny-list. An association belongs to the domain model and an external entity to a consumed service, so counting them would report a module as unorganised on the strength of elements nobody can move. The polarity is deliberate: a document type missing from the list is undercounted, so the rule stays quiet rather than inventing a violation.

CONV019 — a navigation screen that cannot be linked to. A Mendix page is reachable at /p/<url> only if it has been given a URL. Without one it exists solely at the end of a click path: no bookmark, no link from an email, no reopen after refresh, and no mxcli run --local --screenshot-url — so verifying one screen means driving a browser through login and the menu.

The rule reports only the pages a navigation profile routes to — profile home, role homes, menu targets. That narrowing is the whole design, and it is measured rather than assumed: on a blank Mendix 11.12.1 app, 0 of 16 pages carry a URL, Mendix's own Home page included. Reporting every page without one would warn about every page of every project from the day it is created.

Never reported, each for its own reason: login and not-found pages (the platform routes to those itself), microflow-valued menu items and home pages (the microflow decides what opens, so there is no page to be addressable), and System or Marketplace targets. A page routed to several ways is one finding naming every route.

Both rules needed new Starlark builtins, and those are the reusable part: documents() exposes every element of the App Explorer tree as (kind, name, qualified_name, module_name, folder) read from the catalog's objects view — so a new document type is covered for free — and navigation_targets() gives (profile, kind, role, caption, page) for every routed-to page. Navigation was previously reachable only from the Go rules, so no Starlark rule could ask which pages a user can actually reach.

Proposal

Offline synchronization configuration. mxcli can create an offline navigation profile and read every row of its sync config, but cannot write one — so the profile builds, routes, installs as a PWA and shows an empty app. Two hazards documented before any code: the read is already lossy (gen declares six properties on the config element, the semantic model keeps three), and the sync mode is an enum whose Studio Pro captions are not its keys — the same shape as the CE0463 gallery defect. Draft; no code

claude and others added 10 commits September 7, 2026 07:06
…s (CONV018)

Studio Pro lets a module hold folders and every Mendix style guide expects
them once a module is more than a handful of documents, but nothing in mxcli
reported their absence: a project with 200 documents loose in one flat module
scored clean across all three rule sets.

CONV018 reports a module only when BOTH halves hold:

  1. more than max_root_documents (default 20) documents sit directly in the
     module root, AND
  2. not one document in the module is in a folder.

The second half is what keeps it from nagging. A module that has started to
organise itself — even one folder — is never reported, however much is still
at its root: the team has evidently made a choice about where things go, and
a linter guessing at the rest is noise. The first half exempts modules that
are small rather than disorganised.

Only kinds Studio Pro actually lets you file in a folder are counted. An
association belongs to the domain model and an external entity to a consumed
service, so counting them would report a module as unorganised on the strength
of elements nobody can move. That list is an allow-list in the rule, where it
is visible and editable, and its polarity is deliberate: a document type
missing from it is undercounted, so the rule stays quiet rather than inventing
a violation.

The rule stands on a new documents() Starlark builtin — every element of the
App Explorer tree as (kind, name, qualified_name, module_name, folder), read
from the catalog's `objects` view so a new document type is covered without a
second list to keep in step. It is the companion to documentable_elements(),
which projects only what can carry documentation and therefore omits
microflows and Java actions — the two kinds that fill up an unorganised
module. No per-kind builtin exposed `folder` uniformly, which is why the rule
could not be written before.

Verified live, not only in unit tests: a 25-microflow flat module reports, and
goes silent the moment one MOVE ... TO FOLDER lands; max_root_documents read
from .claude/lint-config.yaml silences it at 40 and brings it back at 5.

Tests carry their controls. The two silence assertions are paired with
sibling tests that fire on the same fixture, the Marketplace exclusion is
followed by the same module without the marker (which must report), and the
non-foldered-kind test is followed by 40 rows of a kind that can be foldered
(which must report). Stubbing check() to return [] fails four of the six.

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

A Mendix page is reachable at /p/<url> only if it has been given a URL.
Without one it exists solely at the end of a click path: it cannot be
bookmarked, linked to from an email, reopened after a browser refresh, or
captured with `mxcli run --local --screenshot-url`, so verifying one screen
means driving a browser through login and the menu (ako/CapTrackV4
FINDINGS 014, R4).

The rule reports only the pages a navigation profile ROUTES TO — the profile
home page, role home pages, and menu item targets. That narrowing is the whole
design, and it is measured rather than assumed: on a blank Mendix 11.12.1 app
0 of 16 pages carry a URL, Mendix's own Home page and every Administration and
FeedbackModule page included. Reporting every page without one would warn
about every page of every project from the day it is created, which is noise
rather than a finding. The screens a profile routes to are different: they are
what a user lands on, bookmarks and shares. A page reached only from a button
inside another screen is never reported.

Also never reported, each for its own reason: the login and not-found pages
(the platform routes to those itself, so a URL buys nothing), microflow-valued
menu items and home pages (the microflow decides what opens, so there is no
page to be addressable), and targets in System or Marketplace modules. A page
routed to several ways is one finding naming every route, not one per route.

Carried by a new navigation_targets() Starlark builtin — (profile, kind, role,
caption, page) for every routed-to page, where kind is home, role_home or
menu. Navigation was reachable only from the Go rules through the reader's
GetNavigation, so no Starlark rule could ask which pages a user can actually
reach.

Verified live, not only in unit tests: on a blank app it reports Home_Web and
nothing else, naming both routes that reach it; two pages created in one
statement — one with `Url:`, one without — separate exactly as intended, the
linked one silent and the unlinked one reported.

Tests carry their controls. The URL-set test asserts the other two still
report, the Marketplace test asserts the three user pages still do, and the
two projection tests assert the surviving targets rather than only the absent
ones. Stubbing check() to return [] fails four of the six.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
mxcli can create an offline navigation profile and can read every row of its
sync configuration, but cannot write one. The result is a profile that builds,
routes and installs as a PWA and shows an empty app, because a Mendix offline
profile downloads nothing until each entity is given a synchronization mode.

DESCRIBE NAVIGATION already states the gap in its own output — the rows come
back commented, marked "not yet modifiable" — which also makes describe -> exec
lossy for any project using offline sync, the same round-trip failure that
preceded the layout and menu-icon work.

Checked first, because it decides whether this is a feature or a rescue: an
existing configuration SURVIVES a rewrite. Both engines patch the stored
profile rather than rebuilding it, and neither write path touches
OfflineEntityConfigs. This is a clean gap, not the data loss that
`create or modify entity` was inflicting on access rules.

Two hazards the proposal is mostly about, both with a precedent from this week:

The read is lossy. gen declares six properties on the config element and
mdl/types keeps three, discarding DownloadMode, ShouldDownload and
CompatibilityMode — the last being the column with warning triangles in the
report's screenshot. A writer built from the three the semantic model carries
would drop the others, which is the access-rule defect again; so phase 1 is
carrying all six on READ, before anything writes.

The sync mode is an enum whose captions are not its keys. The metamodel
declares All / Constrained / Never / None / NoneAndPreserveData / Online while
Studio Pro shows "Online", "All Objects", "By XPath" — so the mapping cannot be
read off the UI at all. That is mendixlabs#1035 exactly, where a caption reached
gallery.def.json and mxbuild rejected every gallery with CE0463, and the guard
that closed it is the one to copy.

Syntax is a per-entity SYNC block matching the MENU block's shape, with WHERE
implying Constrained so the invalid pairings are unspellable rather than merely
diagnosable.

Honest about what is not known: no project on this machine has a populated
offline entity config — an earlier scan claiming five was matching the key name
that every profile carries, not the element. The shape has to come from a real
document, and §5 lists the four things it settles, including where the
throw-on-reject setting lives, which is on neither gen's profile nor the
metamodel's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`describe workflow` emitted MDL that `mxcli check` refused: 6 of the 14
workflows in the 9 demo apps in mx-test-projects/ failed describe -> check.
Two independent defects behind one symptom.

MDL-WF03 refused the enumeration value Studio Pro actually stores. The rule
required a bare identifier, but every Workflows$EnumerationValueConditionOutcome
in the corpus holds the qualified form Module.Enum.Value (7 of 7 non-empty), so
the describer emitted what was stored and the checker rejected mxcli's own
output. The rule exists to catch free text like 'Confirmed closed'; rejecting
the dot was collateral, and spaces are still refused. The stored document is
what settled which side to change — emitting the last segment instead would
have quieted check and written a document unlike every real one.

MDL could not name the activities a `jump to` targets. Mendix stores
JumpToActivity.TargetActivity as an activity NAME string, not a pointer, and
Studio Pro names activities by type and ordinal (decision1, split1,
callMicroflow1, waitForNotification1) with no relation to the caption. MDL had
a name slot only on `user task`; every other builder did act.Name = act.Caption,
so the stored name had nowhere to be emitted to, two decisions sharing a caption
collided on one name, and a `jump to decision1` resolved to nothing. Reaching
MxBuild it became a jump to itself and surfaced as CE6681 ("not possible to jump
to end activities or jump-to activities") — an error naming a different fault.
This was not a describer bug: the grammar had no name slot, so the fix runs
grammar -> AST -> visitor -> builder -> describer.

`decision`, `parallel split`, `wait for timer` and `wait for notification` now
take an optional name; `call microflow` / `call workflow` take `AS <name>`.
Without one the caption-derived name is unchanged, so existing scripts are
unaffected, and DESCRIBE emits a name only where it is not derivable.

Verified with a control: the pre-fix binary built from the parent commit
reproduces 6/14 failing where the fixed one is 0/14. The round-tripped workflow
was executed into FactoryManagement(AgenticEnterpriseEdition) and read back —
decision1..3, split1, callMicroflow1..6 and waitForNotification1..2 all land and
both jump targets resolve — on top of mx check at 0 errors against an untouched
baseline (mxbuild 11.10.0). mx check alone would not have shown it: a workflow
that jumps to itself is perfectly valid.

Closes #408

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(lint): two rules for things the model shows and nothing reported (CONV018, CONV019)
docs(proposal): offline synchronization configuration
fix(workflow): make describe output re-executable (#408)
`ALTER ENTITY M.Gen ADD ATTRIBUTE …`, where a specialization of Gen also has
an access rule, produced CE0066 "Entity access is out of date" — and
`UPDATE SECURITY`, project-wide or scoped, reported "All entity access rules
are up to date" and changed nothing (mendixlabs#1047, reported against
0.21.0 on a confirmed MPR v2 project).

ReconcileMemberAccesses computes the same-module ancestor set and then used it
ONLY for the association pass. The attribute pass beside it walked the
entity's own attributes, so a specialization's expected member set never
contained the members it inherits: nothing looked missing, nothing was added,
and `modified` stayed 0 — which the command prints as "up to date". A false
success is worse than an error, because it ends the investigation: the
reporter reasonably concluded the command was broken rather than the model.

Inherited members are now included, each qualified against the entity that
DECLARES it (M.Gen.AfterSpec, not M.Spec.AfterSpec — that is CE1613), with a
child attribute shadowing an ancestor's of the same name, matching the
executor's own member walk. An ancestor in another module is still neither
added nor pruned; its domain model is not loaded here, so the existing
entries are carried through by the preserve branch. Stale inherited entries
are likewise still preserved — that is the opposite direction from this
defect, and the report's own control says "-0 removed".

Both engines had it in the same shape and both are fixed. A fix in one of
these parallel writers stays latent in the other until something switches
engines.

Two things fixed in passing, in code this change already touches: the legacy
add-loop iterated a map, so the order of new entries varied between runs; and
legacy keyed coverage by bare attribute name while preserving any reference
not qualified against this entity, which would have preserved AND re-added
the same member once inherited ones were expected. The duplicate only appears
on a second reconcile, so the regression test runs it twice.

Measured on 11.12.1, both engines: the reported repro now checks clean, and a
project broken by the PRE-FIX binary is repaired by the fixed one —
"Reconciled 1 access rule(s) in module ProbeSecond", mx check 1 -> 0, on the
project-wide and the scoped form alike. That is also the first end-to-end
proof of the repair path, which an earlier fix recorded as unproven: once the
write path reconciles, no MDL script can produce a CE0066 to repair.

Reverting either engine's attribute walk to own-attributes-only fails its
test with the reported symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
fix(security): reconcile the members a specialization inherits (mendixlabs#1047)
@ako
ako merged commit 3ea0a5d into mendixlabs:main Sep 7, 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