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
1 change: 1 addition & 0 deletions .claude/skills/fix-issue/findings/cmd-mxcli.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,4 @@
{"area": "cmd/mxcli", "date": "2026-08-30", "raw": "| A widget keyword the grammar accepts is absent from `mxcli syntax page widgets`, so it is concluded not to exist and worked around at length (reported for `tabcontainer`, which cost two days and five hand-rolled pages) | `cmd/mxcli/syntax/features_page.go`, `cmd/mxcli/syntax/widget_keywords_drift_test.go` | The lesson the reporter drew — \"absence from the documentation is not absence from the grammar\" — is true and is a bad thing for the docs to require. `TestEveryWidgetKeywordIsInAPageSyntaxTopic` makes it false instead: it reads the `widgetTypeV3` rule out of the **committed** `.g4` (only the *generated parser* is uncommitted, and the grammar is the authority the reporter was told to consult) and fails when a keyword appears in no `page.*` topic. It found **18**, not one. Exemptions go in `documentedElsewhere` **with the topic that owns them** — layout constructs (`scrollcontainer`, `region`, `navigationtree`, `menubar`, `placeholder`) and pluggable-widget object-list keywords (`group`, `series`, `marker`, …) are not page widgets; an entry with no home is the same defect. The guard carries its own vacuity control: a keyword that does not exist must not match, and one that does must. **Do not document a keyword without running it** — probing all 18 on 11.13 found four the parser accepts and the *default engine refuses* (`statictext`, `staticimage`, `dynamicimage`, `dropdown` → \"widget *pages.X not yet supported by the modelsdk engine\"), two refused on both engines (`referenceselector`, `legacydatagrid`), and one whose bare form emits **CE0463** (`image`). Reported as mxcli-formula1 FINDINGS §69 |", "ce": ["CE0463"]}
{"area": "cmd/mxcli", "date": "2026-08-31", "symptom": "Every open PR goes red at once on `build-and-test` with a failure in a package none of them touched — `--- FAIL: TestSessionLog_PersistAndPrune`, \"after reload+prune: 0 records, want 1\" — and the same test fails on a clean checkout of `main`", "cause": "A **time bomb in the test**, not a regression: the fixture pinned `base := time.Date(2026, 8, 1, ...)` against a 30-day retention window, and `NewSessionLogFile` prunes inside `load()` — *before* the test can assign `log2.now`, so the reload prune runs on the real `time.Now()` whatever clock is injected afterwards. It passed for 30 days and then failed permanently, on every branch simultaneously", "file": "`cmd/mxcli/tunnelhub/sessions_test.go` (`TestSessionLog_PersistAndPrune`), `cmd/mxcli/tunnelhub/sessions.go` (`load` → `pruneLocked` → `clock`)", "insight": "**First establish it is not yours**: run the failing test on a clean `origin/main`. Several PRs failing on one unrelated test is the signature. Then make the fixture relative — `base := time.Now().UTC()` — so the record ages, not the calendar, decide the outcome; the other tests in the file keep their fixed base legitimately, because they use `NewSessionLog` and inject the clock before recording. **A date fixture is only safe where no code path reads the real clock**; the moment a constructor prunes, expires or compares against `time.Now()` before the seam is in place, an absolute date has a fuse on it. Control the repair: stub `pruneLocked` to a no-op and confirm the test still fails (2 records, want 1), or the fix is just a test that stopped testing"}
{"area": "cmd/mxcli", "date": "2026-08-31", "symptom": "`mxcli test --local` cannot start a runtime for ANY project: `Error: local runtime: runtime admin API did not come up: runtime process exited during startup`, then `java.lang.IllegalArgumentException: Path '<project>/.mxcli/deployment-test/model/bundles' cannot be resolved in base path '<project>/.mxcli/deployment-test'`. The tree has `data/` and no `model/`", "cause": "**mxbuild writes the deployment to `<app dir>/deployment` and has no option to move it** \u2014 measured, not inferred: `--target=deploy` on a project whose `deployment/` had just been deleted recreated it there, `mxbuild --help` lists no deployment-path flag, and `BuildRequest` carries none. So giving the test boot a `DeployDir` of its own moved where the RUNTIME reads and not where the BUILD writes, and it booted against an empty directory. `StartLocalApp` now refuses a `DeployDir` the build will not populate, naming mxbuild as the constraint. The \u00a762 blanking the scratch tree was meant to prevent is fixed the only way the constraint allows: `preserveWebClientBundle` copies `deployment/web/dist` aside before the boot and puts it back after (a few MB of copy, against the ~30s re-bundle that made warning the earlier choice), restoring only when the bundle is actually gone so a fresher one is never clobbered.", "file": "`cmd/mxcli/docker/localapp.go` (`checkDeployDirIsBuildable`, the `preserveWebClientBundle` call), `cmd/mxcli/docker/webclient_preserve.go` (new), `cmd/mxcli/docker/localapp_integration_test.go` (new), `cmd/mxcli/testrunner/localapp_options.go`, `cmd/mxcli/testrunner/runner_local.go`, `cmd/mxcli/testrunner/runner.go`", "insight": "**The lesson is in the tests that did not catch it**, and two reporting projects drew it independently (mxcli-ledger \u00a7150, mxcli-sudoku \u00a751): four unit tests asserted `DeployDir` was set, was under `.mxcli/`, was per-project and was not the dev loop's \u2014 all four passed against a build that could not start, because every one was about the OPTION while the symptom lived in what is on disk after mxbuild runs. A fix that redirects a consumer without redirecting the producer moves the failure rather than removing it. The guard is now an integration test asserting the artefact: after a real build, the directory the runtime boots against holds `model/bundles`. Note the reporters' prescribed fix \u2014 thread `DeployDir` through the build \u2014 is not available; mxbuild has no such parameter, which is why the shared tree is accepted and the bundle carried across instead. Controls, both end-to-end on a real 11.13 project: the pre-fix binary reproduces the reported JVM error byte for byte while the fixed one passes; and with `preserveWebClientBundle` stubbed the sentinel bundle is destroyed by a test run that still reports all tests passed. Reinstating the regression fails the new integration test naming both missing paths."}
{"area": "cmd/mxcli", "date": "2026-09-01", "symptom": "After `mxcli test --local`, a live `mxcli run --local` serving the SAME project starts answering **HTTP 200 with a zero-byte body** on every microflow-backed resource \u2014 not a 500, not an error page \u2014 while source-backed ones keep working, so half the app looks fine. The runtime log shows `java.lang.NoClassDefFoundError` on a project class. In a two-app solution it surfaces as tests failing in the OTHER app.", "cause": "The test run recompiles the project's Java into `deployment/run/bin`, which is the classpath the running JVM is holding open. Measured on a real 11.13 project: after one test run all 134 class files have **new inodes and byte-identical content** \u2014 every one deleted and rewritten. A JVM loads classes lazily, so one it has not reached yet can fail permanently. mxcli cannot prevent this: mxbuild's Gradle pass owns the compile and the deployment directory cannot be moved (ledger \u00a7150). So it warns instead, which is what was missing.", "file": "`cmd/mxcli/devloop_recompile_warning.go` (new \u2014 `warnIfDevLoopServing`, `recompileWarning`), `cmd/mxcli/cmd_test_run.go`; reads the existing `cmd/mxcli/devloop_handshake.go`", "insight": "**The mechanism already existed and a duplicate would have broken it.** `mxcli run --local` publishes `devLoopHandshake` at `.mxcli/run-local.json` for `mxcli constant set --apply` \u2014 same path, same pid-liveness staleness check, plus the `adminPass` and `bootConfig` that `--apply` and `--attach` depend on. A second state file was written at that path before this was noticed; it parsed fine (JSON ignores unknown fields) but its WRITER would have silently dropped those two keys. Grep the path before inventing a file. **The liveness check is the feature**: a `run --local` killed or ended by its development licence (\u00a760, measured lifetimes under six hours) leaves the file behind, and a warning driven by the file alone fires forever \u2014 one that is always wrong teaches the reader to skip it. It **warns rather than refuses**, since the warm loop exists so an app can stay up while you work and the reporting project runs two apps that way; neither `--attach` nor `--skip-build` builds, so neither warns. The finding's cost was diagnosis, not breakage \u2014 108 log lines and a wrong hypothesis about a different app, for something whose remedy is one restart \u2014 so the warning names the symptom (HTTP 200, empty body), the part nobody guesses. Controls, end-to-end against a real `run --local`: the warning carries that app's actual pid and port and its handshake still has adminPass and 9 bootConfig keys afterwards; with the loop stopped, and with a stale dead-pid handshake, the same command is silent. Reported as mxcli-formula1 FINDINGS \u00a781."}
20 changes: 16 additions & 4 deletions .claude/skills/mendix/test-microflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,22 @@ tests passed and nothing was reported at either end. The bundle is now copied
aside before the boot and put back after, so the dev loop keeps the exact bundle
it built.

