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
3 changes: 3 additions & 0 deletions .claude/skills/fix-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ cases for these three BSON types — they fell to `default: return nil`.
| An unquoted negative number in an XPath constraint fails to parse: `where [Amount > -7]` → `Parse error: extraneous input '7' expecting {',', ')'}`. Reported as "negative numeric literals truncate (`-7` becomes `-`)" | `xpathWord` — the name-part rule inside XPath — is a **negated token set** that did not exclude `MINUS`, so the sign was consumed as a name word and the digits were left stranded (hence the truncation appearance). The lexer deliberately keeps `-` out of `NUMBER_LITERAL` (a leading sign there mis-tokenises `$x -2`), leaving negation to the parser; the general grammar has `unaryExpression` for this and the XPath grammar simply never got the equivalent | `mdl/grammar/domains/MDLPage.g4` (`xpathValueExpr` gains `MINUS xpathValueExpr`; `MINUS` added to the `xpathWord` exclusion set), `mdl/visitor/visitor_xpath.go` (`buildXPathValueExpr`), `mdl/visitor/visitor_page_v3.go` (`xpathExprToString` emits `-7`, not `- 7`) | **The grammar fix alone is worse than the bug.** With the parser accepting `-7` but the XPath AST builder having no case for the new alternative, the constraint parses and silently serializes to `[Amount > ]` — a dropped operand instead of a loud parse error. Caught only because the visitor has a round-trip helper; the microflow write path uses `GetText()` and looked fine. **When adding a grammar alternative, check every consumer of that rule, not just the one your repro exercises.** **Scope correction**: the finding's own example (`addDays([%CurrentDateTime%], -7)`) still fails — `addDays` is a *microflow expression* function, not an XPath one, and it fails `CE0161` with a POSITIVE argument too, so the sign was never its problem. Repro `mdl-examples/bug-tests/it-18-xpath-negative-literal.mdl`; A/B on Mendix 11.12.1: pre-fix the script does not parse, fixed binary writes it and `mx check` reports 0 errors. issuetracker #18 |

