Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: '1.26.5'
go-version: '1.26.6'

- name: Cache ANTLR4 JAR
uses: actions/cache@v6
Expand Down Expand Up @@ -58,7 +58,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: '1.26.5'
go-version: '1.26.6'

- uses: oven-sh/setup-bun@v2

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/push-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.26.5'
go-version: '1.26.6'
- name: Cache ANTLR4 JAR
uses: actions/cache@v6
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version: '1.26.5'
go-version: '1.26.6'
- uses: oven-sh/setup-bun@v2
- name: Cache ANTLR4 JAR
uses: actions/cache@v6
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed

- **Go toolchain 1.26.5 → 1.26.6** for GO-2026-6218 (`net/url`), GO-2026-6090 (`crypto/tls`), GO-2026-6089 (`net/http`), GO-2026-6088 (`encoding/xml`), GO-2026-5972 (`encoding/asn1`) and GO-2026-5026 (`net/http`, via `golang.org/x/net/idna`). All six are standard-library advisories fixed in go1.26.6; no mxcli code changed. Bumped in `go.mod` and in all three workflows (`push-test`, `release`, `nightly`) together, so released binaries are not still linked against the vulnerable standard library.

## [0.17.0] - 2026-08-10

Headline: **A full Mendix build-and-test loop that fits on an iPad** — you can now design, build, run, observe, and debug a multi-app Mendix solution end-to-end from Claude Code on the web, on a phone or tablet, with no local IDE. Two capabilities make it possible: an **external browser preview** that reverse-tunnels a locally-running app out to a public URL from an egress-only container, and a **short agentic feedback loop** — a warm Docker-free runtime, sub-second microflow unit tests, live log/metric/trace observation, and a name-based microflow debugger — so an agent gets an answer in seconds instead of a build round-trip.
Expand Down
1 change: 1 addition & 0 deletions cmd/mxcli/lsp_completions_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

188 changes: 188 additions & 0 deletions docs/11-proposals/PROPOSAL_container_autosize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
---
title: Size containers from their contents, not from a statement count
status: draft
---

# Proposal: Size containers from their contents, not from a statement count