Two things still to know about running both at once: the test boot loads and
unloads the model in the same tree, and both runtimes share `deployment/data/`.
Neither has been measured to cause damage; if you see something odd, run them one
at a time and say so.
**The compiled Java is shared too, and that one is not fixable — only reportable.**
A test run recompiles the project into `deployment/run/bin`, the classpath the
running app's JVM is holding open. Measured: every class file is rewritten (new
inode, identical content). A JVM loads classes lazily, so one the app has not
reached yet can afterwards fail with `NoClassDefFoundError` — and the microflows
behind it then answer **HTTP 200 with an empty body** rather than an error, so the
app looks half-working. Only Java-backed resources are affected, which is why it
does not look like the test run did it.

mxcli warns when it sees a dev loop serving the same project (`run --local`
records itself in `.mxcli/run-local.json`, removed on exit). **If something the
app serves stops returning data after a test run, restart that app.** That is the
whole remedy.

One thing left that has not been measured: both runtimes share `deployment/data/`.
No damage observed; if you see something odd, run them one at a time and say so.

### Constants

Expand Down
8 changes: 8 additions & 0 deletions cmd/mxcli/cmd_test_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ Examples:
os.Exit(1)
}

// A --local run that BUILDS recompiles the project's Java into the
// classpath a concurrent `mxcli run --local` is holding open, which can
// leave that app answering HTTP 200 with an empty body (mxcli-formula1
// §81). Neither --attach nor --skip-build builds, so neither can cause it.
if local && !attach && !skipBuild {
warnIfDevLoopServing(projectPath, os.Stdout)
}