| Text painted by an **Atlas topbar widget is invisible in a dark theme** — the language selector measures ~1.13:1 contrast, glyph pixels spanning 4 luminance values out of 255. A theme override exists and *names the right element*, so it looks handled | Two separate mistakes stacked. (1) **Specificity**: Atlas's own rule is `.navbar-brand .widget-language-selector .current-language-text` at (0,3,0); a bare `.current-language-text` at (0,1,0) never wins, and only appears to on layouts that do not nest the selector under `.navbar-brand`. (2) **Wrong value**: `color: inherit` inherits *body ink*, which is dark, while the rail is dark in both palettes — so even at the winning specificity it measures 1.00:1. Atlas paints from `--bg-color-secondary` with a `#fff` fallback because it assumes a dark rail | `cmd/mxcli/theme/assets/*/files/theme/web/_mxcli-atlas-map.scss` (the "Atlas fixups" block) | Re-declare **Atlas's own selector shape** so the weights match and source order decides, and resolve the colour through the rail token (`var(--mxt-rail-ink-active, var(--mxt-rail-ink))`) rather than `inherit`. List the bare and the `.navbar-brand`-nested selectors together — each is matched at its own specificity, so one rule covers both layouts. **Generalisable — the shape to look for**: a guard that names the right element is not evidence it applies. Read the *winning* declaration (`CSS.getMatchedStylesForNode` in DevTools, or the computed value) instead of the one you wrote. **Measure contrast, not colour**: reading `getComputedStyle(el).color` once and seeing a plausible value proves nothing — compute the WCAG ratio against the first non-transparent ancestor background, which is what turns "looks fine" into 1.13 vs 19.47. Reported from the RssReader test build; tests in `cmd/mxcli/theme/theme_test.go`, verified in a browser at 17.79:1 light / 19.47:1 dark |
| A loop's variable used after `end loop;` passes `mxcli check`, then `mx check` fails `[error] [CE0108] "Variable 'item' is defined but not in scope at this location."` at the referencing activity. Applies to the **iterator** and to anything the body introduces (a `retrieve`, a `$X = create …`, a call output) | Nothing tracked loop-variable *visibility*. MDL052 already covered the sibling rule — names are unique across the whole microflow (CE0111) — and the wording of that rule ("scoped to the WHOLE microflow") reads as if the variable is readable flow-wide. Uniqueness and visibility are different: the name is reserved everywhere, readable only inside the loop body | `mdl/executor/validate_microflow_loop_scope.go` (new `MDL053`, wired from `microflowValidator.validate`), skill `.claude/skills/mendix/write-microflows.md` | Map each loop-scoped name to the loop whose **own** body introduces it (nested loops keep their own names), then walk the flow with the set of enclosing loops and flag any reference from outside the owner. **A name claimed by two loops is dropped, not reported** — that is the MDL052/CE0111 case, and without the guard the MDL052 negative example started failing for the wrong reason: the first loop's own use of `$R` was blamed on the second loop's claim. **Generalisable**: a rule keyed by variable *name* needs an ambiguity escape hatch whenever another rule exists precisely because names can collide. Both flavours verified against mxbuild 11.12.1 (2 × CE0108 in one probe). Repro `mdl-examples/bug-tests/loop-variable-out-of-scope.fail.mdl`; tests `mdl/executor/validate_microflow_loop_scope_test.go`. Found while working the sudoku findings, but **not** one of them — the numbered finding it was filed under is an app bug in that project, not an mxcli defect |
| `mxcli oql` silently omits a whole column: a `select A, B …` renders only `A`, and the JSON output has no `B` key at all — no error, no empty column. Reproduces whenever the value of `B` is **null in the first row** | The runtime omits a null-valued column from a row's JSON object entirely, and `parseOQLFeedback` took the column list from `extractColumnOrder(rows[0])` — one row, chosen for its key *order*. Every later row was then projected onto that short list, so the column vanished from the result rather than showing NULLs | `cmd/mxcli/docker/oql.go` (`parseOQLFeedback`, `mergeColumnOrder`, `hasOnlyKnownKeys`) | Union the keys of **all** rows, inserting each new key directly after the last key already known rather than appending — merging `[A, C]` with `[A, B, C]` must give `[A, B, C]`, not `[A, C, B]`, or a column that is null early in the result set jumps to the end of the table. The per-row re-scan is skipped when a row carries no unseen key, so the uniform case still costs one length check. **Generalisable**: any "take the shape from the first element" over a sparse encoding is a silent-wrong-answer bug, not a formatting bug — the output looks complete. Tests `TestParseOQLFeedback_ColumnUnionAcrossRows`, `TestMergeColumnOrder`; proven by stubbing the union back to first-row-only and watching the reported symptom return. sudoku #39, first half |
| `mxcli test` cannot run at all in a container without a Docker daemon — parsing, runner generation, model injection and the **entire mxbuild build** succeed natively, then `docker up` fails with "failed to connect to the docker API at unix:///var/run/docker.sock". So microflow tests are unavailable in exactly the environment mxcli targets (Claude Code web containers ship `/usr/bin/docker` with no daemon) | Only one step of the run was containerised — start the runtime and read its log — but it was wired directly to `docker compose`, with no seam for another way to run the app. `run --local` had all three pieces already (boot a standalone runtime, tee its log, restore) | `cmd/mxcli/docker/localapp.go` (new `StartLocalApp`), `cmd/mxcli/testrunner/runner_local.go` (new), `cmd/mxcli/testrunner/runner.go` (docker path extracted to `runDockerAndCapture`), `cmd/mxcli/cmd_test_run.go` + `main.go` (`--local`) | Give the run a seam — `runLocalAndCapture` / `runDockerAndCapture` — so both modes share parse/inject/parse-results/cleanup and differ only in how the app is started. **Two traps, both found by running it rather than reasoning about it.** (a) The runner reports via an **after-startup** microflow, so its LOG output happens DURING the start action, before the runtime's log subscriber attaches; registering the subscriber early is not possible (the runtime answers `LoggingException` pre-start). What actually carries the output is the JVM console tee, live from spawn — verified by A/B running with the early attach removed. (b) A **failing** test makes the runner return false, which makes the after-startup action fail, which makes `start` return an error — the first version reported that as a broken run and dumped a stack trace instead of the test report. A failed boot whose log shows a verdict is a normal outcome. Local runs use their own ports (8081/8091) and a `<project>_test` database so a `run --local` dev loop can keep serving. Verified end-to-end in a daemon-less container: 2 passed; then 1 passed / 1 failed with exit 1, project restored. sudoku #41 |