Closes the last open item of upstream [#884](https://github.com/mendixlabs/mxcli/issues/884) —
problem 1, "container `Size` is unrelated to the positions of the activities inside it".

## Problem

A `LoopedActivity` is the only microflow element mxcli sizes rather than stamping
with a constant. Its `Size` is computed **before** its body is built, from a
pre-pass over the AST that counts statements. The nested builder that actually
places the children runs afterwards, by which time the box is frozen — so child
positions, including explicit `@position` overrides, have **no** influence on the
box that is supposed to contain them.

### Measured

Four microflows, one project, blank Mendix 11.6.6 app, mxcli `bcc3406`
(`mdl-examples/bug-tests/container-autosize-884.mdl`; `LOOP` body varied,
everything else identical):

| | Body | Child centres (X) | Loop `Size` |
|---|---|---|---|
| **A** | 2 activities, default placement | 150, 310 | `480;160` |
| **B** | same 2, `@position(1500,60)` / `@position(2000,60)` | 1500, 2000 | `480;160` |
| **C** | same 2, `@position(160,60)` / `@position(170,60)` | 160, 170 | `480;160` |
| **D** | 4 activities, default placement | 150 … 630 | `800;160` |

A, B and C are the same box around contents spanning 160px, 500px and 10px.
D differs from A only in statement count, and only D changes the box. Width is
exactly `(n-1)·HorizontalSpacing + ActivityWidth + 2·LoopPadding + iteratorSpace`
— a function of `n` alone.

**B is a correctness problem, not a cosmetic one.** The box interior spans
x ∈ [0, 480]; both children sit at 1500 and 2000, entirely outside their own
container. `mx check` reports the *same* error count before and after the script
runs — validation does not model geometry, so nothing catches it. It is visible
only by opening the flow in Studio Pro.

This is what the reporter observed as "identical child positions, different
`Size`" — the two quantities are simply not connected.

### Where it comes from

`measureStatementsSpan` (`mdl/executor/layout.go:92`) takes
`[]ast.MicroflowStatement` and returns `(count-1)*HorizontalSpacing + ActivityWidth`.
It is called at exactly two places, both of which size a container:

- `mdl/executor/cmd_microflows_builder_control.go:572` — `addLoopStatement`
- `mdl/executor/cmd_microflows_builder_control.go:896` — `addWhileStatement`

Neither has any child object at that point. `loopBuilder` — the nested
`flowBuilder` whose `objects` carry the real positions — is constructed about
twenty lines later.

The comment on `measureStatementsSpan` is honest about the half-fix it already
is (#790): the count-based span was introduced to *stop over-sizing* boxes, and
it falls back to the older `measureStatements` for compound bodies precisely
because it "cannot reproduce the builder's geometry without duplicating it".
That is the tell — the pre-pass is trying to predict a computation that has not
run yet.

## Why this is not a one-line change

The current ordering contains a cycle that the obvious fix walks straight into:

```go
loopHeight := max(bodyBounds.Height+2*LoopPadding, MinLoopHeight) // needs body
innerStartY := loopHeight / 2 // body needs this
loopLeftX := fb.posX
loopCenterX := loopLeftX + loopWidth/2 // needs width
```

Children are placed relative to an inner origin derived **from the size**, and
under the fix the size must be derived **from the children**. Anything that
computes the size from real positions has to break that dependency first, which
is why this is a layout-engine change rather than a patch.

## Proposal

**Build first, size after, translate once.**

1. **Build the body at a provisional origin** — run `loopBuilder` from `(0, 0)`.
Nothing in the body placement genuinely needs the box; it needs *an* origin.
2. **Take the real bounding box** from `loopBuilder.objects`
(`Position ± Size/2` over every object, recursively for nested containers).
This is ground truth, and it accounts for `@position` for free — no annotation
plumbing required.
3. **Size the box** as bbox + `LoopPadding` on all sides, plus `iteratorSpace` on
the left for `LOOP`, floored at `MinLoopWidth` / `MinLoopHeight`.
4. **Translate the body once** — a single pass over `loopBuilder.objects` adding
the delta between the provisional origin and the final inner origin, mutating
`Position` only.

Step 4 is what keeps this safe. No builder call site learns about sizing; the
translation is a pure post-pass over objects that already exist. That is the
same single-choke-point shape used for `@curve` (`applyFlowCurves`) and `@merge`
(`mergePosition`) — chosen for the same reason: a rule threaded through N
placement sites fails silently at the one site that was missed.

### Nested containers

The bbox must be computed bottom-up: an inner loop has to be sized before the
outer loop measures it. This already falls out of the recursion — `addStatement`
sizes the inner `LoopedActivity` before it is appended to
`loopBuilder.objects` — but it becomes load-bearing under this change and should
be asserted by a test with two nesting levels, not left to hold by accident.

### Semantics of `@position` inside a container

Two readings, and they must be decided explicitly rather than emerging from the
implementation:

- **(a) container-relative, translated with everything else** — the box grows to
contain the annotated child, and the child keeps its position *relative to the
body*.
- **(b) authoritative, exempt from translation** — the annotated child stays put
and unannotated siblings move around it.

**Recommend (a).** Positions inside a `LoopedActivity` are already stored
relative to the container, so (a) matches the storage model; (b) would leave two
adjacent children obeying different origins, which is unexplainable in a doc and
unpredictable in a diff. Whichever is chosen, it belongs in
`.claude/skills/mendix/` alongside the `@position` reference.

## Non-goals

- **Splits.** `IF` / enum split / inheritance split have no `Size` property in
Mendix storage — there is no box, so there is nothing to fix. `measureStatements`
stays: it is still needed to reserve horizontal room for branches *before* they
are laid out, which is a genuinely predictive use.
- **Re-flowing a body to fit a box.** Out of scope. The box follows the contents,
never the reverse.

## Alternative considered: an `@size(w, h)` escape hatch

The reporter asked for this directly, and it is far cheaper — one annotation, one
field, no layout change. It is rejected as the *primary* fix for two reasons: it
makes the author responsible for a number the engine can compute, and it leaves
the default behaviour wrong for everyone who does not use it.

Worth revisiting **after** the derived size lands, if a real case wants a box
deliberately larger than its contents. Note the coupling introduced by the #884
annotation work: `@size` is currently **rejected** by MDL059, so adding it means
adding it to `knownActivityAnnotations` in `mdl/executor/validate_microflow.go`.
The drift test compares the visitor's case labels against that list in both
directions and fails if only one side changes — which is the intent.

## Risk

**Every generated flow containing a `LOOP` or `WHILE` changes geometry.** That is
unavoidable for a fix of this shape, and has two consequences worth stating in
the release note:

- Fixtures and golden BSON comparisons churn once, in a single commit.
- Under idempotent writes (ADR-0008), the first re-run of any existing script
against an existing project **writes** the affected microflows; subsequent runs
go quiet again. Users will see one round of version-control changes they did
not author.

Neither is a reason not to do it, but a silent geometry shift across an existing
project is exactly the kind of thing that gets reported as a new bug.

## Verification

- **Regression test from the table above.** Cases A–D as a builder-level test
asserting the invariant directly: *every child's bounding box lies inside its
parent's*. That is the property; the specific pixel values are not.
- **Nesting test** — loop inside a loop, both boxes containing their contents.
- **`@position` test** — case B: the box must grow to contain a child pushed to
x=2000, not leave it outside.
- **Control run.** Per the standing rule, the test must be shown to fail against
a pre-fix binary with the reported symptom — a same-size box around different
contents — and not merely pass against fixed code.
- **`mx check`** on every fixture, both engines.

### Candidate lint rule: MPR009

The measurement above shows `mx check` is blind to this. A rule
*"every child of a `LoopedActivity` lies within its parent's box"* would have
caught the entire class from the outside, and would keep catching it if a future
layout change reintroduces it. It sits naturally beside MPR008 (which, after the
#884 work, already partitions objects by canvas and therefore has the container
geometry in hand). Proposed as a follow-up, not part of this change.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module github.com/mendixlabs/mxcli

go 1.26.0

toolchain go1.26.5
toolchain go1.26.6

require (
github.com/alecthomas/chroma/v2 v2.26.1
Expand Down
59 changes: 59 additions & 0 deletions mdl-examples/bug-tests/container-autosize-884.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- upstream #884 problem 1: a LoopedActivity's Size is a function of statement
-- count alone -- child positions, including explicit @position, have no effect.
--
-- Measured on Mendix 11.6.6 (mxcli bcc3406): A, B and C all produce Size
-- 480;160 while their contents span 160px, 500px and 10px respectively. In B
-- both children sit outside the box (interior x in [0,480], children at 1500
-- and 2000) and `mx check` reports no additional error.
--
-- See docs/11-proposals/PROPOSAL_container_autosize.md

CREATE ENTITY MyFirstModule.SzItem (
Name: String(100)
);

-- A: default child placement
CREATE MICROFLOW MyFirstModule.MF_SizeA ()
BEGIN
RETRIEVE $Items FROM MyFirstModule.SzItem;
LOOP $Item IN $Items BEGIN
LOG INFO 'one';
LOG INFO 'two';
END LOOP;
END;

-- B: identical body, children pushed far outside the box
CREATE MICROFLOW MyFirstModule.MF_SizeB ()
BEGIN
RETRIEVE $Items FROM MyFirstModule.SzItem;
LOOP $Item IN $Items BEGIN
@position(1500, 60)
LOG INFO 'one';
@position(2000, 60)
LOG INFO 'two';
END LOOP;
END;

-- C: identical body, children pulled tightly together
CREATE MICROFLOW MyFirstModule.MF_SizeC ()
BEGIN
RETRIEVE $Items FROM MyFirstModule.SzItem;
LOOP $Item IN $Items BEGIN
@position(160, 60)
LOG INFO 'one';
@position(170, 60)
LOG INFO 'two';
END LOOP;
END;

-- D: control -- only the statement count differs from A, and only D resizes
CREATE MICROFLOW MyFirstModule.MF_SizeD ()
BEGIN
RETRIEVE $Items FROM MyFirstModule.SzItem;
LOOP $Item IN $Items BEGIN
LOG INFO 'one';
LOG INFO 'two';
LOG INFO 'three';
LOG INFO 'four';
END LOOP;
END;
Loading