result, err := testrunner.Run(opts)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down
63 changes: 63 additions & 0 deletions cmd/mxcli/devloop_recompile_warning.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"fmt"
"io"
)

// warnIfDevLoopServing tells a `mxcli test --local` run that the dev loop is
// serving the same project, and reports whether it did.
//
// A test run recompiles the project's Java into `deployment/run/bin`, which is
// the classpath that running app's JVM is holding open. mxcli cannot avoid it:
// mxbuild's Gradle pass owns the compile, and the deployment directory cannot be
// moved — mxbuild writes it to `<app dir>/deployment` and takes no option to
// change it (mxcli-ledger §150). So the collision is reported rather than
// prevented, which is the part that was missing: the reporting project spent 108
// log lines and a wrong hypothesis about a different app before connecting the
// two (mxcli-formula1 §81).
//
// It reads the handshake `mxcli run --local` already publishes for `mxcli
// constant set --apply`, rather than a second file of its own: that one carries
// the admin password and boot config those commands depend on, and a competing
// writer at the same path would drop them.
//
// It warns rather than refuses. The warm loop exists so an app can stay up while
// you work on it — the reporting project runs two apps that way as a matter of
// course — and refusing would break the workflow the feature is for, to prevent
// something whose remedy is one restart.
func warnIfDevLoopServing(projectPath string, w io.Writer) bool {
if w == nil {
return false
}
// A missing file, a corrupt one, and a pid that is gone all come back as an
// error here, which is exactly the behaviour wanted: a stale handshake from a
// `run --local` that was killed or whose development licence expired (§60)
// must not warn, or the warning fires forever and stops being read.
hs, err := readDevLoopHandshake(projectPath)
if err != nil {
return false
}
fmt.Fprintln(w, recompileWarning(hs))
return true
}

// recompileWarning names the symptom, because the symptom is the part nobody
// guesses: the app does not error, it answers 200 with nothing in it, and only
// the resources backed by Java are affected — so half the app keeps working,
// which is not a shape that suggests "the test run did this".
func recompileWarning(hs devLoopHandshake) string {
return fmt.Sprintf(
"Warning: `mxcli run --local` is serving this project on port %d (pid %d).\n"+
" This test run recompiles the project's Java into deployment/run/bin, which is\n"+
" that app's classpath — every class file is rewritten. A class the running app\n"+
" has not loaded yet can then fail with NoClassDefFoundError, and the microflows\n"+
" behind it answer HTTP 200 with an EMPTY BODY rather than an error, so the app\n"+
" looks half-working (mxcli-formula1 §81).\n"+
" If anything it serves stops returning data, restart that app.\n"+
" The deployment directory cannot be separated: mxbuild always writes it to\n"+
" <app dir>/deployment.",
hs.AppPort, hs.PID)
}
105 changes: 105 additions & 0 deletions cmd/mxcli/devloop_recompile_warning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"os"
"strings"
"testing"
"time"
)

