Skip to content

Skill packs, OData query-surface fixes, and four defect fixes - #909

Merged
ako merged 37 commits into
mendixlabs:mainfrom
ako:main
Aug 17, 2026
Merged

Skill packs, OData query-surface fixes, and four defect fixes#909
ako merged 37 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Twenty-two commits since the last sync, in four groups. Everything below was measured against a running Mendix 11.13 app or a real build, not inferred from the metamodel; where a claim is documentary rather than measured, it says so.

Skill packs — a skill that can carry assets

mxcli ships 65 skills and every one is a single Markdown file. That was never a design decision, only what the mechanism could carry. Packs are directories: SKILL.md plus references, spec templates, scripts, MDL and now Java.

  • The mechanismmxcli skill list|add|remove|upgrade, vendored packs embedded in the binary. Packs are opt-in: unlike the prose skills mxcli init writes, a pack may install a widget or apply Java actions, so nothing lands until asked. skill add never touches the .mpr — it copies, and prints the command that would apply the MDL.
  • Namespaces are fitted at install. A pluggable widget id and a Java package are both identity: two projects sharing one are two projects claiming the same artifact, and the symptom is not a build error but a widget or class resolving to somebody else's build. Sources ship with {{NAMESPACE}} / {{MODULE}} placeholders that skill add substitutes. Substitution is a whitelist, not a scan, and drift in either direction refuses the install.
  • Three packs: mendix-vega-charts (Vega-Lite via a pluggable widget, shipped as source plus a lockfile), mendix-bulk-oql-dml, and mendix-odata-pushdown.
  • installs.java is the one target that writes outside .claude/skills/, because a helper class only compiles where the module expects it. MDL cannot author a standalone class — a Java action body is a method body — so without it a pack could only tell the reader to copy a directory by hand. Generated action classes are not placed (the MDL owns those), and an existing file that differs is refused rather than overwritten.

OData: the query surface, measured end to end

  • PublishAssociations is a representation, not a yes/no. Studio Pro's labels are "As a link (recommended)" and "As an associated object id". No selects the legacy mode, which demands the system ID published as key — so a service publishing no associations at all fails with CE7375, naming a concept the script never mentions. MDL-ODATA06 explains it, and warns against following CE7375 literally: object ids are autogenerated and differ per environment, so one baked into an external contract breaks when a consumer moves between them.
  • Path has two rules and one trap. No leading slash (CE6550), must end in one (CE6552) — and with no slash at all mxbuild throws ArgumentOutOfRangeException out of its own validator, with no error code and no element name, which reads as a corrupt project rather than a typo. MDL-ODATA05 catches all three. It found a real one immediately: an in-tree example published /odata/customers, which had only ever been mxcli checked, never built.
  • SupportsGraphQL is now settable from MDL (Mendix 10.14+, gated). Verified against a running app rather than only the build: POST to the service location returns real GraphQL. Two constraints appear only once it is on — exposed names must be unique beyond case (CE2881), and PublishAssociations: No is refused outright (CE8055).
  • What GraphQL actually covers, introspected: $select is inherent, $top/$skip/$orderby map to first/offset/orderBy, key lookup is a singular field — and $filter and $count are absent. An unknown argument is silently ignored and returns the full set, so a client assuming a filter exists gets every row and no warning.
  • What a read microflow must implement itself. "Mendix applies none of the query options" is the received wisdom and is not right: $select is applied to the response, on a microflow-backed resource as much as on a database read; $filter, $orderby, $top and $skip are not. And declaring Filterable/Sortable is what turns a safe 400 "non-filterable" into a 200 with every row — the promise is enforced at the boundary and not kept for you.
  • A view entity read from the database already pushes down all five options, so the pushdown machinery is for resources whose data is not in the app's database at all.
  • Keys: an aggregate's key is its grain (composite, OData 4 only, CE7238), a composite key needs no unique rule, and CE6624 does not apply to a view entity at all.

Four defects

mxcli test reports what it asserted

Two changes from the test runner, both closing silent-absence paths. @expect shapes the regex could not parse recorded no assertion at all, and a test with no assertions passes as long as its body does not throw — so @expect 1 = 2 reported PASS. That now fails closed, and every result line carries its assertion count, so a vacuous test cannot hide in a green suite.

Verification

go test ./..., make check-mdl and make check-skill-mdl green. The OData work was verified against a running 11.13 app over both surfaces; the pushdown parser ships a verify: that runs without an app, database or request, and was confirmed to fail when its rules are disabled.

claude and others added 30 commits August 14, 2026 18:02
DROP FOLDER's contract -- "the folder must be empty (no child documents
or sub-folders)" -- existed only in a doc comment. Nothing checked, so
the command called DeleteFolder unconditionally and left every document
inside pointing at a container that no longer existed.

Nothing was deleted. The documents were *orphaned*: they survive as
units but lose their module qualification, so
FeedbackModule.IMM_PostResponse becomes .IMM_PostResponse, nothing can
resolve them, and mxbuild reports CE1613 "no longer exists". That
distinction matters -- the data is recoverable by re-parenting, not lost
-- and it means the fix is "refuse or re-parent", not "stop cascading a
delete".

Reproduces on a STOCK blank app: FeedbackModule ships
Private/Resources/Mappings holding two JSON structures, one import
mapping and one export mapping.

The guard reads ctx.Backend.ListUnits(), NOT the per-kind lists that
LIST FOLDERS renders from. That is load-bearing: documentsByContainer is
a hand-maintained list of twelve document kinds, and the four documents
above are none of them -- which is why the folder rendered as [0] and
made the drop look safe. A guard built on the same list would inherit
the same blind spot and wave through exactly the kinds it forgets.
ListUnits is type-agnostic, so it cannot. Folders are units too
(Projects$Folder), so one containment scan covers sub-folders as well.

It also fails closed: if ListUnits errors, the drop is refused. For a
destructive operation "I could not check" must never mean "go ahead".