**Key insight:** `microflows$ListRange` stores offset/limit inside a nested
`CustomRange` map — must cast `raw["CustomRange"].(map[string]any)` before
Expand Down
20 changes: 17 additions & 3 deletions .claude/skills/mendix/test-microflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,22 @@ For **UI/page testing** (widget rendering, form interactions, browser tests), se
## Prerequisites

- Mendix project with microflows to test
- Docker stack initialized: `mxcli docker init -p app.mpr`
- App buildable: `mxcli docker build -p app.mpr`
- A way to run the app — **either** of:
- `--local` (no Docker): mxcli boots the runtime itself, the same way
`mxcli run --local` does. This is the only option in a container without a
Docker daemon, which includes Claude Code web sessions.
- Docker: stack initialized (`mxcli docker init -p app.mpr`) and the app
buildable (`mxcli docker build -p app.mpr`).

```bash
mxcli test tests/ -p app.mpr --local # no daemon needed
mxcli test tests/ -p app.mpr # Docker
```

`--local` uses its own ports (app 8081, admin 8091) and its own
`<project>_test` database, so a `mxcli run --local` dev loop can keep serving the
same project while the tests run — the tests never write into the database you
are looking at in the browser. The database is created on first use.

---

Expand Down Expand Up @@ -114,7 +128,7 @@ The test runner uses the **after-startup microflow** pattern:
module already exists
3. Generates a `MxTest.TestRunner` microflow with assertion logic and points
after-startup at it
4. Builds the project and restarts the Docker runtime
4. Builds the project and restarts the runtime (Docker, or local with `--local`)
5. Captures structured `MXTEST:` log lines for pass/fail
6. Restores the original after-startup setting and removes the generated runner —
the whole `MxTest` module when the runner created it, otherwise just the
Expand Down
27 changes: 27 additions & 0 deletions .claude/skills/mendix/write-microflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,33 @@ end loop;
- The loop variable type is **automatically derived** from the list type (e.g., `list of Test.Product` → `Test.Product`)
- CHANGE statements inside loops use the derived type to resolve attribute names

> **Nothing a loop defines survives past `end loop;`.** The iterator *and*
> anything the body creates (a `retrieve`, a `$X = create …`, a call output) are
> visible only inside the body; using one afterwards is
> `CE0108 "Variable 'X' is defined but not in scope at this location."`
> (`mxcli check` flags it as **MDL053**).
>
> ```mdl
> -- WRONG: $Last is created inside the loop, read outside it
> loop $Item in $Items
> begin
> $Last = create Test.Product (Name = $Item/Name);
> end loop;
> commit $Last; -- MDL053 / CE0108
>
> -- RIGHT: declare before the loop, assign inside, read after
> declare $LastName string = '';
> loop $Item in $Items
> begin
> set $LastName = $Item/Name;
> end loop;
> log info node 'Test' $LastName;
> ```
>
> Visibility and *naming* are separate rules: names must also be unique across
> the **whole** microflow, so two loops cannot share an iterator name either
> (`CE0111`, flagged as **MDL052**).

### Performance: Batch Commit After Loop

**CRITICAL**: Do NOT commit inside a loop. Each `commit` inside a loop issues a separate database transaction, which causes N round-trips for N records and degrades performance significantly.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ go build -o bin/mxcli ./cmd/mxcli
| **Full-text search** | `search 'keyword'` | Search across all strings and source |
| **Linting** | `mxcli lint -p app.mpr [--format json\|sarif]` | 15 built-in rules + 27 Starlark rules (MDL, SEC, QUAL, ARCH, DESIGN, CONV) |
| **Report** | `mxcli report -p app.mpr [--format markdown\|json\|html]` | Scored best practices report with category breakdown |
| **Testing** | `mxcli test tests/ -p app.mpr` | `.test.mdl` / `.test.md` files, requires Docker |
| **Testing** | `mxcli test tests/ -p app.mpr [--local]` | `.test.mdl` / `.test.md` files; `--local` runs on mxcli's own runtime (no Docker daemon), on its own ports + `<project>_test` database |
| **Diff** | `mxcli diff -p app.mpr changes.mdl` | Compare script against project state |
| **Diff local** | `mxcli diff-local -p app.mpr --ref head` | Git diff for MPR v2 projects |
| **Diff revisions** | `mxcli diff-local -p app.mpr --ref main..feature` | Compare two arbitrary git revisions |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ mxcli add-tool cursor
- `.ai-context/examples/` - Example MDL scripts