// mxcli-formula1 FINDINGS §81 — the half of the shared deployment tree the
// web-bundle fix does not cover.
//
// A local test run recompiles the project's Java into `deployment/run/bin`,
// which is the classpath a live `mxcli run --local` is holding open. Measured on
// a real 11.13 project: after one `mxcli test --local`, all 134 class files have
// NEW INODES and byte-identical content — every one deleted and rewritten. A JVM
// loads classes lazily, so one it has not reached yet can fail afterwards:
//
// java.lang.NoClassDefFoundError: odatapushdown/QueryObject
//
// What that costs is diagnosis, not the breakage. The microflows behind it then
// answer **HTTP 200 with a zero-byte body** — not a 500, not an error page —
// while source-backed resources keep working, so half the app is fine and half
// returns nothing. In the reporting project it surfaced as 21 of 34 tests
// failing in a DIFFERENT app, and 108 log lines went by before anyone connected
// it to the test run.
//
// mxcli cannot prevent the rewrite: mxbuild's Gradle pass owns the compile, and
// the deployment directory cannot be moved (mxcli-ledger §150). It can say so,
// to someone who can act on it.
//
// The fact needed — is a dev loop serving this project? — was ALREADY published:
// `mxcli run --local` writes devLoopHandshake to .mxcli/run-local.json for
// `mxcli constant set --apply`, with the pid liveness check and the
// project-identity field this needs. A second state file was written before that
// was noticed, and it would have CLOBBERED this one, dropping the admin password
// and boot config that `--apply` and `--attach` depend on.

func TestRecompileWarning_SaysWhatWillHappenAndWhatToDo(t *testing.T) {
msg := recompileWarning(devLoopHandshake{PID: 4242, AppPort: 8080})

for _, want := range []string{"8080", "recompil", "restart"} {
if !strings.Contains(strings.ToLower(msg), want) {
t.Errorf("the warning should mention %q: %s", want, msg)
}
}
// The symptom is the part nobody guesses, because it does not look like a
// failure at all.
if !strings.Contains(msg, "200") {
t.Errorf("the warning should name the symptom (HTTP 200, empty body): %s", msg)
}
}

func TestWarnIfDevLoopServing_FiresForALiveDevLoop(t *testing.T) {
p := handshakeProject(t)
if err := writeDevLoopHandshake(p, devLoopHandshake{
Project: p, PID: os.Getpid(), AppPort: 8080, AdminPort: 8090, Started: time.Now(),
}); err != nil {
t.Fatal(err)
}

var buf strings.Builder
if !warnIfDevLoopServing(p, &buf) {
t.Fatal("a live dev loop on the same project must be reported")
}
if !strings.Contains(buf.String(), "8080") {
t.Errorf("the warning did not reach the writer: %q", buf.String())
}
}

// CONTROL 1: silent when nothing is running. Every project that has never used
// the warm loop runs its tests through this path, and a warning there is noise
// on every run.
func TestWarnIfDevLoopServing_SilentWithNoDevLoop(t *testing.T) {
var buf strings.Builder
if warnIfDevLoopServing(handshakeProject(t), &buf) {
t.Error("warned with no dev loop running")
}
if buf.String() != "" {
t.Errorf("wrote %q when there was nothing to say", buf.String())
}
}

// CONTROL 2 — the one that decides whether this is usable at all. A `run --local`
// that was killed, crashed, or died with its development licence (§60, measured
// lifetimes under six hours) leaves the file behind. Warning on that would fire
// forever, and a warning that is always wrong teaches the reader to skip it.
//
// readDevLoopHandshake already refuses a dead pid; this pins that the warning
// inherits it rather than reading the file itself.
func TestWarnIfDevLoopServing_SilentForAStaleHandshake(t *testing.T) {
p := handshakeProject(t)
if err := writeDevLoopHandshake(p, devLoopHandshake{
Project: p, PID: 999999, AppPort: 8080,
}); err != nil {
t.Fatal(err)
}

var buf strings.Builder
if warnIfDevLoopServing(p, &buf) {
t.Errorf("warned about a dev loop whose process is gone: %q", buf.String())
}
}
Loading