Separately adds the five missing kinds to documentsByContainer so the
count stops lying -- the folder now reports [4] and names all four.

Verified end to end on a blank 11.13 app: the drop is refused, the four
documents keep their module qualification, and mx check reports 0
errors. Control run with the guard stubbed fails exactly the three guard
tests and passes with it restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
…target (mendixlabs#891)

`REPLACE NextRunAt WITH { COLUMN ... }` and `INSERT AFTER PageSize
{ COLUMN ... }` reported "Altered page" and left a project mxbuild
could not even LOAD:

  System.InvalidCastException: Unable to cast object of type
  '...LayoutWidgets.DivContainers.DivContainer' to type
  '...CustomWidgets.WidgetObject'

ast.WidgetRef treats only a DOTTED name as a column, so a bare
`NextRunAt` gives Widget="NextRunAt", Column="", IsColumn()==false. The
op skipped the InsertColumns/ReplaceColumn paths -- which work -- and
took the generic widget path, which built the COLUMN as a layout
container and wrote it into the grid's column list. Nothing refused
because findBsonWidget recurses into pluggable-widget internals, so the
bare name DID resolve, to the column node.

Three corrections to the report. The issue calls REPLACE "deletes the
column without writing the replacement" and INSERT "reports success but
makes no changes"; both are corruption, and DESCRIBE PAGE only made them
look benign by skipping the malformed node. And neither needs the grid
nested in a pluggable widget -- both reproduce on a plain top-level
DataGrid2 in a blank app.

Discriminates on the resolved node's $Type: an object-list item
(DataGrid2 column, Accordion group, PopupMenu basicItem) is
CustomWidgets$WidgetObject; a real widget is Forms$* or
CustomWidgets$CustomWidget. Not on a name-match count -- columnMatchCount
already existed but refused only when n > 1, so the single-match case
sailed through, and a widget sharing a name with some column elsewhere
would be a false positive.

Refuses rather than resolving: guessing which grid was meant is what
produced the invalid document. The message names the qualified
`grid.column` form, which always worked and is unaffected.

The guard lives in the mutator, not the executor. An executor-level
check on FindWidget only catches names that are absent entirely -- which
the mutator already refuses -- because FindWidget matches columns too.

Verified on a blank 11.13 app across all four forms: both bare forms now
refuse with the project left at 0 errors (previously unloadable), both
qualified forms still apply and check clean. Control run with the guard
stubbed reproduces the InvalidCastException.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
…t item (mendixlabs#891)

DESCRIBE PAGE rendered an Accordion group with its own properties and
nothing else, so a page whose group held a DataGrid2 described as:

  pluggablewidget '...accordion.Accordion' acc1 (...) {
    group group1 (HeaderRenderMode: 'text', ...)
  }

The grid was genuinely in the model, so feeding that description back
through exec silently deleted it -- the round-trip loss the reporter
flagged, not merely a display gap.

Two halves, and fixing either alone still prints an empty group. An
object-list item can carry child widgets in a Widgets-typed
sub-property (a group's `content` slot), but extractObjectListItem read
only scalar sub-properties -- datasource, attribute, expression, text
template, primitive -- and fell through on anything else. And the
emitter always closed an item with "\n", so children had nowhere to go
even once read.

Reads the Widgets array with parseRawWidget, the same recursion the rest
of DESCRIBE uses, and emits the item with a body through
outputWidgetMDLV3 so nesting and indentation stay consistent. The
keep-this-item test now also counts children, or a group whose only
content is widgets is dropped wholesale.

Generic, not Accordion-specific: any pluggable widget's object-list
items (PopupMenu basicItems, and so on) get the same treatment.

Verified on a stock blank app -- the Accordion widget ships in every
Mendix project, so no marketplace install is involved. The nested grid
and both columns now appear, the description re-parses, and re-executing
it preserves the grid with the project's error count unchanged (2 before,
2 after; the CE0463 on the accordion comes from authoring one through the
generic PLUGGABLEWIDGET path and is present with or without this change).
Control run with the extraction stubbed drops the grid from the output
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
`$A - $B + 1` was stored in the .mxunit as `$A + $B - 1`, and the running
app computed the swapped value. The ledger's caption rendered "48620
months" for a span of 20: 24320 + 24301 - 1.

buildAdditiveExpression read AllPLUS() and AllMINUS() as two separate
token lists and emitted every plus before every minus, discarding the
order they appeared in. The comment said as much — "a simplified approach
- for complex expressions we'd need to track token positions".

The precise rule is that the chain is re-sorted, all `+` ahead of all `-`.
That is sharper than "a `-` followed by a `+` swaps", and it predicts which
cases come through intact: an all-minus chain has no plus to float ahead,
and `+` before `-` is already the order the broken code emitted.

The fix was already in the file, twenty lines below: buildMultiplicativeExpression
walks GetChildren() in order and builds its operator list correctly. This
is that pattern applied to the additive case, so no new mechanism.

Two things make this class of defect nastier than it looks:

  - The corruption is in the stored model, not in DESCRIBE. `strings` on
    the .mxunit shows the swapped text, which is why the runtime computes
    it. Confirmed on 11.12.1.
  - A rewritten expression is perfectly valid, so nothing downstream can
    catch it. `mxcli check` passes, `mx check` reports 0 errors, the build
    succeeds and the microflow runs. The only symptom is the number, and a
    number is the thing a reader assumes is right. The ledger caught this
    one because 48620 months is absurd; out by two, it would have shipped.

The only test that works is round-trip equality — "does it apply cleanly"
proves nothing here. The control cases carry the weight: `$A - $B - 1` and
`$A + $B - 1` pass before and after, so a suite built only from the failing
cases would have gone green against code that sorted all minuses first
instead. Verified by reverting the fix and confirming exactly the four
swapped cases fail with the reported symptom.

All eleven expressions in the example now round-trip verbatim through
DESCRIBE MICROFLOW, with mx check at 0 errors.

Reported in mxcli-ledger FINDINGS #105.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
mxcli ships 65 skills and every one is a single Markdown file. That was
never a design decision — it is what the mechanism can carry. The
mxcli-ledger project has produced blocks that do not fit: a Vega-Lite
charting pack with seven spec templates and a headless checker, and an
OQL bulk-DML pack with three Java actions applied through MDL.

Four independent places blocked them, and the write path is the one with
teeth:

  - Embed  `//go:embed skills/*.md` — flat, .md only
  - Sync   flat `for f in ...*.md` in the Makefile
  - Write  filepath.Join(dir, d.Name()) — BASENAME. Nesting is flattened,
           so references/install.md and specs/install.md silently collide
  - Refresh syncAIContextSkills skips directories outright, so a pack
           would never follow a binary upgrade

The flattening does not error. It produces a plausible-looking directory
with a file quietly missing, which is the failure mode this repo keeps
writing down: a tool accepting what it does not implement is worse than
one that rejects it.

This adds cmd/mxcli/skillpack, `mxcli skill list|add|remove|upgrade`, and
vendors the first pack.

Three decisions worth stating:

**Packs are opt-in; skills are not.** The 65 prose skills are free to
write into every project. A pack is not: this one adds Java actions to the
model, and a charting pack needs a widget installed. So copying a pack
never touches the model — `skill add` writes files and prints the command
that would apply the MDL, for the user to run deliberately.

**`all:` on the embed is load-bearing, not defensive.** A plain go:embed of
a directory skips `_`- and `.`-prefixed files. cmd/mxcli/theme/assets.go
carries the same prefix because `_partial.scss` is how SCSS spells a
partial and the theme package lost them once. A pack is just as likely to
ship a `_helper.mjs`.

**Install prunes.** The existing sync overwrites but never deletes, so a
pack dropping an asset in v2 would leave v1's behind forever — and a stale
spec template is worse than a missing one, because it still looks current.

The tests drive the hazards rather than the happy path: two files both
named install.md in different subdirectories (the flattening case), a v2
pack that drops files (the prune case), a second install that must write
nothing, and a pack directory that must not be reachable from another's
prune. The traversal test earned its place immediately — it caught that
".." survives `name == filepath.Base(filepath.Clean(name))`, which would
have let `skill remove ..` RemoveAll the whole skills directory.

The pack's own MDL is now checked by `make check-skill-mdl`; the existing
script only reads fenced blocks in markdown and never saw it. A pack whose
verifier is not run in CI is a pack that rots.

Deferred, and named in the proposal: digest-fenced refusal of locally
edited files (theme/ already does this and packs should reuse it),
`--apply` actually executing installs.mdl, `init --with`, and the
Vega pack, which cannot be vendored until its widget is re-published away
from the ledger's `ledger.widget.web.*` namespace.

Design and rationale: docs/11-proposals/PROPOSAL_skill_packs.md
Pack source: https://github.com/ako/mxcli-ledger

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The second ledger pack was deferred in the first commit because its widget
ships under `ledger.widget.web.*`, and vendoring it as-is would hand every
project the ledger's namespace. This adds it, and the mechanism that makes
that safe.

A pluggable widget's id is its identity. Two apps whose widgets share one
are two apps claiming the same widget, and the symptom is not a build
error — it is a widget resolving to somebody else's build. The ledger's
own install notes make this three manual edits across three files, done
before the build, "otherwise every page that carries the widget has to be
re-applied".

So the widget source ships with placeholders and `skill add` substitutes
the destination project's namespace into package.json (packagePath and the
build's projectPath), src/package.xml (the client-module path) and
src/VegaChart.xml (the id) — from one value, so they cannot drift apart.
`--namespace acme` overrides; the default is derived from the project name
and always printed, because a namespace nobody chose is as wrong as one
that does not fit.

Three properties make a missed substitution impossible rather than
unlikely:

  - Placeholders, not a real namespace. Leaving `ledger` in place means a
    bug ships THEIR namespace silently; an unsubstituted {{NAMESPACE}}
    fails loudly.
  - A whitelist, not a scan. Only files named in rewrite.files are
    touched. A pack ships megabytes of built JS and spec JSON, and a blind
    replace is how a spec containing brace syntax quietly becomes
    something else.
  - Drift either way is an error — a declared file carrying no token (the
    file changed under the manifest) and a declared file the pack does not
    ship both refuse the install.

`skill upgrade` re-substitutes what the install recorded in pack.lock.yaml
rather than re-deriving. Re-deriving would change the id when a project is
renamed, and a changed widget id is every page pointing at a widget that
no longer exists under that name. The lock is written by the install, not
shipped by the pack, so the prune had to learn to keep it.

The widget ships as SOURCE, not a built .mpk. The built package is 3.1 MB
of bundled Vega, which has no business in a source repo or in the binary;
and the namespace has to be right BEFORE the build, so shipping a prebuilt
package would mean rewriting paths inside a zip and hoping, where
rewriting source is the path the ledger actually verified.

Verified end to end on Mendix 11.12.1, not just at the unit level:

  mxcli skill add mendix-vega-charts -p App1112.mpr   -> namespace app1112
  npm ci && npm run build                             -> app1112.widget.web.VegaChart.mpk
  mxcli widget init -p App1112.mpr                    -> discovered
  a page carrying the widget, then mx check           -> 0 errors

Every path inside the built package is under the new namespace, as is the
id in VegaChart.xml, and zero paths carry the old one. The build's .mpk
landed straight in the project's widgets/ — which is how the relative
projectPath got caught: filepath.Rel refuses to mix a relative and an
absolute path, and the fallback baked an ABSOLUTE path into a package.json
that gets committed, working on exactly one machine.

`mxcli widget init` is now named in the install output. Without it the
first page fails with "no definition for widget ...", which reads as a
packaging problem rather than a step nobody mentioned.

TestVendoredPacks* check the packs that actually ship, not fixtures: every
rewrite.files entry exists and carries a token, everything installs.*
names is present, and no widget source carries a harvested project's
namespace. Both guards were confirmed by vendoring a bad pack on purpose.

The pack's own install.md described the three manual edits; it now
describes what mxcli does instead, since a doc contradicting the tool is
worse than no doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Keep an additive chain's operators in the order they were written
Skill packs: ship a skill that carries assets, with widget namespaces fitted at install
`mxcli test` matched one assertion shape with a regular expression —
`@expect $var (=|<>) <value>` — and when FindStringSubmatch returned nil
it recorded no assertion at all. A test with no assertions passes as long
as its body does not throw, so every other shape passed unconditionally,
with nothing in the output to tell it apart from a real assertion:

    /**
     * @test a self-evident falsehood
     * @expect 1 = 2
     */
      PASS  a self-evident falsehood (6ms)

Also vacuous: length(), find(), substring(), any comparison other than
`=`/`<>`, and `!=`. In the reporting project 16 of 22 tests asserted
nothing beyond "did not throw", and the suite had said 22/22 at every
commit — mutation testing is what exposed it, with mutants returning
obviously wrong values surviving the run.

The narrow support was never the defect; the silence was.

The whole annotation body now goes to a validating parser (expect.go):
a strict recursive-descent pass over exprcheck.Lex — not mdl/exprcheck's
own parser, which recovers and emits hints, exactly the wrong behaviour
here. Anything it cannot compile becomes an ExpectErrors entry, the test
is not generated at all, and the runner reports StatusError, which
FailCount counts, so the run exits non-zero:

    ERROR  an assertion nobody can evaluate
           @expect randomInt($result) = 1: randomInt() is not a Mendix
           expression function at column 1 ("randomInt")

Everything Mendix's expression engine accepts now works: built-ins, every
comparison operator, and/or/not, attribute paths, enumeration values. The
branch-swapping workaround for `<>` is gone — the operator is rewritten
to `!=` at parse time, which is the spelling Mendix accepts.

A failure also reports what came back, not only what was wanted, when the
assertion pins the observed value's type: a String operand is used
directly, a known non-String scalar is wrapped in toString(), and nothing
is emitted when neither side establishes a type. Mendix's expression
engine is typed and a wrong guess fails the build rather than the test.

The summary line separates Errors from Failed, because output that cannot
distinguish "this assertion is false" from "this assertion was never
evaluated" is how the defect stayed invisible.

Measured against mxbuild 11.6.6, on eleven generated microflows covering
every shape in the report: 0 errors. Two controls make that mean
something — `<>` really is CE0117, so the rewrite is load-bearing, and a
wrongly typed comparison really is caught (`$result = 3` on a String is
CE0117). Stubbing ParseExpect back to the old regex returns every canary
in expect_test.go to an empty condition.

Reported as mxcli-sudoku FINDINGS #46. Repro:
mdl-examples/bug-tests/expect-vacuous-assertions.mdl

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

CI failed with both new fixtures reporting "negative test unexpectedly
passed". The guards are correct; the fixtures are in the wrong harness.

`.fail.mdl` has one meaning in `make check-mdl`: the runner executes
`mxcli check <file>` with NO project and demands a non-zero exit. That
tests check-time rules only.

Both of these guards need a model before they can decide anything —
DROP FOLDER's emptiness check lives in mdl/executor/cmd_folders.go, and
the bare-column-target refusal in mdl/backend/pagemutator/mutator.go.
The statements themselves are valid MDL, so `check` exits 0 and the
runner reads that as the rule having regressed. Verified against the
branch's own binary: both exit 0, and 892 reports "Syntax OK (1
statements) / Check passed!".

Renaming to plain .mdl fixes it. Nothing is weakened: the refusals are
covered by the unit tests this PR already added
(cmd_folders_mock_test.go, mutator_column_addressing_test.go), and the
files stay as the by-hand repro, which is what their own comments
describe running.

Confirmed against a real 11.12.1 app that the fix still does its job
after the rename:

  list folders in FeedbackModule   ->  Private/Resources/Mappings [4]
  exec 892-drop-folder-not-empty.mdl -> refused, naming the 4 documents

The two header comments said "expected to FAIL", which was true of the
old suffix and would now mislead the next reader, so each says instead
why it is not a .fail.mdl and points at its unit coverage. The same note
goes in the Makefile beside the convention, since the trap is the
convention's own edge rather than something either author did wrong.

`make check-mdl` is green (both files PASS) and `go test ./...` passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
mxcli test: fail closed on an @expect it cannot evaluate
Refuse DROP FOLDER on a non-empty folder, and stop LIST FOLDERS under-counting (mendixlabs#892)
Resolves the two conflicts blocking PR #152. Both are textual — nothing
semantic collided across the 26 changed files.

Makefile — the .PHONY line only. Each side appended a target the other
did not know about: `sync-skill-packs` here (#150), `check-tunnel-deps`
upstream (the Linux-only tunnel change). Taken as a union; both target
bodies had already merged cleanly, it was the declaration that clashed.

CHANGELOG.md — upstream cut the 0.18.0 release, dating what had been its
[Unreleased] section, while this side added four unreleased Fixed entries
for mendixlabs#891/mendixlabs#892. Those four stay under [Unreleased] and the release section
follows: upstream's 0.18.0 does not mention mendixlabs#891 or mendixlabs#892 anywhere, and
they landed here after that release was cut.

The Go 1.26.5 -> 1.26.6 entry looked like it would duplicate — it was
unreleased here and released there — but upstream's 0.18.0 already
carries the same text, so git matched the two and the merged file has
exactly one copy. Checked rather than assumed.

Verified on the merged tree: `make build`, `go test ./...` and
`make check-mdl` are all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Sync with origin (v0.18.0) — conflicts resolved
Follow-up to the @expect fail-closed fix (#151). That change stopped an
assertion from being silently dropped. This one closes the two remaining
silent-absence paths, both of which produce the same result: a green
suite nobody can read.

**A test that asserts nothing looked exactly like one that asserts six.**
A test with no @expect and no @throws returns PASS the moment its body
completes. That is a legitimate smoke test, but after the @expect fix the
cheapest way back to a green suite is to delete the assertion — and the
output could not tell that apart from a repair. Every result line now
carries the count, and a run containing a vacuous test says so:

    PASS  the board is 81 squares (6ms, 2 assertions)
    PASS  asserts nothing at all (4ms, no assertions)
    ------------------------------------------------------------
    1 test(s) asserted nothing beyond "did not throw". Run with
    --require-assertions to make that an error.

The count is on the ordinary result line rather than behind --verbose,
because the lesson of the original defect is that the *default* output
has to distinguish a test that asserted from one that did not. Vacuous
tests still pass by default; --require-assertions makes them ERROR for a
project that has decided every test must assert.

**@verify is parsed and evaluated by nothing.** It is documented in the
skill's annotation table as an OQL post-condition, has been populated
into TestCase since the runner was written, and is read by nothing but
--list — a test whose only assertion was a @verify asserted nothing. That
is the same defect as a dropped @expect wearing a different annotation,
so it gets the same answer: an ERROR naming the annotation and pointing
at @expect. The docs no longer advertise it as working.

Also: TestResult now carries SourceFile, so JUnit's classname and file
identify the test file instead of stamping every case with the suite
name — a failure in a multi-file run could not say where it lived — and
the assertion count rides along as a <property> a CI report can show.

Every TestResult is now built through one constructor (newResult), which
carries the case-derived fields. The five literal construction sites were
exactly how a new field gets populated in one path and silently missing
in another.

Controls: stubbing AssertionCount to return 1 and reverting the @verify
rejection puts every new test in assertions_test.go back to failing with
the reported symptom.

Reported as mxcli-sudoku FINDINGS #46. Repro:
mdl-examples/bug-tests/expect-vacuous-assertions.test.mdl

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqQLyxnppZGfqPYbUHvSWG
mxcli test: report what each test actually asserted
`references/install.md` and SKILL.md both say `npm ci`, and the pack shipped
no package-lock.json, so the one command it told people to run failed on the
spot:

  npm error The `npm ci` command can only install with an existing
            package-lock.json

Found by the formula1 project, which hit it doing exactly what the pack said.

The reason it shipped is worth recording. The end-to-end verification did
build the widget and produce a correctly-namespaced .mpk — using `npm
install`. So the build was proven while the documented path was never
exercised. A verification that quietly substitutes a working command for the
published one proves the wrong thing.

Shipping the lock rather than downgrading to `npm install`, because the
pack's value is a build that still works later. The three direct
dependencies are pinned exactly, but their transitive tree is not, so
without a lock the build drifts — surfacing as a compile error in somebody
else's project, months on, from a package nobody chose to upgrade. 974 KB
of JSON, and about 1% on the binary.

Substitution cannot desync it: the tokens live in `packagePath` and
`config.projectPath`, while npm's lockfile records only name, version,
license, dependencies and devDependencies for the root package. Verified
rather than reasoned about — installed the pack with `--namespace acme`,
then ran the documented `npm ci && npm run build` against the SUBSTITUTED
tree: 1512 packages, and every path inside the built .mpk under
acme/widget/web/vegachart with a matching id in VegaChart.xml.

Two guards so the class does not recur:

  - TestWidgetPacksShipALockfile — a pack shipping widget/package.json must
    ship the lock beside it. Anchored on package.json rather than on the
    prose, since a pack that builds JavaScript wants a reproducible tree
    whatever its docs say. Confirmed against the pack as it shipped in main:
    it fails with the exact complaint.
  - TestLockfilesAreNotRewritten — a lock must never be listed under
    rewrite.files. It records resolved integrity hashes, so substituting
    into one invalidates them and `npm ci` fails on a checksum, which reads
    as a corrupt registry rather than a packaging mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
From the formula1 project's findings §54, which drafted and validated the
pack and identified the one target the mechanism was missing.

MDL cannot author a standalone class: createJavaActionStatement accepts a
method body, with no class declaration and no imports clause. So a pack
whose Java actions delegate into helper classes — this one is four
two-line delegations into 882 lines of parser — could ship only prose
telling somebody to copy a directory by hand. That is precisely the manual
step packs exist to remove, which is why it was worth a new target rather
than a workaround.

`installs.java` is the third target and the only one writing outside
.claude/skills/<pack>/, because a helper class compiles only where the
module expects it. A Java package is a class's identity exactly as a
widget id is — two projects sharing one are two projects claiming the same
class — so it reuses the substitution machinery unchanged: {{MODULE}} and
{{MODULE_PATH}}, declared in rewrite.files, supplied by --module.

Three rules, each with a wrong default available:

  - java/actions/ is NOT placed. mxcli writes those classes from the MDL,
    so placing the pack's copies means two sources of truth for the same
    files and applying the MDL overwrites them immediately.
  - An existing file that differs is refused, never overwritten
    (guard-don't-drop, ADR-0005). A locally fixed helper and a stale copy
    are indistinguishable from here; silently replacing somebody's edited
    parser is not a trade to make for them. The refusal names the files.
  - NeedsNamespace now keys on installs.widgets rather than on
    rewrite.files. This pack tokenises eight files and wants a MODULE,
    never a NAMESPACE, and asking the wrong question invites an answer
    that goes nowhere.

make check-skill-mdl now substitutes before checking, because that is the
only form anyone runs. Checking the raw file fails on every tokenised pack
— it did, on this one — and whoever hit that would be tempted to drop the
check rather than fix it. Confirmed still able to fail: a deliberately
broken statement in the tokenised MDL is caught.

Verified end to end rather than at the unit level:

  skill add mendix-odata-pushdown --module ODataPushdown
    -> 3 helpers in javasource/odatapushdown/, 4 action classes excluded
    -> package odatapushdown; in all three, MDL naming ODataPushdown.*,
       action bodies delegating to odatapushdown.*
  mxcli check on the substituted MDL     -> 8 statements, syntax OK
  javac on the placed 633-line parser    -> compiles clean
  re-run                                 -> nothing written
  edit a helper, re-run                  -> refused by name, edit intact

The last two matter most: the placement is idempotent, and it will not eat
your changes.

Also fixed a message that would have misled: the "MDL uses a MyModule
placeholder" hint is printed only for a pack whose MDL was NOT
substituted. Saying it of one that just had its real module name written
in sends the reader hunting for a placeholder that is not there.

references/packaging-gap.md is kept rather than deleted, reframed as the
reasoning behind the target's shape — the next pack wanting a new target
needs the same argument made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Ship the Vega pack's lockfile, so the command it documents can run
Let a skill pack place Java, and land the OData pushdown pack
Ledger finding #113 reported a published service failing with CE7375 and
concluded mxcli was missing an "association representation" property. It is
not missing. There is no such property to add, and the value the script
gave an existing one is what cannot build.

Established before writing anything:

  - "Representation" appears nowhere in the Mendix Model SDK (4.114.0, the
    latest published) and nowhere in Mendix 11.13's own metamodel
    assemblies. The only service property is PublishAssociations.
  - Studio Pro's two labels for it, read out of
    Mendix.Modeler.Localization.dll, are "As a link (recommended)" and "As
    an associated object id" — the true and false of that boolean.

So it is a two-value representation wearing the name of a yes/no. A service
publishing no associations reads `No` as obviously correct and gets an
error naming a concept its script never mentions. Measured on 11.13,
control and treatment, same service on a blank app:

  PublishAssociations: No   ->  CE7375
  PublishAssociations: Yes  ->  0 errors

mxcli already defaults it to Yes and accepts it on CREATE, so nothing is
broken in the writer — what was missing is anything that says so. MDL-ODATA06
warns, naming CE7375 and the fix. It stays a warning, not an error: false is
a legitimate Mendix mode for a service whose key is arranged in Studio Pro;
what it is not is what it sounds like.

The same probe found a second shape mxcli writes happily. mxbuild wants a
location with no leading slash that ends in a single slash, and with NO
slash at all its own validator throws:

  Path 'cat'         ->  System.ArgumentOutOfRangeException, no error code
  Path '/cat/'       ->  CE6550 "The path should not start with a slash."
  Path 'odata/cat'   ->  CE6552 "The location should end with a single slash."
  Path 'odata/cat/'  ->  0 errors

The crash is the reason this is worth a rule: there is no code to look up
and no element named, so a one-character mistake reads as a corrupt
project. MDL-ODATA05 catches all three.

It found a real one immediately: 595-published-odata-entitytypepointer.mdl
publishes `/odata/customers`, which is CE6550 — an example that had only
ever been `mxcli check`ed, never built. Fixed here.

The odata-data-sharing skill said to keep the default, but scoped it to
non-persistable entities, which implies a persistable one is fine with No.
It is not: a persistable entity with a unique key of its own fails
identically. Corrected with the measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The check told people to set PublishAssociations: Yes and stopped there,
which leaves the other reading of CE7375 open: publish the ID and make it
the key. That is the worse move, and the error message recommends it.

The two representations are historical. OData v3 had no link support, so a
foreign key had to be an exposed object id; v4 added links largely so
internal ids no longer had to leave the app. Choosing object-id mode now
gives that back up.

And a Mendix object id is not a key you want in a contract: it is
autogenerated and not stable across an app landscape, so the same record
carries different ids in test, acceptance and production. An id baked into
an external contract breaks the moment a consumer moves between
environments or compares data from two of them. A published key should be a
business value the domain already guarantees — an invoice number, an ISIN,
an employee number. Mendix requires a key to be unique, required and
stable, and the unique validation rule it makes you add (CE6624) is
checking exactly that.

Measured while confirming the rule did not need version-scoping: on 11.13
`PublishAssociations: Yes` builds under both OData3 and OData4, so links
are not a v4-only option in current Mendix and MDL-ODATA06 is correct for
both.

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

The business-key advice has a hole exactly where the ledger is standing: a
summary resource has no business key to reach for. Monthly totals per
category are not an invoice, and nothing in the domain issues them a number.

The key is the grain — the columns the aggregate groups by. They identify
one row, they are stable because they ARE the definition of the row, and
they mean the same thing in every environment. What the ledger reached for
instead, `cast(c.id as string) as RowId`, satisfies "a key" while
reintroducing the id problem one level down: autogenerated,
environment-specific, and stable only until the view is rebuilt.

Measured on 11.13, each row a separate build:

  single key, persistable, no unique rule   -> CE6624
  single key, persistable, unique error '…' -> 0 errors
  composite key, OData3                     -> CE7238 (v4 only)
  composite key, OData4, persistable        -> 0 errors
  composite key, OData4, NON-persistable    -> 0 errors
  any validation rule on non-persistable    -> CE0070

Two consequences that are not obvious from either error:

  - A grain key requires ODataVersion: OData4. More than one key attribute
    is a v4 feature; the same model on v3 is CE7238.
  - A composite key needs no unique validation rule, and a non-persistable
    entity could not carry one anyway (CE0070). CE6624 only applies to a
    SINGLE-attribute key, where one attribute must be unique by itself —
    precisely what a grain is not. So the hurdle the ledger hit disappears
    once the key stops pretending to be one column.

ledger-113b-odata-grain-key.mdl is the shape, and it is not just parsed:
applied to a blank 11.13 app it is 0 errors, non-persistable entity and
composite grain key included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Explain the OData property whose name invites the wrong value, and what to key an aggregate on (ledger #113)
A published OData service can answer GraphQL too — one boolean, the same
resources. Every layer already had it except the one an author reaches:
generated/metamodel binds it, modelsdk/gen has SupportsGraphQL() and its
setter, and both engines' writers take arbitrary bools. Only
knownODataServiceProps did not list it, and unknown OData properties are an
ERROR (MDL-ODATA01), so a script asking for GraphQL was rejected outright.

Wired through the whole pipeline: model.PublishedODataService, the AST
(with Set, so `create or modify` that does not mention it cannot turn
GraphQL off on a service that has it), the visitor, create/alter/describe,
both writers, both readers, the known-property list, syntax help and the
quick reference.

No default is inferred. Unlike PublishAssociations, where false can never
build, false is simply what every service was before the property existed —
so an omitted value is left alone rather than opted in.

Gated at Mendix 10.14, where the release notes introduce it as
experimental: "Studio Pro now supports publishing GraphQL services." The
gate is a real guard rather than a nicety, because writing a property a
version's metamodel does not have is not a build error — it is a document
Studio Pro refuses to open. The floor is documentary, not measured: the
Mendix CDN serves no 10.x mxbuild from here, so the earliest assembly I
could inspect is 11.x. Recorded as such in the registry.

Verified against a RUNNING 11.13 app, not just the build, because "the
model stores a flag" and "the app answers GraphQL" are different claims:

  GET  /odata/charts/$metadata          -> 200, OData unchanged
  POST /odata/charts/ {__schema{queryType{name}}}
       -> {"data":{"__schema":{"queryType":{"name":"Query"}}}}
  POST /odata/charts/ {monthCategories{period category total}}
       -> {"data":{"monthCategories":[]}}

The endpoint is the service LOCATION — there is no /graphql path, which
cost a while to establish: /graphql, /graphql/<svc> and
<location>/graphql are all 404.

Two constraints that appear only once GraphQL is on, both found by
building rather than by reading:

  - Exposed names must be unique beyond case (CE2881). Publishing an entity
    without `as '...'` gives the type and the set the same name, which OData
    accepts and GraphQL does not — so a service that built yesterday fails
    the day it is enabled.
  - Query field names are camelCased. `Period` is `period`, and asking for
    `Total` is a 400 "Field 'Total' not found". The two surfaces spell the
    same attribute differently.

DESCRIBE emits it only when on, since false is every pre-existing service
and printing it on each would be noise in every description.

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

Found while verifying the grain-key guidance against an actual OQL view
entity rather than a non-persistable stand-in. Flipping the same service to
PublishAssociations: No produced two errors, not one:

  [CE7375] "Attribute ID for entity 'MyFirstModule.VMonthCategory' must be
  published and be the key when associations are exposed as an associated
  object id."
  [CE8055] "A service that supports GraphQL must publish associations as a
  link."

CE7375 is the ledger's message reproduced verbatim, now on the entity kind
they actually had. CE8055 is new: GraphQL has no representation for an
associated object id, so the pair can never build whatever else the author
does. That makes it a refusal rather than a warning — unlike
PublishAssociations: No on its own, which stays a legitimate mode for a
service whose key is arranged in Studio Pro.

The view-entity verification, end to end on 11.13:

  OQL view over 4 source rows -> 3 grain rows (Rent 1200+300 = 1500)
  published keyed on (Period, Category), PublishAssociations Yes,
  SupportsGraphQL Yes -> mx check 0 errors

  GET  /odata/charts/MonthCategories
       -> 200, the three aggregated rows
  POST /odata/charts/ {monthCategories{period category total}}
       -> 200, the same rows, camelCased
  GET  /odata/charts/MonthCategories(Period='2026-07',Category='Rent')
       -> 200 {"Period":"2026-07","Category":"Rent","Total":1500.0}

That last one is the point of the whole thread: the grain is a real key, so
a client can re-read one row by it — which is what CE7375 was demanding an
object id for, and what an object id would have made environment-specific.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The GraphQL checkbox does not say what it leaves out. Introspected and
exercised on 11.13, on one resource published over both surfaces at once:

  $select   -> inherent; naming the fields IS the projection
  $top      -> first: Int
  $skip     -> offset: Int
  $orderby  -> orderBy: [{field: ASC|DESC}]
  key lookup-> a singular field, vMonthCategory(period:, category:)
  $filter   -> ABSENT
  $count    -> ABSENT

The whole schema for a one-entity service is nine types — Query, SortOrder,
the entity, its order input and the scalars. No filter type, no where type,
no count type exists in it. ($expand is untested; the probe has no
associations.)

Two traps, both measured rather than inferred:

  - orderBy must be a LIST. `orderBy: {total: DESC}` fails with "Incorrect
    value for orderBy" while `[{total: DESC}]` works, and introspection
    advertises the argument as a bare input object rather than a list. The
    schema and the parser disagree, and the error does not say which way.
  - An unknown argument is SILENTLY IGNORED. Both `where: {…}` and
    `bogusArgument: 42` return 200 with the full result set. A client that
    assumes a filter argument exists gets every row and no warning — the
    same "200 with the wrong rows" failure the pushdown work is about,
    surfacing somewhere new.

So paging and sorting are safe over GraphQL and filtering is not there. A
widget needing server-side filtering has to use OData, which is a reason to
keep the OData surface even on a service with GraphQL enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A Studio Pro service publishing a view entity keyed on a SINGLE column,
with no unique validation rule, reports 0 errors. The guidance shipped
here implied otherwise: its table had CE6624 for a single-attribute key
and offered the grain as the way out, which is right for a persistable
entity and wrong for a view.

Reproduced rather than taken on faith — same probe, view entity, one KEY
attribute, no validation rule: 0 errors on 11.13.

So the rule is narrower than stated. CE6624 is a persistable-entity
requirement; a view cannot carry a validation rule at all (CE0070) and is
not asked for one. If a view already has a naturally unique column — an id
carried through from the source data, not the platform's object id — key on
that. The grain is for when no single column identifies a row, which is the
normal case for an aggregate but not for a flattening view.

The business-key argument is unaffected: the column to key on is one the
domain guarantees, whether it arrives as one column or as the grain.

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

The published-entity dialog shows Action: "Read from database" with
Countable, Top supported and Skip supported all ticked, on a view entity.
That is worth measuring, because it decides whether a project needs any
pushdown machinery at all.

Measured on 11.13 against a running app — an OQL view over four rows
aggregating to three, published with the database read and no Java
anywhere:

  $top=1                       -> 1 row, not 3
  $count=true&$top=1           -> "@odata.count": 3, one row returned
  $filter=Category eq 'Rent'   -> only the Rent row
  $orderby=Total desc&$skip=1  -> [400, 250]   (1500 correctly skipped)

So aggregation happens in the database and paging and filtering push down
to it. A chart or grid can page a large resource with nothing hand-written
— which is the capability mendix-odata-pushdown exists to recreate.

That pack is for the case a view cannot cover: a resource with no table
behind it, where a read microflow is the only way to produce the rows and
Mendix applies none of the query options to them. Recorded as the contrast,
so the pack is reached for when it is needed rather than by default.

The same dialog also states the grain rule in Mendix's own words: "Choose
the attribute(s) that form the key of this entity. These attributes should
never be empty, and should together form a unique identifier." Multi-column
keys, uniqueness on the combination, and required — which is what the grain
guidance already says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
claude and others added 7 commits August 17, 2026 05:41
The previous wording listed "a CSV through a connector" among the cases a
view entity cannot cover, which is true and flattens the point. The shape
that makes the pushdown load-bearing is two apps:

  frontend app  --- external entities / OData --->  backend app
  (grid, chart)                                     (no data of its own)
                                                          |
                                            external database connector
                                                          |
                                                    DuckDB over CSV

The frontend pages and filters by generating $top/$skip/$filter, because
external entities ARE OData and it has no other vocabulary. The backend's
read microflow has to translate those into the SQL it sends through the
connector. Without that translation the paging still looks correct while
every page drags the whole file across, and neither app reports anything.

Two things follow that the old phrasing obscured:

  - "Prefer a view entity" is not advice that applies here. The data is not
    in this app's database, so there is no table to select from. The real
    question is only whether THIS app owns the data.
  - The consumer's capability flags have to match the service: an external
    entity generated with TopSupported/SkipSupported the service does not
    honour is CE6630 in the consuming app. The two ends are checked against
    each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
"Mendix applies none of the query options to a read-microflow resource" is
the received wisdom and it is not right. Same view served both ways on
11.13, three rows behind each:

  option      database read   read microflow
  $select     applied         APPLIED - Mendix projects the response either way
  $filter     applied         200, unfiltered
  $orderby    applied         200, unsorted
  $top/$skip  applied         200, full set
  $count      applied         needs System.ODataResponse (CE6962)

Two consequences the all-or-nothing version obscures:

  - $select is not the microflow's correctness problem. The client already
    receives only the fields it asked for, and the CONSUMER drives it:
    removing attributes from an external entity narrows the $select it
    sends, because the external entity has nowhere to put what it dropped.
    So pushing $select into the source query is a cost optimisation — fewer
    columns read at the source — never a fix for wrong output. Worth knowing
    before writing code for it.
  - Declaring the capability is what turns a safe refusal into a silent lie.
    Without Filterable/Sortable, Mendix rejects the request outright: 400
    "Property 'Category' is non-filterable." Declare them — which you must,
    or no client can filter at all — and the identical request answers 200
    with every row.

That second one is the sharpest statement of why the pushdown work exists:
the failure is created BY promising the capability, and the microflow is the
only place left to keep the promise.

Three commits recovered here rather than stacked on merged history: they
were pushed to the #157 branch after that PR had already been merged, so
they never reached main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
$select is the one OData option worth adding here and the one whose value
is easy to overstate, so the code says which it is. Mendix applies $select
to the response itself — measured on 11.13, on a microflow-backed resource
as much as on a database read — so the client already receives only the
fields it asked for whatever the microflow does. Pushing it down saves
READING columns nobody looks at: real time over a wide CSV through a
columnar reader, nothing over a narrow table. The consumer drives it, since
an external entity with attributes removed sends a narrower $select — it
has nowhere to put what it dropped.

Result gains selectSql (" a, b, c", spliced after SELECT) and
selectedColumns (the same as exposed names, for binding callers), carried
onto the Query entity as SelectSql / SelectedColumns.

Three decisions, none of them obvious:

  - An unknown column is REJECTED, not skipped. sortTerms ignores what it
    cannot place because a wrong order is cosmetic; a dropped projection is
    not — answering with a null where data was expected is the same "200
    and wrong" this component exists to prevent.
  - The key is always projected, even when $select omits it. One column,
    and it stops a caller that dedupes, associates or re-reads by key from
    losing the value it does that with. The client still sees only what it
    asked for.
  - Sort columns are NOT forced in. ORDER BY may name a column the SELECT
    list omits, which is ordinary SQL, and adding them would defeat the
    narrowing.

$expand stays unsupported and is rejected rather than ignored. It is a
different kind of work — not a projection but a join producing a nested
object graph the microflow would have to build as associated objects, with
nested options multiplying the surface. Written down next to $search,
$apply and the lambda operators so the boundary is a decision rather than
an omission.

Also fixes a bug the module rewrite exists to prevent and did not catch:
QueryObject declared ENTITY = "ODataPushdown.Query" as a literal, in a file
whose package line is tokenised. Installing with --module Warehouse gave
`package warehouse;` alongside an instantiate of ODataPushdown.Query — a
class that compiles and finds nothing. Now {{MODULE}}.Query, verified by
installing under a different module name.

scripts/ParserCheck.java is the verify: the proposal asks for and this pack
wanted most. The parser takes no Mendix types, so a dialect or grammar
regression is checkable in a second where every other test of it needs an
app, a database and a request. It covers the projection, the filter
grammar's unquoting of numeric columns (the combo-box-vs-grid-header case),
the sort terms, the MaxTop clamp, $count, and that an unreadable filter is
rejected. Confirmed to fail: disabling the key-inclusion rule exits 1 and
prints the diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
@ako
ako merged commit 5510716 into mendixlabs:main Aug 17, 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