**Tool-Specific:**
- **Claude Code**: `.claude/settings.json`, `CLAUDE.md`, commands, lint-rules, skills
- **Claude Code**: `.claude/settings.json`, `CLAUDE.md`, commands, lint-rules, `lint-config.yaml` (System module excluded from lint), skills
- **Cursor**: `.cursorrules` - Compact MDL reference
- **Continue.dev**: `.continue/config.json` - Custom commands and slash commands
- **Windsurf**: `.windsurfrules` - MDL rules for Codeium
Expand Down
50 changes: 50 additions & 0 deletions cmd/mxcli/cmd_lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/mendixlabs/mxcli/mdl/linter"
"github.com/mendixlabs/mxcli/mdl/linter/rules"
Expand Down Expand Up @@ -186,6 +187,20 @@ Examples:
if len(cfg.ExcludeModules) > 0 {
merged := append(excludeModules, cfg.ExcludeModules...)
ctx.SetExcludedModules(merged)
// An exclude always wins over --modules (LintContext.IsExcluded
// checks the exclude set first), so asking for a module the
// config excludes yields zero findings with no explanation.
// `mxcli init` now ships a config excluding System, which makes
// `lint -m System` exactly that trap — say so rather than
// returning a silent empty result.
if shadowed := intersect(moduleFilter, cfg.ExcludeModules); len(shadowed) > 0 {
fmt.Fprintf(os.Stderr,
"Warning: --modules names %s, but %s excluded by %s — no findings will be reported for %s. Remove it from excludeModules to lint it.\n",
strings.Join(shadowed, ", "),
pluralIsAre(len(shadowed)),
configPath,
pluralItThem(len(shadowed)))
}
}
cfg.ApplyConfig(lint)
} else {
Expand Down Expand Up @@ -252,3 +267,38 @@ func catalogRefreshCommand(mode linter.CatalogMode) string {
return "REFRESH CATALOG"
}
}

// intersect returns the values of want that appear in have, preserving want's
// order and dropping duplicates.
func intersect(want, have []string) []string {
if len(want) == 0 || len(have) == 0 {
return nil
}
inHave := make(map[string]bool, len(have))
for _, h := range have {
inHave[h] = true
}
seen := make(map[string]bool, len(want))
var out []string
for _, w := range want {
if inHave[w] && !seen[w] {
seen[w] = true
out = append(out, w)
}
}
return out
}

func pluralIsAre(n int) string {
if n == 1 {
return "it is"
}
return "they are"
}

func pluralItThem(n int) string {
if n == 1 {
return "it"
}
return "them"
}
12 changes: 11 additions & 1 deletion cmd/mxcli/cmd_test_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@ The test runner:
1. Parses test files and extracts test blocks with @test/@expect annotations
2. Generates a TestRunner microflow
3. Injects it into the project as after-startup microflow
4. Builds and restarts the Mendix runtime in Docker
4. Builds and restarts the Mendix runtime (Docker, or --local)
5. Captures structured log output to determine pass/fail
6. Restores original project settings

With --local the app runs on mxcli's own runtime instead of a container — the
same boot as 'mxcli run --local', so no Docker daemon is needed. It uses its own
ports (8081/8091) and its own '<project>_test' database, so a warm 'run --local'
loop can keep serving the same project while tests run.

Supports two file formats:
.test.mdl — Pure MDL test blocks separated by /
.test.md — Markdown specification with embedded mdl-test code blocks
Expand All @@ -52,6 +57,9 @@ Examples:
# List tests without executing
mxcli test tests/ -p app.mpr --list

# Run without Docker, on mxcli's own local runtime
mxcli test tests/ -p app.mpr --local

# Skip build (reuse existing deployment)
mxcli test tests/ -p app.mpr --skip-build

Expand All @@ -64,6 +72,7 @@ Examples:
list, _ := cmd.Flags().GetBool("list")
junitOutput, _ := cmd.Flags().GetString("junit")
skipBuild, _ := cmd.Flags().GetBool("skip-build")
local, _ := cmd.Flags().GetBool("local")
verbose, _ := cmd.Flags().GetBool("verbose")
color, _ := cmd.Flags().GetBool("color")
timeoutStr, _ := cmd.Flags().GetString("timeout")
Expand Down Expand Up @@ -93,6 +102,7 @@ Examples:
ProjectPath: projectPath,
TestFiles: args,
SkipBuild: skipBuild,
Local: local,
Timeout: timeout,
JUnitOutput: junitOutput,
Verbose: verbose,
Expand Down
Loading