From f86b8b093cf275284d70793c5d5727dddfc4bf83 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 14:39:52 +0000 Subject: [PATCH 01/18] fix(catalog): save the cache atomically so parallel processes do not race (#951) SaveToFile now writes to a temp file in the cache's directory (VACUUM INTO, manual-copy fallback into the same temp file) and renames it over the cache. buildCatalog and refresh catalog communities no longer remove the cache first. Opening a cache at the current schema version no longer writes to it: a write on a file renamed underneath an open connection fails with SQLITE_READONLY_DBMOVED. File-backed connections get a busy_timeout. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/mdl-other.jsonl | 1 + CHANGELOG.md | 1 + mdl/catalog/catalog.go | 55 ++++++- mdl/catalog/catalog_save_race_test.go | 139 ++++++++++++++++++ mdl/catalog/catalogdb_sqlite.go | 17 ++- mdl/executor/cmd_catalog.go | 6 +- 6 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 mdl/catalog/catalog_save_race_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other.jsonl b/.claude/skills/fix-issue/findings/mdl-other.jsonl index 261f84b4f3..bc8bfa9023 100644 --- a/.claude/skills/fix-issue/findings/mdl-other.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-other.jsonl @@ -90,3 +90,4 @@ {"area": "mdl/catalog", "date": "2026-10-03", "symptom": "`delete $Order` / `change $Order (…)` inside `loop $Order in $Orders` produce no refs row (no delete/change edge to the entity), while `delete $Orders` on the list does; same for the output of `retrieve $Cust from $Order/Mod.Assoc`. show references / impact under-report batch flows.", "cause": "buildVarEntityMap seeded only object/list parameters and create / database-retrieve outputs, from a flattened action list that had already lost the loop's IterableList; loop iterators and association-retrieve outputs never got an entity, so microflowVarActionRef could not resolve them.", "file": "`mdl/catalog/builder_references.go` (buildVarEntityMap, associationTarget, associationEnds)", "insight": "Walk the object collection, not the flattened actions: the iterator's type lives on the LoopedActivity. Objects is not flow order, so map to a fixpoint (first assignment wins, which also bounds it). An association retrieve's output is the OTHER end from the start variable's entity; when the start is neither end (a specialization) leave it unmapped rather than guess. Control in the test: the list delete that always resolved.", "refs": ["mendixlabs/mxcli#1266"]} {"area": "mdl/linter", "date": "2026-10-03", "symptom": "lint CONV013 reports \"Java action call ... uses '' error handling instead of Custom\" on calls that have `on error { \u2026 }`, and CONV014 never fires on `on error continue` on an action", "cause": "Both rules read BaseActivity.ErrorHandlingType, which the model reader never fills: Mendix stores an action activity's error handling on the ACTION (Microflows$JavaActionCallAction.ErrorHandlingType). The '' in the message was the empty field", "file": "`mdl/linter/rules/conv_error_handling.go`; shared reader `sdk/microflows/error_handling.go` (`ObjectErrorHandlingType`)", "insight": "The unit tests had always set the activity field by hand, so they passed against a shape the reader never produces. Build test objects the way flowObjectFromGen does. Two private reflection helpers (executor DESCRIBE, MCP backend) already read the action correctly; the rules had a third, wrong copy. A '' interpolated into a diagnostic is the cheapest tell of a never-populated field", "refs": ["mendixlabs/mxcli#1202"], "rules": ["CONV013", "CONV014"]} {"area": "mdl/catalog", "date": "2026-10-03", "symptom": "activities table / activities_for() has no rows for anything inside a loop (nested loops included) in a microflow, nanoflow or rule; a Starlark rule cannot find a retrieve, commit or delete in a loop", "cause": "buildMicroflows had three near-copy loops (microflow, nanoflow, rule) over ObjectCollection.Objects that never recursed into LoopedActivity.ObjectCollection, although the reader fills it; countDecisionPoints beside them did recurse", "file": "`mdl/catalog/builder_microflows.go` (`insertFlowActivities`, `countFlowActivities`)", "insight": "Three copies of one walk is how the gap stayed in all three flavours. One shared walker writes ParentLoopId/LoopDepth; activities_for() keeps its top-level default (filtering ParentLoopId = '') so bundled rules such as CONV010 keep their counts, and ActivityCount keeps its top-level meaning beside a new TotalActivityCount. Raw SQL over activities now sees loop-body rows", "refs": ["mendixlabs/mxcli#1266"]} +{"area": "mdl/catalog", "date": "2026-10-03", "symptom": "Parallel mxcli processes on one project (8x `lint` on a fresh copy) print `Warning: failed to save catalog cache: failed to create table catalog_meta: table catalog_meta already exists` or `database is locked (SQLITE_BUSY)`; a reader can open a half-written .mxcli/catalog.db", "cause": "buildCatalog removed the cache and SaveToFile wrote into the path in place: VACUUM INTO refuses a non-empty target another process had just created, and the manual-copy fallback then CREATE TABLEd into that same file. Opening a cache (NewFromFile) also always wrote (createTables + schema_version row), so concurrent openers contended for the write lock", "file": "`mdl/catalog/catalog.go` (`SaveToFile`, `NewFromFile`), `mdl/catalog/catalogdb_sqlite.go`, `mdl/executor/cmd_catalog.go` (buildCatalog save)", "insight": "Write to a temp file in the same directory and os.Rename it over the cache \u2014 but a rename alone moves the failure to readers: SQLite refuses a write on a file renamed out from under an open connection (SQLITE_READONLY_DBMOVED, 'attempt to write a readonly database', 1032). So opening a cache at the current schema version must not write at all; the busy_timeout in the DSN covers the remaining writes of an old-version cache. An in-process test with 8 goroutine writers + 4 reader loops reproduces all three errors deterministically, no subprocesses needed", "refs": ["ako/mxcli#951"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index f78ba22a38..6e79ebef40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Parallel mxcli runs on one project no longer fail to save the catalog cache** (ako/mxcli#951) — eight parallel `lint` runs on a fresh copy printed `failed to create table catalog_meta: table catalog_meta already exists` or `database is locked`. The cache is now written to a temporary file next to it and renamed into place, so every run saves and a reader sees the old cache or the new one, never a half-written file; opening a current cache no longer writes to it. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". - **A required caption with no text in the default language is foreseen** (ako/mxcli#944) — making de_DE the default left a stock app's `Administration.Account_Overview` `tabPage2` (en_US only) empty in de_DE, and mxbuild refused it with CE4899 "Empty caption. [German, Germany]" while `check -p`, `lint` and `exec` said nothing. Measured on 11.14, the tab page caption is the one caption kind the build requires (page titles, buttons, labels, group boxes, column headers, menu items, enumeration captions and messages build without it; page templates and building blocks are not checked). Now: **lint rule QUAL006** (error) lists every tab page caption without the default language; **`alter settings language (DefaultLanguageCode: …)`** prints how many there are and where, with the `alter page … { set (Caption: …) on … }` that fixes one; **`check -p` reports MDL-I18N01** for a script that changes the default — stored captions, and captions the script wrote before the change. And a page created **after** the change in the same script is now written in the new default: the authoring language was resolved once per session, so it was still written in the old one and failed the build too. diff --git a/mdl/catalog/catalog.go b/mdl/catalog/catalog.go index 516de09513..b3bf74c30c 100644 --- a/mdl/catalog/catalog.go +++ b/mdl/catalog/catalog.go @@ -6,6 +6,8 @@ package catalog import ( "database/sql" "fmt" + "os" + "path/filepath" "strconv" "strings" "time" @@ -348,6 +350,17 @@ func NewFromFile(path string) (*Catalog, error) { return nil, fmt.Errorf("failed to migrate cached catalog: %w", err) } + // A cache already at the current schema version was saved from a catalog + // that ran createTables, so it is complete — open it without writing. Opening + // must stay read-only: a parallel process may rename a fresh cache over this + // path at any moment (SaveToFile is atomic, ako/mxcli#951), and SQLite refuses + // a write to a file that has been moved ("attempt to write a readonly + // database"), while concurrent openers writing the version row contend for + // the lock. + if stored, err := c.GetMeta(MetaSchemaVersion); err == nil && stored == CatalogSchemaVersion { + return c, nil + } + // Idempotent schema upgrade — adds any tables/indexes the cached file // doesn't have yet. Existing data is untouched (unless dropped above). // createTables also records the current schema version in catalog_meta. @@ -443,6 +456,15 @@ func (c *Catalog) migrateIfSchemaMismatch() error { // SaveToFile saves the catalog to a SQLite file. // This copies the in-memory database to a file for persistence. // Requires the underlying CatalogDB to be a *SqliteCatalogDB. +// +// The save is atomic: the database is written to a temporary file in the same +// directory and renamed over path. Parallel mxcli processes on one project all +// save the same cache (ako/mxcli#951: eight parallel `lint` runs), and writing in +// place let them collide — VACUUM INTO refused the file another process had just +// created, the manual fallback then failed with "table catalog_meta already +// exists" or "database is locked", and a reader could open a half-written file. +// With a rename every writer succeeds (the last one wins) and a reader sees the +// old cache or a complete new one, never a partial file. func (c *Catalog) SaveToFile(path string) error { sdb, ok := c.db.(*SqliteCatalogDB) if !ok { @@ -450,15 +472,38 @@ func (c *Catalog) SaveToFile(path string) error { } rawDB := sdb.RawDB() + // Same directory, so the rename cannot cross a filesystem boundary. + tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmp := tmpFile.Name() + tmpFile.Close() + // VACUUM INTO requires the target to be absent or empty; it is empty. + committed := false + defer func() { + if !committed { + os.Remove(tmp) + } + }() + // Use SQLite backup API via VACUUM INTO (SQLite 3.27+) // Fall back to manual copy if not available - safePath := strings.ReplaceAll(path, "'", "''") - _, err := rawDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", safePath)) - if err != nil { - // Fall back: export and import - return c.saveToFileManual(path, rawDB) + safePath := strings.ReplaceAll(tmp, "'", "''") + if _, err := rawDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", safePath)); err != nil { + // Fall back: export and import, into a fresh empty temp file. + if err := os.Truncate(tmp, 0); err != nil { + return err + } + if err := c.saveToFileManual(tmp, rawDB); err != nil { + return err + } } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace catalog cache %s: %w", path, err) + } + committed = true return nil } diff --git a/mdl/catalog/catalog_save_race_test.go b/mdl/catalog/catalog_save_race_test.go new file mode 100644 index 0000000000..32262e43de --- /dev/null +++ b/mdl/catalog/catalog_save_race_test.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// TestSaveToFile_ConcurrentWritersAndReaders is the guard for ako/mxcli#951 +// item 2. Eight parallel `mxcli lint` runs on a fresh project copy all built the +// catalog and saved it to the same .mxcli/catalog.db. The save removed the file +// and wrote into the path in place: VACUUM INTO refused a file another process +// had just created, the manual-copy fallback then hit "table catalog_meta already +// exists" or "database is locked", and a reader could open a half-written file. +// +// The save must be atomic: every writer succeeds, and a reader sees either the +// old cache or a complete new one — never a partial file. +func TestSaveToFile_ConcurrentWritersAndReaders(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "catalog.db") + + newCat := func(t *testing.T, mode string) *Catalog { + t.Helper() + cat, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + if err := cat.SetCacheInfo("/p/app.mpr", time.Unix(1700000000, 0), "11.8.0", mode, time.Second); err != nil { + t.Fatalf("SetCacheInfo: %v", err) + } + return cat + } + + // An existing cache is the common case (the project changed, so every + // process rebuilds and overwrites it). + seed := newCat(t, "fast") + if err := seed.SaveToFile(path); err != nil { + t.Fatalf("seed SaveToFile: %v", err) + } + seed.Close() + + const writers = 8 + cats := make([]*Catalog, writers) + for i := range cats { + cats[i] = newCat(t, "full") + } + defer func() { + for _, c := range cats { + c.Close() + } + }() + + stop := make(chan struct{}) + var readerErrs []error + var mu sync.Mutex + var readers sync.WaitGroup + for r := 0; r < 4; r++ { + readers.Add(1) + go func() { + defer readers.Done() + for { + select { + case <-stop: + return + default: + } + c, err := NewFromFile(path) + if err != nil { + mu.Lock() + readerErrs = append(readerErrs, err) + mu.Unlock() + continue + } + info, err := c.GetCacheInfo() + c.Close() + if err == nil && info.BuildMode != "fast" && info.BuildMode != "full" { + err = fmt.Errorf("reader saw build mode %q (partial cache)", info.BuildMode) + } + if err != nil { + mu.Lock() + readerErrs = append(readerErrs, err) + mu.Unlock() + } + } + }() + } + + start := make(chan struct{}) + errs := make(chan error, writers) + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func(c *Catalog) { + defer wg.Done() + <-start + errs <- c.SaveToFile(path) + }(cats[i]) + } + close(start) + wg.Wait() + close(stop) + readers.Wait() + close(errs) + + for err := range errs { + if err != nil { + t.Errorf("concurrent SaveToFile: %v", err) + } + } + for _, err := range readerErrs { + t.Errorf("concurrent reader: %v", err) + } + + final, err := NewFromFile(path) + if err != nil { + t.Fatalf("final cache does not open: %v", err) + } + defer final.Close() + info, err := final.GetCacheInfo() + if err != nil { + t.Fatalf("final cache info: %v", err) + } + if info.BuildMode != "full" { + t.Errorf("final cache build mode = %q, want full", info.BuildMode) + } + + // No temporary files may be left behind next to the cache. + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if n := e.Name(); n != "catalog.db" && n != "catalog.db-journal" && n != "catalog.db-wal" && n != "catalog.db-shm" { + t.Errorf("leftover file next to the cache: %s", n) + } + } +} diff --git a/mdl/catalog/catalogdb_sqlite.go b/mdl/catalog/catalogdb_sqlite.go index 0da6e43bf2..cb7ec87d6e 100644 --- a/mdl/catalog/catalogdb_sqlite.go +++ b/mdl/catalog/catalogdb_sqlite.go @@ -6,6 +6,8 @@ package catalog import ( "database/sql" + "fmt" + "strings" _ "modernc.org/sqlite" ) @@ -28,9 +30,22 @@ func NewSqliteCatalogDB() (*SqliteCatalogDB, error) { return &SqliteCatalogDB{db: db}, nil } +// fileBusyTimeoutMs is how long a connection to an on-disk catalog waits for a +// lock held by another process before failing with SQLITE_BUSY. Opening a cache +// writes to it (schema upgrade, schema version), so parallel mxcli processes on +// one project contend for the write lock for a moment (ako/mxcli#951). +const fileBusyTimeoutMs = 10000 + // NewSqliteCatalogDBFromFile opens a file-based SQLite database. func NewSqliteCatalogDBFromFile(path string) (*SqliteCatalogDB, error) { - db, err := sql.Open("sqlite", path) + dsn := path + // The busy timeout goes in the DSN so it applies to every pooled connection, + // not only the one a PRAGMA statement happens to run on. A '?' in the path + // would be read as the start of the query string; keep the bare path then. + if !strings.Contains(path, "?") { + dsn = fmt.Sprintf("%s?_pragma=busy_timeout(%d)", path, fileBusyTimeoutMs) + } + db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } diff --git a/mdl/executor/cmd_catalog.go b/mdl/executor/cmd_catalog.go index 0415297b3f..9141297481 100644 --- a/mdl/executor/cmd_catalog.go +++ b/mdl/executor/cmd_catalog.go @@ -578,8 +578,9 @@ func buildCatalog(ctx *ExecContext, full, isSource, communities bool, resolution } cacheDir := filepath.Dir(cachePath) if err := os.MkdirAll(cacheDir, 0755); err == nil { - // Remove existing cache file first - os.Remove(cachePath) + // SaveToFile replaces the cache atomically. Do not remove it first: + // that opened a window in which a parallel process found no cache, + // and the in-place write that followed raced it (ako/mxcli#951). if err := cat.SaveToFile(cachePath); err != nil { fmt.Fprintf(ctx.progress(), "Warning: failed to save catalog cache: %v\n", err) } else { @@ -655,7 +656,6 @@ func execRefreshCatalogStmt(ctx *ExecContext, stmt *ast.RefreshCatalogStmt) erro return mdlerrors.NewBackend("graph analysis", err) } if !loadedFromCache && cachePath != "" { - os.Remove(cachePath) if err := ctx.Catalog.SaveToFile(cachePath); err != nil { fmt.Fprintf(ctx.Output, "Warning: failed to save catalog cache: %v\n", err) } From 54997cabbc396bb3fbae5b63d61f3f2a0775d05b Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 14:51:58 +0000 Subject: [PATCH 02/18] fix(lint): keep failing rules out of the report score (#952) A Starlark rule reading a struct field this mxcli does not expose is now reported at info level as "rule needs a newer mxcli ()"; other rule failures stay errors. Every rule failure is marked RuleFailure, kept out of BuildReport's score, summary and categories, and listed in its own "Rules That Could Not Run" section (ruleFailures in JSON). A configured rule severity no longer applies to the rule's own failure, and a rule file failing to load on an undefined name hints at a newer mxcli. Part of #952. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/mdl-other.jsonl | 1 + CHANGELOG.md | 1 + docs-site/src/tools/mxcli-lint.md | 12 ++ docs-site/src/tools/mxcli-report.md | 13 ++ mdl/linter/linter.go | 13 +- mdl/linter/report.go | 28 ++- mdl/linter/report_format.go | 35 ++++ mdl/linter/starlark.go | 54 ++++- mdl/linter/starlark_rule_failure_test.go | 189 ++++++++++++++++++ 9 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 mdl/linter/starlark_rule_failure_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other.jsonl b/.claude/skills/fix-issue/findings/mdl-other.jsonl index 261f84b4f3..939cfa2ef8 100644 --- a/.claude/skills/fix-issue/findings/mdl-other.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-other.jsonl @@ -90,3 +90,4 @@ {"area": "mdl/catalog", "date": "2026-10-03", "symptom": "`delete $Order` / `change $Order (…)` inside `loop $Order in $Orders` produce no refs row (no delete/change edge to the entity), while `delete $Orders` on the list does; same for the output of `retrieve $Cust from $Order/Mod.Assoc`. show references / impact under-report batch flows.", "cause": "buildVarEntityMap seeded only object/list parameters and create / database-retrieve outputs, from a flattened action list that had already lost the loop's IterableList; loop iterators and association-retrieve outputs never got an entity, so microflowVarActionRef could not resolve them.", "file": "`mdl/catalog/builder_references.go` (buildVarEntityMap, associationTarget, associationEnds)", "insight": "Walk the object collection, not the flattened actions: the iterator's type lives on the LoopedActivity. Objects is not flow order, so map to a fixpoint (first assignment wins, which also bounds it). An association retrieve's output is the OTHER end from the start variable's entity; when the start is neither end (a specialization) leave it unmapped rather than guess. Control in the test: the list delete that always resolved.", "refs": ["mendixlabs/mxcli#1266"]} {"area": "mdl/linter", "date": "2026-10-03", "symptom": "lint CONV013 reports \"Java action call ... uses '' error handling instead of Custom\" on calls that have `on error { \u2026 }`, and CONV014 never fires on `on error continue` on an action", "cause": "Both rules read BaseActivity.ErrorHandlingType, which the model reader never fills: Mendix stores an action activity's error handling on the ACTION (Microflows$JavaActionCallAction.ErrorHandlingType). The '' in the message was the empty field", "file": "`mdl/linter/rules/conv_error_handling.go`; shared reader `sdk/microflows/error_handling.go` (`ObjectErrorHandlingType`)", "insight": "The unit tests had always set the activity field by hand, so they passed against a shape the reader never produces. Build test objects the way flowObjectFromGen does. Two private reflection helpers (executor DESCRIBE, MCP backend) already read the action correctly; the rules had a third, wrong copy. A '' interpolated into a diagnostic is the cheapest tell of a never-populated field", "refs": ["mendixlabs/mxcli#1202"], "rules": ["CONV013", "CONV014"]} {"area": "mdl/catalog", "date": "2026-10-03", "symptom": "activities table / activities_for() has no rows for anything inside a loop (nested loops included) in a microflow, nanoflow or rule; a Starlark rule cannot find a retrieve, commit or delete in a loop", "cause": "buildMicroflows had three near-copy loops (microflow, nanoflow, rule) over ObjectCollection.Objects that never recursed into LoopedActivity.ObjectCollection, although the reader fills it; countDecisionPoints beside them did recurse", "file": "`mdl/catalog/builder_microflows.go` (`insertFlowActivities`, `countFlowActivities`)", "insight": "Three copies of one walk is how the gap stayed in all three flavours. One shared walker writes ParentLoopId/LoopDepth; activities_for() keeps its top-level default (filtering ParentLoopId = '') so bundled rules such as CONV010 keep their counts, and ActivityCount keeps its top-level meaning beside a new TotalActivityCount. Raw SQL over activities now sees loop-body rows", "refs": ["mendixlabs/mxcli#1266"]} +{"area": "mdl/linter", "date": "2026-10-03", "symptom": "mxcli report scores a project lower for lint rules that crash: under v0.24.0, project rules written by a newer mxcli (QUAL004, CUSTOM002 reading .document_noun_title) each produced an error-severity 'Starlark rule error: \"microflow\" struct has no .document_noun_title attribute' that counted 10 points against the project", "cause": "StarlarkRule.Check turned every evaluation error into an ordinary SeverityError violation, indistinguishable from a finding; BuildReport and Summarize counted it, and a configured rule severity was applied to it too", "fix": "ruleFailureViolation marks every failure Violation.RuleFailure; a missing struct attribute (matched on the evaluator message, since starlark flattens NoSuchAttrError via fmt.Errorf) becomes info 'rule needs a newer mxcli ()'; BuildReport splits RuleFailures out before counting and every report format lists them separately; Linter.Run skips the severity override for them; an 'undefined:' load failure gets a newer-mxcli hint", "insight": "The score measures the project, so anything about the tooling has to be partitioned out BEFORE counting, not filtered in the formatter. The control that makes the score assertion meaningful is a working rule's finding that does move the score", "issue": "ako/mxcli#952", "file": "mdl/linter/starlark.go (ruleFailureViolation); mdl/linter/report.go (BuildReport); mdl/linter/linter.go (Run); mdl/linter/report_format.go", "test": "mdl/linter/starlark_rule_failure_test.go"} diff --git a/CHANGELOG.md b/CHANGELOG.md index f78ba22a38..8e230058e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A lint rule that fails no longer costs the project score** (ako/mxcli#952) — a Starlark rule reading a struct field this mxcli does not expose (a rule written for a newer mxcli, such as one using `document_noun_title` under v0.24.0) is reported at info level as `rule needs a newer mxcli ()` instead of an error. All rule failures are kept out of `mxcli report`'s score, summary and categories and listed in their own "Rules That Could Not Run" section (`ruleFailures` in JSON); other failures stay `Starlark rule error` errors in `mxcli lint`. A configured rule severity no longer applies to the rule's own failure. A rule file that fails to load on an undefined name says the rule may need a newer mxcli. Measured on PedApp with v0.24.0 and rules from main: QUAL004 and CUSTOM002 crashed and scored as 2 errors. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". - **A required caption with no text in the default language is foreseen** (ako/mxcli#944) — making de_DE the default left a stock app's `Administration.Account_Overview` `tabPage2` (en_US only) empty in de_DE, and mxbuild refused it with CE4899 "Empty caption. [German, Germany]" while `check -p`, `lint` and `exec` said nothing. Measured on 11.14, the tab page caption is the one caption kind the build requires (page titles, buttons, labels, group boxes, column headers, menu items, enumeration captions and messages build without it; page templates and building blocks are not checked). Now: **lint rule QUAL006** (error) lists every tab page caption without the default language; **`alter settings language (DefaultLanguageCode: …)`** prints how many there are and where, with the `alter page … { set (Caption: …) on … }` that fixes one; **`check -p` reports MDL-I18N01** for a script that changes the default — stored captions, and captions the script wrote before the change. And a page created **after** the change in the same script is now written in the new default: the authoring language was resolved once per session, so it was still written in the old one and failed the build too. diff --git a/docs-site/src/tools/mxcli-lint.md b/docs-site/src/tools/mxcli-lint.md index 51cc2af09d..474b504fa5 100644 --- a/docs-site/src/tools/mxcli-lint.md +++ b/docs-site/src/tools/mxcli-lint.md @@ -89,6 +89,18 @@ mxcli lint -p app.mpr --format json | jq 'group_by(.severity) | map({severity: . mxcli lint -p app.mpr --format json | jq '[.[] | select(.severity == "error")]' ``` +## Rules That Fail + +A Starlark rule that reads a struct field this mxcli does not expose — usually a +rule written for a newer mxcli — is reported at **info** level as +`rule needs a newer mxcli ()`, not as an error. Any other rule +failure is an error (`Starlark rule error: …`). A rule's configured severity in +`lint-config.yaml` applies to its findings, never to its failure. `mxcli report` +keeps all rule failures out of the score. + +A rule file that does not load at all (for example, one calling a builtin this +mxcli lacks) is skipped with a warning on stderr. + ## Exit Codes | Code | Meaning | diff --git a/docs-site/src/tools/mxcli-report.md b/docs-site/src/tools/mxcli-report.md index ef06f1e059..8269a11f79 100644 --- a/docs-site/src/tools/mxcli-report.md +++ b/docs-site/src/tools/mxcli-report.md @@ -52,6 +52,19 @@ Each category shows: - Number of findings in that category - Specific rule violations with affected elements +## Rules That Could Not Run + +A lint rule that fails is a problem with the tooling, not the project, so it is +**not counted** in the score, the summary or any category. The report lists it +in its own section ("Rules That Could Not Run"; `ruleFailures` in JSON). + +The common case is a rule written for a newer mxcli — one that reads a field +this binary's catalog does not expose. It is reported at info level as +`rule QUAL004 needs a newer mxcli ("microflow" struct has no .document_noun_title attribute)`. +Any other failure is reported as a `Starlark rule error`. If the project's +tooling was written by a newer mxcli, `mxcli` warns about it on every command; +see [Syncing with Updates](../ide/syncing.md#the-version-stamp). + ## Writing Reports to Files ```bash diff --git a/mdl/linter/linter.go b/mdl/linter/linter.go index 2e1d0b1f2d..0141a8a96f 100644 --- a/mdl/linter/linter.go +++ b/mdl/linter/linter.go @@ -56,6 +56,11 @@ type Violation struct { Message string Location Location Suggestion string + // RuleFailure marks a finding about the rule rather than the project: the + // rule itself failed to run. It says nothing about the model, so the + // report keeps it out of the score and lists it separately + // (ako/mxcli#952), and a configured severity override does not apply. + RuleFailure bool } // Location identifies where a violation occurred. @@ -167,10 +172,14 @@ func (l *Linter) Run(ctx context.Context) ([]Violation, error) { // Run the rule violations := rule.Check(l.ctx) - // Apply configured severity if different from default + // Apply configured severity if different from default. A rule + // failure keeps its own: configuring QUAL004 as "warning" says how + // much its findings matter, not how much its crashing does. if config, ok := l.configs[rule.ID()]; ok { for i := range violations { - violations[i].Severity = config.Severity + if !violations[i].RuleFailure { + violations[i].Severity = config.Severity + } } } diff --git a/mdl/linter/report.go b/mdl/linter/report.go index 4337046890..48e30860f7 100644 --- a/mdl/linter/report.go +++ b/mdl/linter/report.go @@ -15,6 +15,10 @@ type Report struct { Categories []CategoryScore `json:"categories"` Violations []Violation `json:"-"` Summary Summary `json:"summary"` + // RuleFailures are the rules that failed to run. They are about the + // tooling, not the project, so they are neither in Violations nor in any + // score or count — they are listed on their own (ako/mxcli#952). + RuleFailures []Violation `json:"-"` } // CategoryScore tracks the score for a lint category. @@ -100,12 +104,26 @@ var categoryWeight = map[string]float64{ } // BuildReport creates a Report from a list of violations. -func BuildReport(projectName, date string, violations []Violation) *Report { +// +// A rule failure (Violation.RuleFailure) is split out into RuleFailures before +// anything is counted: the score measures the project, and a rule that could +// not run — usually one written for a newer mxcli — measured nothing. Counted, +// three crashing rules cost a project 30 points of "errors" it did not have. +func BuildReport(projectName, date string, all []Violation) *Report { + var violations, failures []Violation + for _, v := range all { + if v.RuleFailure { + failures = append(failures, v) + } else { + violations = append(violations, v) + } + } report := &Report{ - ProjectName: projectName, - Date: date, - Violations: violations, - Summary: Summarize(violations), + ProjectName: projectName, + Date: date, + Violations: violations, + Summary: Summarize(violations), + RuleFailures: failures, } // Group violations by category diff --git a/mdl/linter/report_format.go b/mdl/linter/report_format.go index 989140f890..89c0483031 100644 --- a/mdl/linter/report_format.go +++ b/mdl/linter/report_format.go @@ -5,6 +5,7 @@ package linter import ( "encoding/json" "fmt" + "html" "io" "strings" ) @@ -86,6 +87,17 @@ func (f *MarkdownReportFormatter) FormatReport(report *Report, w io.Writer) erro fmt.Fprintln(w) } + if len(report.RuleFailures) > 0 { + fmt.Fprintf(w, "## Rules That Could Not Run\n\n") + fmt.Fprintf(w, "Not counted in the score or the summary: these are problems with the lint tooling, not the project.\n\n") + fmt.Fprintf(w, "| Rule | Severity | Problem |\n") + fmt.Fprintf(w, "|------|----------|---------|\n") + for _, v := range report.RuleFailures { + fmt.Fprintf(w, "| %s | %s | %s |\n", v.RuleID, v.Severity, v.Message) + } + fmt.Fprintln(w) + } + return nil } @@ -115,6 +127,9 @@ type JSONReport struct { Summary JSONSummary `json:"summary"` Categories []CategoryScore `json:"categories"` Violations []JSONViolation `json:"violations"` + // RuleFailures are rules that could not run; excluded from the score and + // the summary (ako/mxcli#952). + RuleFailures []JSONViolation `json:"ruleFailures,omitempty"` } // JSONSummary is the summary in JSON format. @@ -152,6 +167,15 @@ func (f *JSONReportFormatter) FormatReport(report *Report, w io.Writer) error { }) } + for _, v := range report.RuleFailures { + jr.RuleFailures = append(jr.RuleFailures, JSONViolation{ + RuleID: v.RuleID, + Severity: v.Severity.String(), + Message: v.Message, + Suggestion: v.Suggestion, + }) + } + encoder := json.NewEncoder(w) encoder.SetIndent("", " ") return encoder.Encode(jr) @@ -274,6 +298,17 @@ func (f *HTMLReportFormatter) FormatReport(report *Report, w io.Writer) error { fmt.Fprintf(w, "\n") } + if len(report.RuleFailures) > 0 { + fmt.Fprintf(w, "

Rules That Could Not Run

\n") + fmt.Fprintf(w, "

Not counted in the score or the summary: these are problems with the lint tooling, not the project.

\n\n") + fmt.Fprintf(w, "\n") + for _, v := range report.RuleFailures { + fmt.Fprintf(w, "\n", + html.EscapeString(v.RuleID), v.Severity, html.EscapeString(v.Message)) + } + fmt.Fprintf(w, "
RuleSeverityProblem
%s%s%s
\n") + } + fmt.Fprintf(w, "\n\n") return nil } diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index ee623f6b64..769aa7242e 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -3,6 +3,7 @@ package linter import ( + "errors" "fmt" "os" "path/filepath" @@ -69,17 +70,51 @@ func (r *StarlarkRule) Check(ctx *LintContext) []Violation { // Call the check function result, err := starlark.Call(thread, r.checkFn, nil, nil) if err != nil { - return []Violation{{ - RuleID: r.id, - Severity: SeverityError, - Message: fmt.Sprintf("Starlark rule error: %v", err), - }} + return []Violation{ruleFailureViolation(r.id, err)} } // Convert result to violations return r.convertViolations(result) } +// missingStructAttrRe matches the evaluator's message for reading a field a +// struct does not have — "entity struct has no .document_noun attribute", +// optionally followed by a "(did you mean .x?)" hint. The evaluator flattens +// starlark.NoSuchAttrError into a plain error, so the message is all there is. +var missingStructAttrRe = regexp.MustCompile(`(?:\S+ )?struct has no \.[A-Za-z_][A-Za-z0-9_]* attribute.*`) + +// ruleFailureViolation reports a rule that failed to run. +// +// A rule that reads a struct field this binary does not expose is almost +// always a rule written for a newer mxcli: the shipped rules gain fields with +// the catalog (document_noun arrived after v0.24.0, and QUAL004, CONV010 and +// CUSTOM002 all crashed on it). That is reported as what it is — an info line +// naming the cause — rather than as a project error. Any other failure is a +// broken rule and stays an error. Both are RuleFailure: neither says anything +// about the project, so neither may move its score (ako/mxcli#952). +func ruleFailureViolation(ruleID string, err error) Violation { + msg := err.Error() + var evalErr *starlark.EvalError + if errors.As(err, &evalErr) { + msg = evalErr.Msg // without the backtrace + } + if detail := missingStructAttrRe.FindString(msg); detail != "" { + return Violation{ + RuleID: ruleID, + Severity: SeverityInfo, + Message: fmt.Sprintf("rule %s needs a newer mxcli (%s)", ruleID, detail), + Suggestion: "Update mxcli to the version that wrote this project's lint rules; if the rule is your own, check the field name against the write-lint-rules skill.", + RuleFailure: true, + } + } + return Violation{ + RuleID: ruleID, + Severity: SeverityError, + Message: fmt.Sprintf("Starlark rule error: %v", err), + RuleFailure: true, + } +} + // convertViolations converts a Starlark list to Go violations. func (r *StarlarkRule) convertViolations(result starlark.Value) []Violation { var violations []Violation @@ -1422,7 +1457,14 @@ func LoadStarlarkRulesFromDir(dir string) ([]*StarlarkRule, []RuleLoadFailure, e path := filepath.Join(dir, entry.Name()) rule, err := LoadStarlarkRule(path) if err != nil { - failures = append(failures, RuleLoadFailure{Path: path, Reason: err.Error()}) + reason := err.Error() + // A name the resolver does not know is, in a rule that used to + // load, a builtin from a newer mxcli — say so, as the run-time + // counterpart in ruleFailureViolation does (ako/mxcli#952). + if strings.Contains(reason, ": undefined: ") { + reason += " (a builtin this mxcli does not have — the rule may need a newer mxcli)" + } + failures = append(failures, RuleLoadFailure{Path: path, Reason: reason}) continue } diff --git a/mdl/linter/starlark_rule_failure_test.go b/mdl/linter/starlark_rule_failure_test.go new file mode 100644 index 0000000000..ed65e2c290 --- /dev/null +++ b/mdl/linter/starlark_rule_failure_test.go @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +// ako/mxcli#952: a project's lint rules were written by a newer mxcli and read +// a struct field (document_noun) the binary on PATH did not expose. Three +// rules crashed, each crash was an error-severity finding, and the report +// scored the project 30 points lower for problems the project did not have. + +// newerMxcliRule reads a field no struct has — what a rule written for a newer +// mxcli looks like to an older one. +const newerMxcliRule = ` +RULE_ID = "NEWER001" +RULE_NAME = "Newer" +DESCRIPTION = "reads a field this mxcli does not expose" +CATEGORY = "Quality" +SEVERITY = "error" + +def check(): + v = violation(message = "x") + return [violation(message = v.document_noun_from_the_future)] +` + +// brokenRule fails for a reason that is not a missing field. +const brokenRule = ` +RULE_ID = "BROKEN001" +RULE_NAME = "Broken" +DESCRIPTION = "divides by zero" +CATEGORY = "Quality" +SEVERITY = "warning" + +def check(): + return [violation(message = str(1 // 0))] +` + +// findingRule is the control: a working rule whose finding must still count. +const findingRule = ` +RULE_ID = "QUAL001" +RULE_NAME = "Finding" +DESCRIPTION = "always finds one thing" +CATEGORY = "Quality" +SEVERITY = "warning" + +def check(): + return [violation(message = "a real finding")] +` + +func loadRuleSource(t *testing.T, name, src string) *StarlarkRule { + t.Helper() + dir := t.TempDir() + write(t, dir, name, src) + r, err := LoadStarlarkRule(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("LoadStarlarkRule(%s): %v", name, err) + } + return r +} + +func TestStarlarkRule_MissingFieldIsNewerMxcliInfo(t *testing.T) { + r := loadRuleSource(t, "newer.star", newerMxcliRule) + vs := r.Check(&LintContext{}) + if len(vs) != 1 { + t.Fatalf("got %d violations, want 1: %+v", len(vs), vs) + } + v := vs[0] + if !v.RuleFailure { + t.Error("not marked as a rule failure") + } + if v.Severity != SeverityInfo { + t.Errorf("severity = %s, want info", v.Severity) + } + if !strings.HasPrefix(v.Message, "rule NEWER001 needs a newer mxcli (") || + !strings.Contains(v.Message, ".document_noun_from_the_future") { + t.Errorf("message = %q", v.Message) + } + if strings.Contains(v.Message, "Traceback") { + t.Errorf("message carries the backtrace: %q", v.Message) + } +} + +func TestStarlarkRule_OtherFailureStaysError(t *testing.T) { + r := loadRuleSource(t, "broken.star", brokenRule) + vs := r.Check(&LintContext{}) + if len(vs) != 1 || !vs[0].RuleFailure || vs[0].Severity != SeverityError { + t.Fatalf("got %+v, want one error-severity rule failure", vs) + } + if !strings.HasPrefix(vs[0].Message, "Starlark rule error:") { + t.Errorf("message = %q", vs[0].Message) + } +} + +// A severity configured for a rule is about its findings; it must not turn +// the rule's own failure back into an error (or hide a real crash). +func TestLinterRun_SeverityOverrideSkipsRuleFailures(t *testing.T) { + l := New(&LintContext{}) + l.AddRule(loadRuleSource(t, "newer.star", newerMxcliRule)) + l.ConfigureRule("NEWER001", RuleConfig{Enabled: true, Severity: SeverityError}) + vs, err := l.Run(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(vs) != 1 || vs[0].Severity != SeverityInfo { + t.Errorf("got %+v, want the failure to stay info", vs) + } +} + +func TestBuildReport_RuleFailuresAreNotScored(t *testing.T) { + l := New(&LintContext{}) + for name, src := range map[string]string{"newer.star": newerMxcliRule, "broken.star": brokenRule, "finding.star": findingRule} { + l.AddRule(loadRuleSource(t, name, src)) + } + all, err := l.Run(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(all) != 3 { + t.Fatalf("got %d violations, want 3: %+v", len(all), all) + } + + report := BuildReport("Demo", "today", all) + + // The control finding still counts: one warning in Quality. + if report.Summary.Total != 1 || report.Summary.Warnings != 1 || report.Summary.Errors != 0 { + t.Errorf("summary = %+v, want only the control warning", report.Summary) + } + if len(report.Violations) != 1 || report.Violations[0].RuleID != "QUAL001" { + t.Errorf("violations = %+v", report.Violations) + } + if len(report.RuleFailures) != 2 { + t.Errorf("rule failures = %+v, want both failing rules", report.RuleFailures) + } + // The score is the control finding's alone. + control := BuildReport("Demo", "today", []Violation{report.Violations[0]}) + if report.OverallScore != control.OverallScore { + t.Errorf("score = %v, want %v (rule failures must not move it)", report.OverallScore, control.OverallScore) + } + if control.OverallScore == 100 { + t.Error("control finding did not move the score; the comparison proves nothing") + } + + // Every format lists the failures separately. + var md bytes.Buffer + if err := GetReportFormatter("markdown").FormatReport(report, &md); err != nil { + t.Fatal(err) + } + if !strings.Contains(md.String(), "## Rules That Could Not Run") || !strings.Contains(md.String(), "needs a newer mxcli") { + t.Errorf("markdown lacks the rule-failure section:\n%s", md.String()) + } + var js bytes.Buffer + if err := GetReportFormatter("json").FormatReport(report, &js); err != nil { + t.Fatal(err) + } + var jr JSONReport + if err := json.Unmarshal(js.Bytes(), &jr); err != nil { + t.Fatal(err) + } + if len(jr.RuleFailures) != 2 || len(jr.Violations) != 1 { + t.Errorf("json: %d rule failures, %d violations; want 2 and 1", len(jr.RuleFailures), len(jr.Violations)) + } + var h bytes.Buffer + if err := GetReportFormatter("html").FormatReport(report, &h); err != nil { + t.Fatal(err) + } + if !strings.Contains(h.String(), "Rules That Could Not Run") { + t.Error("html lacks the rule-failure section") + } +} + +// A builtin a newer mxcli added fails at load, as an undefined name; the +// skipped-file reason says what that usually means. +func TestLoadStarlarkRulesFromDir_UndefinedBuiltinNamesNewerMxcli(t *testing.T) { + dir := t.TempDir() + write(t, dir, "future.star", "def check():\n return future_builtin()\n") + _, failures, err := LoadStarlarkRulesFromDir(dir) + if err != nil { + t.Fatal(err) + } + if len(failures) != 1 || !strings.Contains(failures[0].Reason, "may need a newer mxcli") { + t.Errorf("failures = %+v", failures) + } +} From 70cb9b1ec65ede741bbd38be730650f8ce68a0d1 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 14:52:02 +0000 Subject: [PATCH 03/18] feat(init): stamp the tooling version, guard against older binaries, sync rules and CLAUDE.md (#952) - init and every sync write .ai-context/mxcli-tooling.json; any -p command warns once on stderr when the binary is provably older (releases by number, nightlies by tag date, mixed by build date, dev builds never). - init --sync-skills (alias --sync) refuses from an older binary instead of downgrading, and now also refreshes the bundled lint rules (by file name; user rules untouched) and the CLAUDE.md/AGENTS.md section between new mxcli:begin/end markers, keeping project notes outside them. - The bootstrap script compares the PATH binary and ./mxcli with the stamp before using them, downloads MXCLI_TAG instead of linking an older binary, and downloads through a temp file so a symlinked ./mxcli is never written through. Binaries <= v0.24.0 cannot read the stamp; the script is their guard. - init and new create mdlsource/ with a README. Closes #952. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + CHANGELOG.md | 3 + cmd/mxcli/cmd_new.go | 5 + cmd/mxcli/init.go | 49 ++- cmd/mxcli/init_hook.go | 140 +++++++- cmd/mxcli/init_hook_version_test.go | 219 ++++++++++++ cmd/mxcli/init_tooling_sync.go | 323 ++++++++++++++++++ cmd/mxcli/init_tooling_sync_test.go | 261 ++++++++++++++ cmd/mxcli/main.go | 10 + cmd/mxcli/tooling_stamp.go | 239 +++++++++++++ cmd/mxcli/tooling_stamp_test.go | 138 ++++++++ docs-site/src/ide/init-output.md | 5 +- docs-site/src/ide/mxcli-init.md | 6 + docs-site/src/ide/syncing.md | 115 +++++-- docs-site/src/tutorial/skills.md | 3 +- 15 files changed, 1464 insertions(+), 53 deletions(-) create mode 100644 cmd/mxcli/init_hook_version_test.go create mode 100644 cmd/mxcli/init_tooling_sync.go create mode 100644 cmd/mxcli/init_tooling_sync_test.go create mode 100644 cmd/mxcli/tooling_stamp.go create mode 100644 cmd/mxcli/tooling_stamp_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index 3e132fcc23..31bd5c425f 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -152,3 +152,4 @@ {"date": "2026-10-03", "area": "cmd/mxcli/check", "symptom": "`mxcli check -p` prints MDL067 (bare commit now WITH events) for a create-or-modify flow already stored with events, which `exec -p` no longer prints", "cause": "cmd_check ran ValidateProgram without the DropSettledCommitNotes filter exec_preflight applies; the project was only connected later, for the reference tier", "fix": "check connects to the project before the semantic report when -p is given, applies DropSettledCommitNotes and StoredTaskClaimViolations, and reuses that connection for the reference tier", "insight": "Two gates over one rule set drift whenever a post-filter lives in only one of them; grep for every caller of ValidateProgram when adding a filter. Control: a flow stored without events still notes", "issue": "ako/mxcli#943", "file": "cmd/mxcli/cmd_check.go", "test": "cmd/mxcli/check_stored_semantics_test.go"} {"date": "2026-10-03", "area": "cmd/mxcli/test", "symptom": "every `mxcli test` run prints 2x MDL-DEPR001 and 2x MDL-V1-SLASH about a script the user never wrote", "cause": "GenerateEndpointMDL emitted a headerless mdl 0 script with `create or replace` and `/` terminators; the test-flow generators had already moved to the version-aware writeScriptHeader/createFlow/writeFlowEnd", "fix": "GenerateEndpointMDL writes mdl 1 through the same helpers (header, create or modify, `;` only); endpoint script is independent of the suite's version", "insight": "A generated script is checked like a user's one; pin it with a test that parses it and asserts ValidateProgram returns nothing. Verified end to end with `mxcli test --local` on a fresh 11.13 app", "issue": "ako/mxcli#943", "file": "cmd/mxcli/testrunner/endpoint.go", "test": "cmd/mxcli/testrunner/endpoint_clean_test.go"} {"date": "2026-10-03", "area": "cmd/mxcli/theme", "symptom": "`theme create acme --from design.css` with `--mxt-font: \"Inter\", system-ui, sans-serif` prints nothing about Inter; the theme ships no woff2 and no @font-face for it and renders in the fallback font wherever Inter is not installed", "cause": "planFonts only decided which VENDORED families to drop; a seeded family outside the vendored set was never looked at, so the silent outcome was the default", "fix": "unvendoredSeededFamilies takes the primary (first) family of each seeded font stack, skips generic families and var() and the families the base partial loads, and CreateResult.UnvendoredFonts carries them to cmd_theme.go, which prints a note per family naming mxcli-fonts/ and the partial", "insight": "Only the first family of a stack is the design's choice; flagging the fallbacks (Helvetica, Arial) would make the note noise. The controls are a vendored family (IBM Plex Mono) and a generic stack, which must stay silent", "issue": "ako/mxcli#944", "file": "cmd/mxcli/theme/create_seeded.go (unvendoredSeededFamilies, planFonts); cmd/mxcli/cmd_theme.go", "test": "cmd/mxcli/theme/create_seeded_test.go (TestCreate_NamesSeededFontsItDoesNotVendor)"} +{"area": "cmd/mxcli", "date": "2026-10-03", "symptom": "A project whose CLAUDE.md, skills and lint rules were written by a newer mxcli is served by an older binary (v0.24.0 on PATH) with no warning: 'mdl 1;' is a parse error and shipped lint rules crash, all reading as project defects", "cause": "Nothing recorded which mxcli wrote the tooling; .claude/bootstrap-mxcli.sh linked whatever mxcli was on PATH; init --sync-skills refreshed only .ai-context/skills, never .claude/lint-rules or CLAUDE.md/AGENTS.md", "fix": "init and every sync write .ai-context/mxcli-tooling.json; root PersistentPreRun warns once on stderr when the binary is provably older (release by number, nightly by tag date, mixed by build date, dev never); sync refuses from an older binary; the bootstrap script carries a POSIX-sh copy of the ordering and neither links an older PATH binary nor keeps an older ./mxcli, downloading via a temp file + mv; sync also refreshes bundled lint rules by name and the CLAUDE.md/AGENTS.md section between mxcli:begin/end markers", "insight": "A binary cannot warn about a stamp it predates, so the guard for already-shipped binaries must live in the generated script, which the newer mxcli regenerates. The sh and Go comparisons share one test table so they cannot drift. curl -o ./mxcli on a symlinked ./mxcli would overwrite the PATH binary — always download to a temp name and rename", "issue": "ako/mxcli#952", "file": "cmd/mxcli/tooling_stamp.go; cmd/mxcli/init_tooling_sync.go; cmd/mxcli/init_hook.go (bootstrapScriptTemplate); cmd/mxcli/main.go; cmd/mxcli/init.go", "test": "cmd/mxcli/tooling_stamp_test.go; cmd/mxcli/init_hook_version_test.go; cmd/mxcli/init_tooling_sync_test.go"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e230058e5..f4e3a7e85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **A project records which mxcli wrote its tooling, and an older binary says so** (ako/mxcli#952) — `mxcli init` and every `init --sync-skills` write `.ai-context/mxcli-tooling.json` (version, build time, date; rewritten only when the version changes). Any command that opens the project with `-p` and a binary **older** than the stamp warns once on stderr, naming both versions and how to update; `init --sync-skills` from an older binary **refuses** instead of rolling the skills, rules and CLAUDE.md back. Releases compare by number, nightlies by tag date, a release against a nightly by build date; dev builds are never reported. Binaries from v0.24.0 and earlier cannot read the stamp, so the regenerated `.claude/bootstrap-mxcli.sh` checks it before choosing a binary: an older `mxcli` on PATH is not linked in (it downloads `MXCLI_TAG` instead), an older `./mxcli` is replaced, and the download lands through a temporary file so a `./mxcli` symlink never has it written through into the PATH binary. +- **`init --sync-skills` (alias `--sync`) refreshes the bundled lint rules and the mxcli section of CLAUDE.md / AGENTS.md** (ako/mxcli#952) — it used to refresh only the skills, so a project kept the lint rules and guidance of whichever mxcli first initialised it. Bundled rules are recognised by file name; your own rules beside them are never touched. CLAUDE.md and AGENTS.md are now written between `` / `` markers, and only that section is refreshed — by the sync and by a re-run of `mxcli init` — so project notes outside the markers survive. A file written before the markers is left alone by the sync, with a note; run `mxcli init` once to adopt them. +- **`mxcli init` and `mxcli new` create `mdlsource/`** (ako/mxcli#952) — the directory the generated CLAUDE.md says scripts live in, with a README. - **Starlark lint builtins over the catalog tables that had none** (mendixlabs/mxcli#1265) — `associations()`, `entity_event_handlers()`, `navigation_menu_items()`, `jar_dependencies()`, `strings(language = None)`, `layouts()`, `published_rest_operations()` and `modules()`, each filtered like the other builtins (no System or Marketplace modules, `--modules` / `--exclude`, `--documents` for the document-scoped ones). A rule calling `strings()` gets a full catalog automatically; an untranslated language has no row. Fields are documented in the write-lint-rules skill. - **Association delete behaviour, domain model documentation and the admin user for lint rules** (mendixlabs/mxcli#1269) — `associations()` carries `to_delete_behavior` / `from_delete_behavior` and their error messages (raw Mendix values; CATALOG.ASSOCIATIONS gains the matching columns), `modules()` carries `domain_model_documentation` (CATALOG.MODULES.DomainModelDocumentation; mxcli read it nowhere before, and its write paths keep it), and `project_security()` gains `admin_user_name` and `admin_user_role` — never the password. Catalog schema 17: a cached catalog rebuilds once. - **Activity properties for lint rules** (mendixlabs/mxcli#1266, mendixlabs/mxcli#1267) — `activities_for(name, nested = True)` returns the activities inside loops with `parent_loop_id` and `loop_depth`, and every activity now carries its real `caption` (a split's caption, an annotation's text; it was the placeholder `Activity` on every row), `auto_generate_caption`, `description`, `condition_expression` / `condition_rule` (exclusive splits), `error_handling_type` (`Rollback`, `Custom`, `CustomWithoutRollBack` — capital B — `Continue`, `Abort`), `log_level` / `log_node_expression` / `log_message`, `commit_type` / `with_events`, and `retrieve_source` (`database` / `association`, with `entity_ref` for a database retrieve). A web service call now fills `service_ref`, `action_ref` and its timeout. The same values are columns on the catalog's `activities` table. A stored action mxcli does not model is labelled by its Mendix type (`GenerateJumpToOptionsAction`) instead of `UnsupportedAction`. diff --git a/cmd/mxcli/cmd_new.go b/cmd/mxcli/cmd_new.go index 62b88cf40a..4f967889d6 100644 --- a/cmd/mxcli/cmd_new.go +++ b/cmd/mxcli/cmd_new.go @@ -256,6 +256,11 @@ Examples: } else { fmt.Printf("\nStep 5/7: Skipped (--skip-init)\n") } + // mdlsource/ is where scripts go whatever tooling was chosen — init + // creates it too, this covers --skip-init (ako/mxcli#952). + if _, err := ensureMdlsourceDir(absDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: creating mdlsource/: %v\n", err) + } // Align the project's Java version with what mxcli can build and run // BEFORE the first build, or that build is the thing that fails: Mendix diff --git a/cmd/mxcli/init.go b/cmd/mxcli/init.go index 4903afb35a..c26a9ecb26 100644 --- a/cmd/mxcli/init.go +++ b/cmd/mxcli/init.go @@ -23,6 +23,7 @@ var ( initListTools bool initContainerRuntime string initSyncSkills bool + initSync bool ) const mendixGitignore = `# Mendix project @@ -140,16 +141,27 @@ Container Runtime: // init is interactive-ish and writes tool configs; this is the part that // must follow a binary upgrade, and the SessionStart bootstrap runs it // unattended on every session (mxcli-formula1 §16). - if initSyncSkills { - res, err := syncAIContextSkills(absDir) + // + // It also refreshes the bundled lint rules and the mxcli section of + // CLAUDE.md / AGENTS.md, and stamps the version that did it — and + // refuses when this binary is older than that stamp (ako/mxcli#952). + if initSyncSkills || initSync { + res, err := syncProjectTooling(absDir) if err != nil { - fmt.Fprintf(os.Stderr, "Error syncing skills: %v\n", err) + fmt.Fprintf(os.Stderr, "Error syncing project tooling: %v\n", err) os.Exit(1) } - reportSkillSync(os.Stdout, res) + reportToolingSync(os.Stdout, os.Stderr, res) return } + // An explicit init from an older binary still runs — the user asked for + // it — but not silently: it rewrites the tooling a newer mxcli wrote. + if s := staleBinaryStamp(absDir); s != nil { + writeStaleBinaryWarning(os.Stderr, *s) + fmt.Fprintln(os.Stderr, " Continuing: 'mxcli init' will rewrite that tooling with this older version.") + } + // Find .mpr file. With none here, look one level down: a solution repo // keeps each app in its own folder, and running `mxcli init` from the // root used to write everything at the root against an invented @@ -300,8 +312,14 @@ Container Runtime: // Generate content content := file.Content(projectName, mprFile) - // Write file - if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + // Write file. CLAUDE.md goes through the mxcli markers so a + // later sync can refresh it without touching project notes. + if isOwnedDoc(file.Path) { + if _, err := writeOwnedDoc(filePath, content, true); err != nil { + fmt.Fprintf(os.Stderr, " Error writing %s: %v\n", file.Path, err) + continue + } + } else if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { fmt.Fprintf(os.Stderr, " Error writing %s: %v\n", file.Path, err) continue } @@ -444,7 +462,7 @@ Container Runtime: filePath := filepath.Join(absDir, file.Path) content := file.Content(projectName, mprFile) - if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + if _, err := writeOwnedDoc(filePath, content, true); err != nil { fmt.Fprintf(os.Stderr, " Error writing %s: %v\n", file.Path, err) os.Exit(1) } @@ -542,6 +560,20 @@ Container Runtime: } } + // The directory the generated CLAUDE.md tells the agent to put scripts + // in; it used to be named and never created (ako/mxcli#952). + if created, err := ensureMdlsourceDir(absDir); err != nil { + fmt.Fprintf(os.Stderr, " Warning: creating mdlsource/: %v\n", err) + } else if created { + fmt.Println("\nCreated mdlsource/ (MDL scripts)") + } + + // Record which mxcli wrote all of the above, so an older binary + // opening the project later can say so (ako/mxcli#952). + if _, err := writeToolingStamp(absDir); err != nil { + fmt.Fprintf(os.Stderr, " Warning: writing %s: %v\n", toolingStampRel, err) + } + fmt.Println("\n✓ Initialization complete!") fmt.Println("\nWhat was created:") fmt.Println(" • .gitignore - Mendix project ignore patterns") @@ -649,7 +681,8 @@ func init() { initCmd.Flags().BoolVar(&initAllTools, "all-tools", false, "Initialize for all supported AI tools") initCmd.Flags().BoolVar(&initListTools, "list-tools", false, "List supported AI tools and exit") initCmd.Flags().StringVar(&initContainerRuntime, "container-runtime", "docker", "Container runtime for devcontainer (docker or podman)") - initCmd.Flags().BoolVar(&initSyncSkills, "sync-skills", false, "Refresh .ai-context/skills/ from this binary and exit (quiet when already current)") + initCmd.Flags().BoolVar(&initSyncSkills, "sync-skills", false, "Refresh the project's mxcli tooling from this binary and exit: skills, bundled lint rules, the mxcli section of CLAUDE.md/AGENTS.md, and the version stamp (quiet when already current; refuses when this binary is older than the stamp)") + initCmd.Flags().BoolVar(&initSync, "sync", false, "Same as --sync-skills") } // findMprFilesInSubdirs returns the .mpr files one level below dir, sorted, so diff --git a/cmd/mxcli/init_hook.go b/cmd/mxcli/init_hook.go index 3c75b84c02..c2b6a9126c 100644 --- a/cmd/mxcli/init_hook.go +++ b/cmd/mxcli/init_hook.go @@ -44,33 +44,121 @@ const bootstrapScriptTemplate = `#!/bin/sh # skipping when it is absent. # # Pin a specific mxcli with MXCLI_TAG=vX.Y.Z (default: nightly). +# +# Version guard: .ai-context/mxcli-tooling.json records the mxcli that wrote +# this project's tooling (CLAUDE.md, skills, lint rules). A binary older than +# that does not understand all of it, so an older ./mxcli is replaced and an +# older mxcli on PATH is not linked in. Only a provable "older" counts: dev +# builds and unknown versions are never second-guessed. Binaries that predate +# the stamp cannot check it themselves — this script is where the check lives +# for them. set -e MPR='%s' TAG="${MXCLI_TAG:-nightly}" +STAMP=.ai-context/mxcli-tooling.json + +# stamp_field KEY: a string field of the tooling stamp, or nothing. +stamp_field() { + [ -f "$STAMP" ] || return 0 + sed -n "s/.*\"$1\": *\"\([^\"]*\)\".*/\1/p" "$STAMP" | head -n 1 +} + +# ymd TIMESTAMP: 2026-10-01T12:00:00Z -> 20261001, anything else -> nothing. +ymd() { + echo "$1" | sed -n 's/^\([0-9]\{4\}\)-\([0-9][0-9]\)-\([0-9][0-9]\)T.*/\1\2\3/p' +} + +# bin_version BINARY / bin_built BINARY: what 'BINARY --version' reports. +bin_version() { + MXCLI_QUIET=1 "$1" --version 2>/dev/null | sed -n 's/^mxcli version \([^ ]*\).*/\1/p' | head -n 1 +} +bin_built() { + ymd "$(MXCLI_QUIET=1 "$1" --version 2>/dev/null | sed -n 's/^mxcli version [^ ]* (\(.*\))$/\1/p' | head -n 1)" +} + +# vclass VERSION: "r MAJOR MINOR PATCH" for a release tag, "n YYYYMMDD" for a +# nightly tag, "u" for anything else (dev builds: v0.24.0-888-g4ba1495f2). +vclass() { + case "$1" in + nightly-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-*) + echo "n $(echo "$1" | cut -d- -f2)" ;; + *) + r=$(echo "$1" | sed -n 's/^v\{0,1\}\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\)$/r \1 \2 \3/p') + if [ -n "$r" ]; then echo "$r"; else echo u; fi ;; + esac +} -if [ ! -x ./mxcli ]; then +# version_lt HAVE HAVE_BUILT WANT WANT_BUILT: succeeds when HAVE is provably +# older than WANT. Releases compare by number, nightlies by tag date, and a +# release against a nightly by build date (YYYYMMDD), when both are known. +version_lt() { + hc=$(vclass "$1"); wc=$(vclass "$3"); hb=$2; wb=$4 + [ "$hc" = u ] && return 1 + [ "$wc" = u ] && return 1 + hk=$(echo "$hc" | cut -d' ' -f1); wk=$(echo "$wc" | cut -d' ' -f1) + if [ "$hk" = r ] && [ "$wk" = r ]; then + set -- $(echo "$hc" | cut -d' ' -f2-) $(echo "$wc" | cut -d' ' -f2-) + [ "$1" -lt "$4" ] && return 0 + [ "$1" -gt "$4" ] && return 1 + [ "$2" -lt "$5" ] && return 0 + [ "$2" -gt "$5" ] && return 1 + [ "$3" -lt "$6" ] + return + fi + [ "$hk" = n ] && hb=$(echo "$hc" | cut -d' ' -f2) + [ "$wk" = n ] && wb=$(echo "$wc" | cut -d' ' -f2) + [ -n "$hb" ] && [ -n "$wb" ] && [ "$hb" -lt "$wb" ] +} + +# older_than_stamp BINARY: succeeds when BINARY is provably older than the +# mxcli that wrote this project's tooling. +older_than_stamp() { + want=$(stamp_field version) + [ -n "$want" ] || return 1 + have=$(bin_version "$1") + [ -n "$have" ] || return 1 + version_lt "$have" "$(bin_built "$1")" "$want" "$(ymd "$(stamp_field built)")" +} + +stale= +if [ -x ./mxcli ] && older_than_stamp ./mxcli; then + echo "./mxcli is $(bin_version ./mxcli), older than the mxcli that wrote this project's tooling ($(stamp_field version)) — replacing it." >&2 + stale=1 +fi + +if [ ! -x ./mxcli ] || [ -n "$stale" ]; then # Prefer a copy that is already on this machine. Some environments ship mxcli # pre-installed on PATH, and the bootstrap instructions have you delete the # hardlink 'mxcli new' left in the project — after which this guard could # never be satisfied by the PATH binary and re-downloaded ~85 MB on EVERY # fresh session, forever. (ako/ChipCoV1) # + # But never one older than the tooling: that is how a project written by a + # newer mxcli ended up served by v0.24.0, its CLAUDE.md asking for syntax the + # binary could not parse (ako/mxcli#952). + # # Hardlink first because that is what 'mxcli new' does and it costs nothing; # fall back to a symlink across filesystems, then to a copy. Any of the three # leaves ./mxcli working, which is what the rest of this script and the # project's own CLAUDE.md assume. onpath=$(command -v mxcli 2>/dev/null || true) - if [ -n "$onpath" ] && [ -x "$onpath" ]; then - echo "mxcli found on PATH (${onpath}) — linking it in rather than downloading." - ln -f "$onpath" ./mxcli 2>/dev/null || - ln -sf "$onpath" ./mxcli 2>/dev/null || - cp "$onpath" ./mxcli - chmod +x ./mxcli 2>/dev/null || true + if [ -n "$onpath" ] && [ -x "$onpath" ] && [ "$onpath" != "./mxcli" ] && [ "$onpath" != "$PWD/mxcli" ]; then + if older_than_stamp "$onpath"; then + echo "mxcli on PATH (${onpath}) is $(bin_version "$onpath"), older than this project's tooling ($(stamp_field version)) — not linking it; downloading ${TAG} instead." >&2 + else + echo "mxcli found on PATH (${onpath}) — linking it in rather than downloading." + rm -f ./mxcli + ln -f "$onpath" ./mxcli 2>/dev/null || + ln -sf "$onpath" ./mxcli 2>/dev/null || + cp "$onpath" ./mxcli + chmod +x ./mxcli 2>/dev/null || true + stale= + fi fi fi -if [ ! -x ./mxcli ]; then +if [ ! -x ./mxcli ] || [ -n "$stale" ]; then os=$(uname -s | tr 'A-Z' 'a-z') case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; @@ -78,20 +166,36 @@ if [ ! -x ./mxcli ]; then *) arch=$(uname -m) ;; esac url="https://github.com/mendixlabs/mxcli/releases/download/${TAG}/mxcli-${os}-${arch}" - echo "mxcli not found — downloading ${TAG} for ${os}/${arch}..." - if ! curl -fsSL -o ./mxcli "$url"; then + echo "Downloading mxcli ${TAG} for ${os}/${arch}..." + # Into a temporary file, then renamed over ./mxcli: writing straight to + # ./mxcli would write THROUGH a symlink into the PATH binary it points at, + # and a failed download would leave no binary at all. + if curl -fsSL -o ./mxcli.download "$url"; then + chmod +x ./mxcli.download + mv -f ./mxcli.download ./mxcli + else + rm -f ./mxcli.download echo "Could not download mxcli from ${url}." >&2 - echo "Fetch it manually, or set MXCLI_TAG to a released version." >&2 - exit 1 + if [ ! -x ./mxcli ]; then + echo "Fetch it manually, or set MXCLI_TAG to a released version." >&2 + exit 1 + fi + echo "Keeping the older ./mxcli; expect failures where the tooling uses newer features." >&2 fi - chmod +x ./mxcli fi -# Keep .ai-context/skills/ in step with this binary. The skills are embedded in -# mxcli and written once by 'mxcli init', so upgrading the binary used to leave -# yesterday's guidance in place with no warning — and an agent reads stale -# guidance with the same confidence as current guidance. Quiet when already -# current; never fatal, since a skills refresh must not block the session. +if older_than_stamp ./mxcli; then + echo "WARNING: ./mxcli ($(bin_version ./mxcli)) is still older than this project's tooling ($(stamp_field version))." >&2 + echo " Set MXCLI_TAG to $(stamp_field version) or newer (or 'nightly') and re-run this script." >&2 +fi + +# Keep the project's tooling (skills, bundled lint rules, the mxcli section of +# CLAUDE.md/AGENTS.md) in step with this binary. They are embedded in mxcli and +# written by 'mxcli init', so upgrading the binary used to leave yesterday's +# guidance in place with no warning — and an agent reads stale guidance with +# the same confidence as current guidance. Quiet when already current; refuses +# (rather than downgrades) when the binary is older than the tooling; never +# fatal, since a refresh must not block the session. ./mxcli init --sync-skills . || true exec ./mxcli run --local --setup --ensure-db -p "$MPR" diff --git a/cmd/mxcli/init_hook_version_test.go b/cmd/mxcli/init_hook_version_test.go new file mode 100644 index 0000000000..4e6a86a206 --- /dev/null +++ b/cmd/mxcli/init_hook_version_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// The bootstrap script decides which mxcli serves the project before any +// mxcli runs, so it carries its own copy of the version ordering. These tests +// run the generated script under sh with stand-in binaries (ako/mxcli#952). + +func requireSh(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the bootstrap script is POSIX sh") + } + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("no sh") + } +} + +// bootstrapFunctions is the script's helper-function prelude: everything +// before the first statement that acts. +func bootstrapFunctions(t *testing.T) string { + t.Helper() + script := fmt.Sprintf(bootstrapScriptTemplate, "App.mpr") + i := strings.Index(script, "\nstale=\n") + if i < 0 { + t.Fatal("bootstrap script has no 'stale=' line to cut the prelude at") + } + return strings.Replace(script[:i], "set -e\n", "", 1) +} + +// TestBootstrapVersionGuard_AgreesWithGo runs the script's version_lt over the +// same table the Go comparison is tested with. +func TestBootstrapVersionGuard_AgreesWithGo(t *testing.T) { + requireSh(t) + prelude := bootstrapFunctions(t) + for _, c := range versionOrderCases { + t.Run(c.name, func(t *testing.T) { + sh := prelude + fmt.Sprintf("\nif version_lt %q \"$(ymd %q)\" %q \"$(ymd %q)\"; then echo older; else echo not; fi\n", + c.have, c.haveBuilt, c.want, c.wantBuilt) + out, err := exec.Command("sh", "-c", sh).CombinedOutput() + if err != nil { + t.Fatalf("sh: %v\n%s", err, out) + } + got := strings.TrimSpace(string(out)) == "older" + if got != c.older { + t.Errorf("sh version_lt(%s, %s) = %v (output %q), want %v", c.have, c.want, got, out, c.older) + } + }) + } +} + +// fakeMxcli writes a stand-in mxcli reporting version, logging every other +// invocation so a test can tell which binary ran the rest of the script. +func fakeMxcli(t *testing.T, path, version string) { + t.Helper() + body := "#!/bin/sh\n" + + "if [ \"$1\" = \"--version\" ]; then echo \"mxcli version " + version + " (2026-10-01T00:00:00Z)\"; exit 0; fi\n" + + "echo \"" + version + " $*\" >> \"$BOOT_LOG\"\n" + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} + +type bootEnv struct { + project, bin, log string +} + +// newBootEnv sets up a project with the bootstrap script, an optional stamp, +// and a bin dir holding a fake curl that "downloads" a nightly from the future. +func newBootEnv(t *testing.T, stampVersion string) bootEnv { + t.Helper() + root := t.TempDir() + e := bootEnv{project: filepath.Join(root, "app"), bin: filepath.Join(root, "bin"), log: filepath.Join(root, "boot.log")} + for _, d := range []string{e.project, e.bin} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if _, err := writeBootstrapScript(filepath.Join(e.project, ".claude"), "App.mpr"); err != nil { + t.Fatal(err) + } + if stampVersion != "" { + stamp := fmt.Sprintf("{\n \"version\": %q,\n \"written\": \"2026-10-01\"\n}\n", stampVersion) + if err := os.MkdirAll(filepath.Join(e.project, ".ai-context"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(e.project, toolingStampRel), []byte(stamp), 0o644); err != nil { + t.Fatal(err) + } + } + // curl -fsSL -o FILE URL: write a nightly stand-in to FILE. + curl := "#!/bin/sh\nout=\nwhile [ $# -gt 0 ]; do if [ \"$1\" = \"-o\" ]; then out=$2; shift; fi; shift; done\n" + + "echo \"curl $out\" >> \"$BOOT_LOG\"\n" + + "printf '#!/bin/sh\\nif [ \"$1\" = \"--version\" ]; then echo \"mxcli version nightly-20991231-abcdef0\"; exit 0; fi\\necho \"nightly $*\" >> \"$BOOT_LOG\"\\n' > \"$out\"\n" + if err := os.WriteFile(filepath.Join(e.bin, "curl"), []byte(curl), 0o755); err != nil { + t.Fatal(err) + } + return e +} + +func (e bootEnv) run(t *testing.T) (stderr string) { + t.Helper() + cmd := exec.Command("sh", bootstrapScriptName) + cmd.Dir = e.project + cmd.Env = append(os.Environ(), "PATH="+e.bin+":/usr/bin:/bin", "BOOT_LOG="+e.log, "MXCLI_TAG=") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bootstrap failed: %v\n%s", err, out) + } + return string(out) +} + +func (e bootEnv) projectBinaryVersion(t *testing.T) string { + t.Helper() + out, err := exec.Command(filepath.Join(e.project, "mxcli"), "--version").Output() + if err != nil { + t.Fatalf("./mxcli --version: %v", err) + } + return strings.Fields(string(out))[2] +} + +func (e bootEnv) downloaded(t *testing.T) bool { + data, _ := os.ReadFile(e.log) + return strings.Contains(string(data), "curl ") +} + +// The reported skew: v0.24.0 on PATH, tooling from a newer mxcli. The old +// bootstrap linked it in; the guard refuses and downloads instead. +func TestBootstrapVersionGuard_OlderPathBinaryIsNotLinked(t *testing.T) { + requireSh(t) + e := newBootEnv(t, "v0.25.0") + fakeMxcli(t, filepath.Join(e.bin, "mxcli"), "v0.24.0") + + out := e.run(t) + if !e.downloaded(t) { + t.Errorf("older PATH binary was linked instead of downloading:\n%s", out) + } + if got := e.projectBinaryVersion(t); got != "nightly-20991231-abcdef0" { + t.Errorf("./mxcli is %s, want the downloaded nightly", got) + } + if !strings.Contains(out, "not linking it") { + t.Errorf("no explanation for skipping the PATH binary:\n%s", out) + } +} + +// Control: a PATH binary at least as new as the stamp is linked, no download. +func TestBootstrapVersionGuard_NewerPathBinaryIsLinked(t *testing.T) { + requireSh(t) + for _, c := range []struct{ name, stamp, onPath string }{ + {"newer than stamp", "v0.24.0", "v0.25.0"}, + {"no stamp (pre-#952 project)", "", "v0.24.0"}, + {"dev build is never second-guessed", "v0.25.0", "v0.24.0-888-g4ba1495f2"}, + } { + t.Run(c.name, func(t *testing.T) { + e := newBootEnv(t, c.stamp) + fakeMxcli(t, filepath.Join(e.bin, "mxcli"), c.onPath) + out := e.run(t) + if e.downloaded(t) { + t.Errorf("downloaded although the PATH binary qualifies:\n%s", out) + } + if got := e.projectBinaryVersion(t); got != c.onPath { + t.Errorf("./mxcli is %s, want the PATH binary %s", got, c.onPath) + } + }) + } +} + +// A project binary older than the stamp is replaced — by a newer PATH binary +// when there is one. +func TestBootstrapVersionGuard_StaleProjectBinaryIsReplaced(t *testing.T) { + requireSh(t) + e := newBootEnv(t, "v0.25.0") + fakeMxcli(t, filepath.Join(e.project, "mxcli"), "v0.24.0") + fakeMxcli(t, filepath.Join(e.bin, "mxcli"), "v0.26.0") + + out := e.run(t) + if got := e.projectBinaryVersion(t); got != "v0.26.0" { + t.Errorf("./mxcli is %s, want v0.26.0 from PATH:\n%s", got, out) + } + if e.downloaded(t) { + t.Error("downloaded although PATH had a newer binary") + } + // The rest of the script ran on the replacement, not the stale binary. + log, _ := os.ReadFile(e.log) + if strings.Contains(string(log), "v0.24.0 ") { + t.Errorf("stale binary still ran:\n%s", log) + } +} + +// ./mxcli symlinked to an old PATH binary: the replacement must not be +// written through the link into the PATH binary itself. +func TestBootstrapVersionGuard_DownloadDoesNotWriteThroughSymlink(t *testing.T) { + requireSh(t) + e := newBootEnv(t, "v0.25.0") + onPath := filepath.Join(e.bin, "mxcli") + fakeMxcli(t, onPath, "v0.24.0") + if err := os.Symlink(onPath, filepath.Join(e.project, "mxcli")); err != nil { + t.Fatal(err) + } + + e.run(t) + if got := e.projectBinaryVersion(t); got != "nightly-20991231-abcdef0" { + t.Errorf("./mxcli is %s, want the downloaded nightly", got) + } + out, err := exec.Command(onPath, "--version").Output() + if err != nil || !strings.Contains(string(out), "v0.24.0") { + t.Errorf("PATH binary was modified: %q %v", out, err) + } +} diff --git a/cmd/mxcli/init_tooling_sync.go b/cmd/mxcli/init_tooling_sync.go new file mode 100644 index 0000000000..802da90852 --- /dev/null +++ b/cmd/mxcli/init_tooling_sync.go @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// init_tooling_sync.go extends the session-start refresh beyond the skills to +// the rest of what `mxcli init` writes from the binary: the bundled lint rules +// and the mxcli-owned part of CLAUDE.md / AGENTS.md (ako/mxcli#952). +// +// Before this, `init --sync-skills` refreshed only the skills. A project kept +// the lint rules and CLAUDE.md of whichever mxcli first initialised it, so +// after an upgrade the guidance and the rules disagreed with the binary in +// exactly the way the skills sync was written to prevent. + +// Markers delimiting the part of a generated markdown file mxcli owns. Matched +// by prefix, so the wording of the begin line can change without orphaning +// the files that carry an older one. +const ( + mxcliSectionBeginPrefix = "" + mxcliSectionEnd = "" +) + +// ownedDocs are the generated markdown files whose mxcli section is kept +// current. Each is refreshed only when it already exists: the sync never +// creates a file `mxcli init` did not (a Cursor-only project has no CLAUDE.md, +// and must not grow one). +func ownedDocs() []ToolFile { + var docs []ToolFile + for _, f := range SupportedTools["claude"].Files { + if f.Path == "CLAUDE.md" { + docs = append(docs, f) + } + } + return append(docs, UniversalFiles...) +} + +// isOwnedDoc reports whether init should write path through the markers. +func isOwnedDoc(path string) bool { + for _, d := range ownedDocs() { + if d.Path == path { + return true + } + } + return false +} + +// wrapMxcliSection puts generated content between the markers. +func wrapMxcliSection(generated string) string { + if !strings.HasSuffix(generated, "\n") { + generated += "\n" + } + return mxcliSectionBegin + "\n" + generated + mxcliSectionEnd + "\n" +} + +// mergeMxcliSection replaces the marked section of existing with generated, +// keeping every byte outside the markers. ok is false when existing has no +// well-formed marker pair, in which case nothing can be told apart and the +// caller must not write. +func mergeMxcliSection(existing, generated string) (out string, ok bool) { + begin := lineStartingWith(existing, mxcliSectionBeginPrefix, 0) + if begin < 0 { + return "", false + } + end := lineStartingWith(existing, mxcliSectionEnd, begin) + if end < 0 { + return "", false + } + after := end + len(mxcliSectionEnd) + // Consume the rest of the end-marker line, including its newline. + if nl := strings.IndexByte(existing[after:], '\n'); nl >= 0 { + after += nl + 1 + } else { + after = len(existing) + } + return existing[:begin] + wrapMxcliSection(generated) + existing[after:], true +} + +// lineStartingWith returns the offset of the first line at or after from that +// begins with prefix, or -1. +func lineStartingWith(s, prefix string, from int) int { + for i := from; i < len(s); { + if (i == 0 || s[i-1] == '\n') && strings.HasPrefix(s[i:], prefix) { + return i + } + nl := strings.IndexByte(s[i:], '\n') + if nl < 0 { + break + } + i += nl + 1 + } + return -1 +} + +// docWriteResult is what writing one owned doc did. +type docWriteResult int + +const ( + docUnchanged docWriteResult = iota + docWritten + docNoMarkers // exists without markers; left alone +) + +// writeOwnedDoc writes the generated content of an mxcli-owned markdown file. +// +// A file with markers has only its marked section replaced. A file without +// them predates the markers: `mxcli init` regenerates it whole, as it always +// has (adopting the markers from then on), while the unattended sync leaves it +// untouched — it cannot tell mxcli's text from the project's, and a refresh +// that runs on every session start must never be the thing that deletes a +// project's notes. +func writeOwnedDoc(path, generated string, fromInit bool) (docWriteResult, error) { + existing, err := os.ReadFile(path) + var want string + switch { + case os.IsNotExist(err): + if !fromInit { + return docUnchanged, nil + } + want = wrapMxcliSection(generated) + case err != nil: + return docUnchanged, err + default: + merged, ok := mergeMxcliSection(string(existing), generated) + switch { + case ok: + want = merged + case fromInit: + want = wrapMxcliSection(generated) + default: + return docNoMarkers, nil + } + if want == string(existing) { + return docUnchanged, nil + } + } + if err := os.WriteFile(path, []byte(want), 0o644); err != nil { + return docUnchanged, fmt.Errorf("writing %s: %w", path, err) + } + return docWritten, nil +} + +// bundledLintRuleNames lists the rule files this binary ships, by file name. +// The name IS the marker of a bundled rule: a project's own rules live beside +// them in the same directory under other names, and are never touched. +func bundledLintRuleNames() ([]string, error) { + var names []string + err := fs.WalkDir(lintRulesFS, "lint-rules", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + names = append(names, d.Name()) + } + return nil + }) + sort.Strings(names) + return names, err +} + +// syncBundledLintRules rewrites the bundled rules in a project's lint-rules +// directory from the binary, returning the names that differed. A missing +// directory means the project was not initialised with a tool that uses the +// rules, and stays missing. +// +// An edit to a bundled rule file is overwritten — the file belongs to mxcli, +// like a skill. To change one, copy it under a new name (and a new rule ID), +// or override it in lint-config.yaml. +func syncBundledLintRules(lintRulesDir string) ([]string, error) { + if st, err := os.Stat(lintRulesDir); err != nil || !st.IsDir() { + return nil, nil + } + names, err := bundledLintRuleNames() + if err != nil { + return nil, err + } + var changed []string + for _, name := range names { + want, err := lintRulesFS.ReadFile("lint-rules/" + name) + if err != nil { + return changed, err + } + target := filepath.Join(lintRulesDir, name) + if have, readErr := os.ReadFile(target); readErr == nil && bytes.Equal(have, want) { + continue + } + if err := os.WriteFile(target, want, 0o644); err != nil { + return changed, fmt.Errorf("writing %s: %w", target, err) + } + changed = append(changed, name) + } + return changed, nil +} + +// toolingSyncResult reports a full tooling refresh. +type toolingSyncResult struct { + Skills skillSyncResult + LintRules []string // bundled rule files rewritten + Docs []string // owned docs whose mxcli section was rewritten + NoMarkers []string // owned docs left alone: no markers to refresh between + StampWritten bool + // RefusedFor is set when the sync did nothing because this binary is older + // than the one that wrote the tooling. + RefusedFor *toolingStamp +} + +// syncProjectTooling is `mxcli init --sync-skills`: refresh everything init +// derives from the binary, without touching what the project wrote. +// +// It refuses outright when the running binary is older than the stamp. The +// refresh is a copy from the binary, so running it from an older one is a +// downgrade — the skills, the rules and CLAUDE.md all moved back to what the +// old binary knows, silently, on the next session start. Updating the binary is +// the fix; rolling the project back to match it is not. +func syncProjectTooling(projectDir string) (toolingSyncResult, error) { + var res toolingSyncResult + if s := staleBinaryStamp(projectDir); s != nil { + res.RefusedFor = s + return res, nil + } + + skills, err := syncAIContextSkills(projectDir) + if err != nil { + return res, fmt.Errorf("syncing skills: %w", err) + } + res.Skills = skills + + if res.LintRules, err = syncBundledLintRules(filepath.Join(projectDir, ".claude", "lint-rules")); err != nil { + return res, fmt.Errorf("syncing lint rules: %w", err) + } + + // The docs embed the .mpr path, so without one there is nothing correct to + // render; init would write a placeholder, a refresh must not. + if mprFile := findMprFile(projectDir); mprFile != "" { + projectName := filepath.Base(projectDir) + for _, doc := range ownedDocs() { + r, err := writeOwnedDoc(filepath.Join(projectDir, doc.Path), doc.Content(projectName, mprFile), false) + if err != nil { + return res, err + } + switch r { + case docWritten: + res.Docs = append(res.Docs, doc.Path) + case docNoMarkers: + res.NoMarkers = append(res.NoMarkers, doc.Path) + } + } + } + + if res.StampWritten, err = writeToolingStamp(projectDir); err != nil { + return res, err + } + return res, nil +} + +// reportToolingSync prints what the refresh did — nothing at all when the +// project was already current, since this runs on every session start. The +// refusal and the marker notice go to errw: they are about the setup, not the +// refresh, and must be visible even when stdout is discarded. +func reportToolingSync(w, errw io.Writer, res toolingSyncResult) { + if res.RefusedFor != nil { + fmt.Fprintf(errw, "Not syncing: this mxcli (%s) is older than the mxcli that wrote this project's tooling (%s);\n", + currentMxcliVersion(), res.RefusedFor.Version) + fmt.Fprintln(errw, " a sync from it would downgrade the skills, lint rules and CLAUDE.md. Update mxcli instead:") + fmt.Fprintf(errw, " ./mxcli setup mxcli --tag %s --output ./mxcli, or delete ./mxcli and run: sh %s\n", + updateTag(res.RefusedFor.Version), bootstrapScriptName) + return + } + reportSkillSync(w, res.Skills) + if len(res.LintRules) > 0 { + fmt.Fprintf(w, "Refreshed %d bundled lint rule(s) in .claude/lint-rules/ to match this mxcli: %s\n", + len(res.LintRules), abridge(res.LintRules)) + } + if len(res.Docs) > 0 { + fmt.Fprintf(w, "Refreshed the mxcli section of %s\n", strings.Join(res.Docs, ", ")) + } + if len(res.NoMarkers) > 0 { + fmt.Fprintf(errw, "Note: %s %s no mxcli section markers, so %s not refreshed. Run 'mxcli init' once to adopt them\n", + strings.Join(res.NoMarkers, ", "), map[bool]string{true: "has", false: "have"}[len(res.NoMarkers) == 1], + map[bool]string{true: "it was", false: "they were"}[len(res.NoMarkers) == 1]) + fmt.Fprintln(errw, " (it regenerates the file; keep project notes outside the markers from then on).") + } +} + +// mdlsourceReadme seeds mdlsource/, the directory the generated CLAUDE.md +// says scripts live in. Without it the first script had nowhere obvious to go. +const mdlsourceReadme = `# mdlsource + +MDL scripts for this project, one file per concern. Each starts with ` + "`mdl 1;`" + `. + + ./mxcli check mdlsource/.mdl -p .mpr + ./mxcli exec mdlsource/.mdl -p .mpr + +Exec a change twice: the second run must write nothing. +` + +// ensureMdlsourceDir creates mdlsource/ with a README so the directory +// survives git (which does not track empty directories). An existing README +// is left alone. +func ensureMdlsourceDir(projectDir string) (created bool, err error) { + dir := filepath.Join(projectDir, "mdlsource") + if err := os.MkdirAll(dir, 0o755); err != nil { + return false, err + } + readme := filepath.Join(dir, "README.md") + if _, err := os.Stat(readme); err == nil { + return false, nil + } + if err := os.WriteFile(readme, []byte(mdlsourceReadme), 0o644); err != nil { + return false, err + } + return true, nil +} diff --git a/cmd/mxcli/init_tooling_sync_test.go b/cmd/mxcli/init_tooling_sync_test.go new file mode 100644 index 0000000000..3f3af049c6 --- /dev/null +++ b/cmd/mxcli/init_tooling_sync_test.go @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// newSyncProject is a project as `mxcli init` left it, ready for a sync: an +// .mpr, a .claude/lint-rules directory, and the tooling dirs. +func newSyncProject(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "Demo") + for _, d := range []string{".claude/lint-rules", ".ai-context/skills"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "Demo.mpr"), nil, 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func mustRead(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// ako/mxcli#952: `init --sync-skills` refreshed only the skills, so a project +// kept the lint rules of whichever mxcli first initialised it. The bundled +// rules are refreshed now; a project's own rule beside them is not touched. +func TestSyncProjectTooling_RefreshesBundledLintRulesOnly(t *testing.T) { + dir := newSyncProject(t) + names, err := bundledLintRuleNames() + if err != nil || len(names) == 0 { + t.Fatalf("no bundled lint rules: %v", err) + } + rulesDir := filepath.Join(dir, ".claude", "lint-rules") + stale := filepath.Join(rulesDir, names[0]) + mustWrite(t, stale, "# yesterday's rule\n") + user := filepath.Join(rulesDir, "zz_my_project_rule.star") + const userRule = "RULE_ID = \"PROJ001\"\ndef check():\n return []\n" + mustWrite(t, user, userRule) + + res, err := syncProjectTooling(dir) + if err != nil { + t.Fatal(err) + } + want, _ := lintRulesFS.ReadFile("lint-rules/" + names[0]) + if got := mustRead(t, stale); got != string(want) { + t.Errorf("bundled rule %s not refreshed", names[0]) + } + if got := mustRead(t, user); got != userRule { + t.Errorf("project rule was modified:\n%s", got) + } + if !strings.Contains(strings.Join(res.LintRules, ","), names[0]) { + t.Errorf("result does not report %s: %v", names[0], res.LintRules) + } + + // Control: a second sync has nothing to do. + res2, err := syncProjectTooling(dir) + if err != nil { + t.Fatal(err) + } + if len(res2.LintRules) != 0 || len(res2.Docs) != 0 || res2.Skills.Stale() || res2.StampWritten { + t.Errorf("second sync was not a no-op: %+v", res2) + } +} + +// A project without .claude/lint-rules (a tool that does not use them) does +// not grow one from the sync. +func TestSyncProjectTooling_NoLintRulesDirStaysAbsent(t *testing.T) { + dir := newSyncProject(t) + if err := os.RemoveAll(filepath.Join(dir, ".claude", "lint-rules")); err != nil { + t.Fatal(err) + } + if _, err := syncProjectTooling(dir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, ".claude", "lint-rules")); !os.IsNotExist(err) { + t.Error("sync created .claude/lint-rules") + } +} + +// The mxcli section of CLAUDE.md is refreshed; what the project wrote above +// and below the markers survives byte for byte. +func TestSyncProjectTooling_RefreshesOnlyTheMarkedSection(t *testing.T) { + dir := newSyncProject(t) + const above = "\n" + const below = "\n## Our conventions\n\nCustomers are never deleted.\n" + path := filepath.Join(dir, "CLAUDE.md") + mustWrite(t, path, above+wrapMxcliSection("# Mendix Project: Demo\n\nOld guidance from an older mxcli.\n")+below) + + res, err := syncProjectTooling(dir) + if err != nil { + t.Fatal(err) + } + got := mustRead(t, path) + want := above + wrapMxcliSection(generateClaudeMD("Demo", "Demo.mpr")) + below + if got != want { + t.Errorf("CLAUDE.md after sync:\n%s", got) + } + if strings.Contains(got, "Old guidance") { + t.Error("stale mxcli section survived") + } + if len(res.Docs) != 1 || res.Docs[0] != "CLAUDE.md" { + t.Errorf("Docs = %v, want [CLAUDE.md]", res.Docs) + } + // AGENTS.md did not exist and must not be created by a sync. + if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) { + t.Error("sync created AGENTS.md") + } +} + +// A CLAUDE.md from before the markers cannot be split into mxcli's text and +// the project's, so the unattended sync leaves it alone and says why. +func TestSyncProjectTooling_UnmarkedDocIsLeftAlone(t *testing.T) { + dir := newSyncProject(t) + path := filepath.Join(dir, "AGENTS.md") + const legacy = "# Mendix Project: Demo\n\nWritten by an mxcli without markers, then edited by hand.\n" + mustWrite(t, path, legacy) + + res, err := syncProjectTooling(dir) + if err != nil { + t.Fatal(err) + } + if got := mustRead(t, path); got != legacy { + t.Errorf("unmarked AGENTS.md was rewritten:\n%s", got) + } + var out, errOut bytes.Buffer + reportToolingSync(&out, &errOut, res) + if !strings.Contains(errOut.String(), "AGENTS.md has no mxcli section markers") { + t.Errorf("no notice about the missing markers:\n%s", errOut.String()) + } +} + +// The sync stamps the version it ran with. +func TestSyncProjectTooling_WritesStamp(t *testing.T) { + dir := newSyncProject(t) + withBinaryVersion(t, "v0.25.0", "2026-10-01T00:00:00Z") + res, err := syncProjectTooling(dir) + if err != nil { + t.Fatal(err) + } + if !res.StampWritten { + t.Error("stamp not written") + } + if s := readToolingStamp(dir); s == nil || s.Version != "v0.25.0" { + t.Errorf("stamp = %+v", s) + } +} + +// An older binary must not "refresh" the project back to what it knows: that +// would downgrade the skills, rules and CLAUDE.md a newer mxcli wrote. +func TestSyncProjectTooling_OlderBinaryRefuses(t *testing.T) { + dir := newSyncProject(t) + withBinaryVersion(t, "v0.25.0", "") + if _, err := syncProjectTooling(dir); err != nil { + t.Fatal(err) + } + names, _ := bundledLintRuleNames() + rule := filepath.Join(dir, ".claude", "lint-rules", names[0]) + mustWrite(t, rule, "# written by v0.25.0, newer than this binary knows\n") + + withBinaryVersion(t, "v0.24.0", "") + res, err := syncProjectTooling(dir) + if err != nil { + t.Fatal(err) + } + if res.RefusedFor == nil { + t.Fatal("older binary synced") + } + if got := mustRead(t, rule); !strings.Contains(got, "written by v0.25.0") { + t.Error("older binary overwrote the newer lint rule") + } + if s := readToolingStamp(dir); s.Version != "v0.25.0" { + t.Errorf("stamp downgraded to %s", s.Version) + } + var out, errOut bytes.Buffer + reportToolingSync(&out, &errOut, res) + if !strings.Contains(errOut.String(), "v0.24.0") || !strings.Contains(errOut.String(), "v0.25.0") { + t.Errorf("refusal does not name both versions:\n%s", errOut.String()) + } +} + +func TestMergeMxcliSection(t *testing.T) { + cases := []struct { + name, existing, want string + ok bool + }{ + {"no markers", "# hand written\n", "", false}, + {"begin only", mxcliSectionBegin + "\nx\n", "", false}, + {"end before begin", mxcliSectionEnd + "\n" + mxcliSectionBegin + "\n", "", false}, + {"replaced in place", "a\n" + wrapMxcliSection("old") + "b\n", "a\n" + wrapMxcliSection("new") + "b\n", true}, + {"older begin wording still matches", "\nold\n" + mxcliSectionEnd + "\ntail", wrapMxcliSection("new") + "tail", true}, + {"end marker at EOF without newline", mxcliSectionBegin + "\nold\n" + mxcliSectionEnd, wrapMxcliSection("new"), true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := mergeMxcliSection(c.existing, "new") + if ok != c.ok || (ok && got != c.want) { + t.Errorf("merge = %q, %v; want %q, %v", got, ok, c.want, c.ok) + } + }) + } +} + +// ako/mxcli#952 item 4: CLAUDE.md says scripts live in mdlsource/, and init +// did not create it. Init also stamps the version and writes CLAUDE.md inside +// the markers; re-running it keeps the project's notes below them. +func TestInit_CreatesMdlsourceStampAndMarkedDocs(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "Demo.mpr"), "") + runInit(t, []string{"claude"}, dir) + + if _, err := os.Stat(filepath.Join(dir, "mdlsource", "README.md")); err != nil { + t.Errorf("mdlsource/README.md not created: %v", err) + } + if readToolingStamp(dir) == nil { + t.Errorf("%s not written", toolingStampRel) + } + for _, doc := range []string{"CLAUDE.md", "AGENTS.md"} { + got := mustRead(t, filepath.Join(dir, doc)) + if !strings.HasPrefix(got, mxcliSectionBeginPrefix) || !strings.Contains(got, mxcliSectionEnd) { + t.Errorf("%s is not wrapped in the mxcli markers", doc) + } + } + + path := filepath.Join(dir, "CLAUDE.md") + notes := "\n## Project notes\n\nInvoices are immutable once sent.\n" + mustWrite(t, path, mustRead(t, path)+notes) + readme := filepath.Join(dir, "mdlsource", "README.md") + mustWrite(t, readme, "our own readme\n") + + runInit(t, []string{"claude"}, dir) + if got := mustRead(t, path); !strings.HasSuffix(got, notes) { + t.Errorf("re-running init dropped the project notes:\n%s", got) + } + if got := mustRead(t, readme); got != "our own readme\n" { + t.Error("re-running init overwrote mdlsource/README.md") + } +} diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 2a2e7db586..fbe338c386 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -125,6 +125,16 @@ beta language; --mdl 0 starts them in the alpha language, and in the REPL an fmt.Fprintf(os.Stderr, "Using project: %s\n", discovered) } } + // A binary older than the mxcli that wrote this project's tooling is + // the cause of failures that otherwise read as project defects: a + // CLAUDE.md asking for statements this parser lacks, lint rules + // reading fields this binary does not expose (ako/mxcli#952). Once, + // on stderr, so it cannot corrupt --json or piped output. `init` + // reports the same skew itself, in the terms of what it will do. + if cmd != initCmd { + projectPath, _ = cmd.Flags().GetString("project") + warnIfToolingNewer(os.Stderr, projectPath) + } globalJSONFlag, _ = cmd.Flags().GetBool("json") globalMCPURL, _ = cmd.Flags().GetString("mcp") globalMCPDial, _ = cmd.Flags().GetString("mcp-dial") diff --git a/cmd/mxcli/tooling_stamp.go b/cmd/mxcli/tooling_stamp.go new file mode 100644 index 0000000000..d16fb7ddac --- /dev/null +++ b/cmd/mxcli/tooling_stamp.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// tooling_stamp.go records which mxcli wrote a project's tooling, and warns +// when a binary older than that one opens the project (ako/mxcli#952). +// +// The failure it exists for: a project's CLAUDE.md asked for `mdl 1;` and its +// lint rules read `document_noun`, both written by a newer mxcli than the +// v0.24.0 on PATH. Every one of those then failed — a parse error, three +// crashing rules counted as project errors — and nothing said the cause was the +// binary rather than the project, because nothing recorded which mxcli had +// written the tooling. +// +// A binary can only warn about a stamp it knows to read, so a binary that +// predates the stamp (v0.24.0 and everything before it) stays silent whatever +// the stamp says. That is why the bootstrap script checks the version too: it +// runs before the binary is chosen, and it is regenerated by the newer mxcli +// that writes the stamp. + +// toolingStampRel is the stamp's path relative to the project directory. It +// lives in .ai-context/, which every --tool receives, rather than .claude/, +// which only Claude Code projects have. +const toolingStampRel = ".ai-context/mxcli-tooling.json" + +// toolingStamp is the content of the stamp file. +type toolingStamp struct { + // Version is the mxcli version that last wrote the tooling, exactly as + // `mxcli --version` reports it (v0.25.0, nightly-20261002-4ba1495f2, + // v0.24.0-888-g4ba1495f2 for a dev build). + Version string `json:"version"` + // Built is that binary's build time (RFC 3339), when it had one. It is + // what lets a release and a nightly be ordered against each other. + Built string `json:"built,omitempty"` + // Written is the date the stamp was (re)written, YYYY-MM-DD. + Written string `json:"written"` +} + +// currentMxcliVersion is the running binary's version as the stamp records +// it. A build without -X main.Version has no meaningful version at all. +func currentMxcliVersion() string { + if Version == "" { + return "unknown" + } + return Version +} + +// readToolingStamp returns the project's stamp, or nil when there is none (or +// it is unreadable — a broken stamp must never stop a command). +func readToolingStamp(projectDir string) *toolingStamp { + data, err := os.ReadFile(filepath.Join(projectDir, toolingStampRel)) + if err != nil { + return nil + } + var s toolingStamp + if json.Unmarshal(data, &s) != nil || s.Version == "" { + return nil + } + return &s +} + +// writeToolingStamp records the running binary as the writer of the project's +// tooling. It rewrites nothing when the stamp already names this version, so a +// sync on every session start does not dirty the working tree once a day. +func writeToolingStamp(projectDir string) (changed bool, err error) { + cur := currentMxcliVersion() + if old := readToolingStamp(projectDir); old != nil && old.Version == cur { + return false, nil + } + s := toolingStamp{Version: cur, Built: BuildTime, Written: time.Now().UTC().Format("2006-01-02")} + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return false, err + } + path := filepath.Join(projectDir, toolingStampRel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + return false, fmt.Errorf("writing %s: %w", path, err) + } + return true, nil +} + +// versionKind classifies an mxcli version string. +type versionKind int + +const ( + versionUnknown versionKind = iota // dev build, untagged, or unparseable + versionRelease // v0.24.0 + versionNightly // nightly-20261002-4ba1495f2 +) + +type mxcliVersion struct { + kind versionKind + parts [3]int // release only + date string // nightly only: YYYYMMDD +} + +var ( + releaseVersionRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`) + nightlyVersionRe = regexp.MustCompile(`^nightly-(\d{8})-[0-9a-f]+$`) + buildDateRe = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})T`) +) + +// parseMxcliVersion classifies a version. Anything that is not exactly a +// release tag or a nightly tag — `git describe` output such as +// v0.24.0-888-g4ba1495f2, a -dirty build, "unknown" — is versionUnknown, and an +// unknown version is never compared: a dev build is whatever its author made +// it, and guessing an order for it would warn about nothing or miss real skew. +func parseMxcliVersion(v string) mxcliVersion { + v = strings.TrimSpace(v) + if i := strings.IndexByte(v, ' '); i >= 0 { // "v0.24.0 (2026-…)" from --version + v = v[:i] + } + if m := releaseVersionRe.FindStringSubmatch(v); m != nil { + var out mxcliVersion + out.kind = versionRelease + for i := 0; i < 3; i++ { + out.parts[i], _ = strconv.Atoi(m[i+1]) + } + return out + } + if m := nightlyVersionRe.FindStringSubmatch(v); m != nil { + return mxcliVersion{kind: versionNightly, date: m[1]} + } + return mxcliVersion{} +} + +// buildDate turns an RFC 3339 build time into YYYYMMDD, or "". +func buildDate(built string) string { + m := buildDateRe.FindStringSubmatch(built) + if m == nil { + return "" + } + return m[1] + m[2] + m[3] +} + +// binaryOlderThan reports whether a binary (version + build time) is provably +// older than the one that wrote the stamp. "Provably" is the operative word: +// with either side unknown the answer is false. +// +// - two releases compare by version number; +// - two nightlies compare by the date in the tag; +// - a release against a nightly compares build dates, since neither number +// orders the other — and says nothing when a build date is missing. +// +// Dates are compared by day, so a release and a nightly from the same day are +// never reported as skewed. +// +// The bootstrap script carries a POSIX-sh copy of these rules (vclass / +// version_lt in bootstrapScriptTemplate); TestBootstrapVersionGuard holds the +// two together. +func binaryOlderThan(binVersion, binBuilt string, stamp toolingStamp) bool { + b := parseMxcliVersion(binVersion) + s := parseMxcliVersion(stamp.Version) + if b.kind == versionUnknown || s.kind == versionUnknown { + return false + } + if b.kind == versionRelease && s.kind == versionRelease { + for i := 0; i < 3; i++ { + if b.parts[i] != s.parts[i] { + return b.parts[i] < s.parts[i] + } + } + return false + } + bd, sd := b.date, s.date + if b.kind == versionRelease { + bd = buildDate(binBuilt) + } + if s.kind == versionRelease { + sd = buildDate(stamp.Built) + } + if bd == "" || sd == "" { + return false + } + return bd < sd +} + +// staleBinaryStamp returns the project's stamp when the running binary is +// older than it, else nil. +func staleBinaryStamp(projectDir string) *toolingStamp { + s := readToolingStamp(projectDir) + if s == nil || !binaryOlderThan(Version, BuildTime, *s) { + return nil + } + return s +} + +// updateTag is the release tag to fetch for a stamp version: a nightly is only +// ever available as the rolling "nightly" release. +func updateTag(stampVersion string) string { + if parseMxcliVersion(stampVersion).kind == versionNightly { + return "nightly" + } + return stampVersion +} + +// writeStaleBinaryWarning explains the skew: both versions, what goes wrong, +// and how to update. +func writeStaleBinaryWarning(w io.Writer, s toolingStamp) { + fmt.Fprintf(w, "Warning: this project's mxcli tooling (CLAUDE.md, skills, lint rules) was written by mxcli %s,\n", s.Version) + fmt.Fprintf(w, " but this binary is %s. Statements, commands and lint-rule fields the tooling uses may not exist here.\n", currentMxcliVersion()) + fmt.Fprintf(w, " Update: ./mxcli setup mxcli --tag %s --output ./mxcli (add --os/--arch off linux/amd64),\n", updateTag(s.Version)) + fmt.Fprintf(w, " or delete ./mxcli and re-run: sh %s\n", bootstrapScriptName) +} + +var toolingWarnOnce sync.Once + +// warnIfToolingNewer prints the skew warning at most once per process. It +// takes the -p value: a .mpr path or a project directory. +func warnIfToolingNewer(w io.Writer, projectPath string) { + if projectPath == "" { + return + } + dir := projectPath + if st, err := os.Stat(projectPath); err != nil || !st.IsDir() { + dir = filepath.Dir(projectPath) + } + s := staleBinaryStamp(dir) + if s == nil { + return + } + toolingWarnOnce.Do(func() { writeStaleBinaryWarning(w, *s) }) +} diff --git a/cmd/mxcli/tooling_stamp_test.go b/cmd/mxcli/tooling_stamp_test.go new file mode 100644 index 0000000000..5ec018e088 --- /dev/null +++ b/cmd/mxcli/tooling_stamp_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// withBinaryVersion sets the running binary's version for one test. +func withBinaryVersion(t *testing.T, version, built string) { + t.Helper() + oldV, oldB := Version, BuildTime + Version, BuildTime = version, built + t.Cleanup(func() { Version, BuildTime = oldV, oldB }) +} + +// versionOrderCases is shared by the Go comparison and the bootstrap script's +// copy of it, so the two cannot disagree about what "older" means. +var versionOrderCases = []struct { + name string + have, haveBuilt string + want, wantBuilt string + older bool +}{ + {"release older", "v0.24.0", "", "v0.25.0", "", true}, + {"release older by minor across digits", "v0.9.0", "", "v0.10.0", "", true}, + {"release older by patch", "v0.24.0", "", "v0.24.1", "", true}, + {"release equal", "v0.24.0", "", "v0.24.0", "", false}, + {"release newer", "v0.25.0", "", "v0.24.0", "", false}, + {"release major beats minor", "v1.0.0", "", "v0.99.0", "", false}, + {"nightly older", "nightly-20261001-aaaaaaa", "", "nightly-20261002-4ba1495f2", "", true}, + {"nightly same day", "nightly-20261002-aaaaaaa", "", "nightly-20261002-4ba1495f2", "", false}, + {"nightly newer", "nightly-20261003-aaaaaaa", "", "nightly-20261002-4ba1495f2", "", false}, + {"release built before nightly", "v0.24.0", "2026-06-01T10:00:00Z", "nightly-20261002-4ba1495f2", "", true}, + {"release built after nightly", "v0.25.0", "2026-10-05T10:00:00Z", "nightly-20261002-4ba1495f2", "", false}, + {"nightly before release build", "nightly-20260601-aaaaaaa", "", "v0.25.0", "2026-10-05T10:00:00Z", true}, + {"release without build time vs nightly", "v0.24.0", "", "nightly-20261002-4ba1495f2", "", false}, + {"dev binary never older", "v0.24.0-888-g4ba1495f2", "2026-01-01T00:00:00Z", "v0.25.0", "", false}, + {"dirty binary never older", "v0.24.0-dirty", "", "v0.25.0", "", false}, + {"unknown binary never older", "unknown", "", "v0.25.0", "", false}, + {"dev stamp never newer", "v0.24.0", "", "v0.24.0-914-g5d6927a8e", "", false}, +} + +func TestBinaryOlderThan(t *testing.T) { + for _, c := range versionOrderCases { + t.Run(c.name, func(t *testing.T) { + got := binaryOlderThan(c.have, c.haveBuilt, toolingStamp{Version: c.want, Built: c.wantBuilt}) + if got != c.older { + t.Errorf("binaryOlderThan(%q, %q, %q/%q) = %v, want %v", c.have, c.haveBuilt, c.want, c.wantBuilt, got, c.older) + } + }) + } +} + +// The stamp is rewritten only when the version changes: the sync runs on +// every session start, and a "written" date that moved daily would dirty the +// working tree every day. +func TestWriteToolingStamp_RewritesOnlyOnVersionChange(t *testing.T) { + dir := t.TempDir() + withBinaryVersion(t, "v0.25.0", "2026-10-01T00:00:00Z") + + if changed, err := writeToolingStamp(dir); err != nil || !changed { + t.Fatalf("first write: changed=%v err=%v, want a write", changed, err) + } + s := readToolingStamp(dir) + if s == nil || s.Version != "v0.25.0" || s.Built != "2026-10-01T00:00:00Z" || s.Written == "" { + t.Fatalf("stamp = %+v", s) + } + if changed, _ := writeToolingStamp(dir); changed { + t.Error("second write with the same version changed the stamp") + } + withBinaryVersion(t, "v0.26.0", "") + if changed, _ := writeToolingStamp(dir); !changed { + t.Error("a new version did not rewrite the stamp") + } + if got := readToolingStamp(dir).Version; got != "v0.26.0" { + t.Errorf("stamp version = %q, want v0.26.0", got) + } +} + +// The reported skew: tooling written by a newer mxcli, opened by an older +// binary. The warning names both versions and how to update, and appears once +// however many times the check runs. +func TestWarnIfToolingNewer_OnceWithBothVersions(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, nil, 0o644); err != nil { + t.Fatal(err) + } + withBinaryVersion(t, "v0.25.0", "") + if _, err := writeToolingStamp(dir); err != nil { + t.Fatal(err) + } + + toolingWarnOnce = sync.Once{} + t.Cleanup(func() { toolingWarnOnce = sync.Once{} }) + + // Control: the binary that wrote the stamp is silent. + var buf bytes.Buffer + warnIfToolingNewer(&buf, mpr) + if buf.Len() != 0 { + t.Fatalf("same version warned: %s", buf.String()) + } + + withBinaryVersion(t, "v0.24.0", "") + warnIfToolingNewer(&buf, mpr) + warnIfToolingNewer(&buf, dir) // a directory -p is accepted too + out := buf.String() + for _, want := range []string{"v0.25.0", "v0.24.0", "setup mxcli --tag v0.25.0", bootstrapScriptName} { + if !strings.Contains(out, want) { + t.Errorf("warning lacks %q:\n%s", want, out) + } + } + if n := strings.Count(out, "Warning:"); n != 1 { + t.Errorf("warned %d times, want once:\n%s", n, out) + } +} + +func TestWarnIfToolingNewer_DevBuildIsSilent(t *testing.T) { + dir := t.TempDir() + withBinaryVersion(t, "v0.25.0", "") + if _, err := writeToolingStamp(dir); err != nil { + t.Fatal(err) + } + toolingWarnOnce = sync.Once{} + t.Cleanup(func() { toolingWarnOnce = sync.Once{} }) + withBinaryVersion(t, "v0.24.0-888-g4ba1495f2", "") + var buf bytes.Buffer + warnIfToolingNewer(&buf, filepath.Join(dir, "App.mpr")) + if buf.Len() != 0 { + t.Errorf("a dev build warned: %s", buf.String()) + } +} diff --git a/docs-site/src/ide/init-output.md b/docs-site/src/ide/init-output.md index d26baaa540..c24a1b59e0 100644 --- a/docs-site/src/ide/init-output.md +++ b/docs-site/src/ide/init-output.md @@ -8,8 +8,11 @@ These files are shared by all AI tools: ``` your-mendix-project/ -├── AGENTS.md # Comprehensive AI assistant guide +├── AGENTS.md # Comprehensive AI assistant guide (mxcli section between markers) +├── mdlsource/ # MDL scripts, one file per concern +│ └── README.md ├── .ai-context/ +│ ├── mxcli-tooling.json # Which mxcli wrote this tooling (version stamp) │ ├── skills/ # MDL pattern guides │ │ ├── write-microflows.md # Microflow syntax and patterns │ │ ├── create-page.md # Page/widget syntax reference diff --git a/docs-site/src/ide/mxcli-init.md b/docs-site/src/ide/mxcli-init.md index 44b169417f..1c468b1298 100644 --- a/docs-site/src/ide/mxcli-init.md +++ b/docs-site/src/ide/mxcli-init.md @@ -64,6 +64,12 @@ All tools also receive the universal files (`AGENTS.md`, `.ai-context/`). 4. **Set up dev container** -- `.devcontainer/` with Dockerfile and configuration 5. **Copy mxcli binary** -- places the mxcli executable in the project root 6. **Install VS Code extension** -- copies and installs the bundled `.vsix` file +7. **Create `mdlsource/`** -- the directory the generated `CLAUDE.md` tells the assistant to keep MDL scripts in, with a short README (`mxcli new` creates it even with `--skip-init`) +8. **Stamp the version** -- `.ai-context/mxcli-tooling.json` records which mxcli wrote the tooling; see [Syncing with Updates](./syncing.md#the-version-stamp) + +`CLAUDE.md` and `AGENTS.md` are written between `` and +`` markers. Notes you add outside the markers survive a +re-run of `mxcli init` and every `mxcli init --sync-skills`. ## Adding a Tool Later diff --git a/docs-site/src/ide/syncing.md b/docs-site/src/ide/syncing.md index c09e052ecd..776e5511a0 100644 --- a/docs-site/src/ide/syncing.md +++ b/docs-site/src/ide/syncing.md @@ -1,28 +1,101 @@ # Syncing with Updates -When you upgrade `mxcli` to a newer version, the skills, commands, and VS Code extension bundled with it may have changed. This page explains how to keep your project's files in sync. +When you upgrade `mxcli`, the skills, lint rules and project guidance it ships +may have changed. A project keeps them in step with the binary through one +command, which the Claude Code bootstrap script runs on every session start: -## What Gets Updated +```bash +mxcli init --sync-skills # or: mxcli init --sync +``` -| Component | Source of Truth | Sync Target | -|-----------|----------------|-------------| -| Skills | `reference/mendix-repl/templates/.claude/skills/` | `.ai-context/skills/` | -| Commands | `.claude/commands/mendix/` | `.claude/commands/mendix/` | -| VS Code extension | `vscode-mdl/vscode-mdl-*.vsix` | Installed extension | -| Lint rules | Bundled Starlark rules | `.claude/lint-rules/` | +It is quiet when everything is already current. -## Re-running mxcli init +## What Gets Refreshed -The simplest way to sync is to re-run `mxcli init`: +| Component | Refreshed by `--sync-skills` | What is never touched | +|-----------|------------------------------|-----------------------| +| Skills | `.ai-context/skills/` and `.claude/skills/` | skill directories mxcli does not ship | +| Lint rules | the bundled rules in `.claude/lint-rules/`, identified by file name | any rule file under another name — your own rules | +| `CLAUDE.md`, `AGENTS.md` | the part between the `` and `` markers | everything outside the markers; a file that does not exist is not created | +| Version stamp | `.ai-context/mxcli-tooling.json` | — | -```bash -mxcli init /path/to/my-mendix-project +A bundled lint rule file belongs to mxcli, like a skill: an edit to it is +overwritten. To change a bundled rule, copy it under a new file name and rule +ID, or override its severity or options in `.claude/lint-config.yaml`. + +### The mxcli section of CLAUDE.md and AGENTS.md + +`mxcli init` writes its content between two markers. Put project notes **outside** +them — above or below — and every later sync and every re-run of `mxcli init` +keeps them: + +```markdown + +# Mendix Project: MyApp +… + + +## Our conventions + +Invoices are immutable once sent. ``` -This overwrites the generated skill files with the latest versions. **Custom modifications to built-in skill files will be lost.** To preserve customizations: +A file written by an mxcli from before the markers cannot be split into mxcli's +text and yours, so the sync leaves it alone and prints a note. Run `mxcli init` +once to adopt the markers: it regenerates the file, so move any notes you had +added below the end marker afterwards. + +## The Version Stamp -1. Keep custom skills in separate files (e.g., `my-custom-pattern.md`) -2. Or use version control to merge changes +`mxcli init` and every sync record the mxcli that wrote the tooling in +`.ai-context/mxcli-tooling.json`: + +```json +{ + "version": "v0.25.0", + "built": "2026-10-01T09:12:44Z", + "written": "2026-10-03" +} +``` + +It is rewritten only when the version changes, so the daily session-start sync +does not dirty the working tree. Commit it with the rest of the tooling. + +**An older binary warns.** Any command that opens the project (`-p`) with a +binary older than the stamp prints, once and on stderr, both versions and how +to update. The tooling may use statements, commands or lint-rule fields the +older binary does not have — `mdl 1;` is a parse error before it existed, and +a rule reading a newer catalog field fails. + +**An older binary does not sync.** `init --sync-skills` from an older binary +refuses rather than rolling the skills, rules and `CLAUDE.md` back to what it +knows. Update the binary instead. An explicit `mxcli init` from an older binary +still runs, after the same warning. + +Versions are compared only when the order is certain: two releases by number +(`v0.24.0` < `v0.25.0`), two nightlies by the date in the tag +(`nightly-20261002-…`), a release against a nightly by build date. A dev build +(`v0.24.0-888-g4ba1495f2`, `-dirty`, or no version) is never reported as older. + +### Binaries that predate the stamp + +A binary can only check a stamp it knows to read. mxcli v0.24.0 and earlier +open a project stamped by a newer mxcli **without any warning**. For them the +guard is the bootstrap script, `.claude/bootstrap-mxcli.sh`, which a newer +`mxcli init` regenerates: before it links the `mxcli` on `PATH` into the +project it compares that binary's version with the stamp, and it does not link +an older one — it downloads `MXCLI_TAG` (default `nightly`) instead. A +`./mxcli` older than the stamp is replaced the same way, through a temporary +file, so a `./mxcli` that is a symlink to the `PATH` binary never has the +download written through it. If the download fails, the older `./mxcli` is +kept with a warning rather than leaving the project without a binary. + +## Re-running mxcli init + +`mxcli init` regenerates everything, including the devcontainer and tool +configuration files, and keeps project notes outside the `CLAUDE.md` / +`AGENTS.md` markers. Use it after an upgrade that changed tool configuration; +for skills, rules and guidance alone, `--sync-skills` is enough. ## Build-Time Sync (for mxcli developers) @@ -37,15 +110,7 @@ make sync-vsix # VS Code extension only ## Checking Versions -To see which version of mxcli and its bundled assets you have: - ```bash -mxcli version +mxcli version # the binary +cat .ai-context/mxcli-tooling.json # the mxcli that wrote the project's tooling ``` - -## Recommended Workflow - -1. **Keep custom skills separate** from built-in skills so re-syncing does not overwrite them -2. **Use version control** for your `.ai-context/` and `.claude/` directories -3. **Re-run `mxcli init`** after upgrading mxcli to pick up new skills and bug fixes -4. **Review the diff** after syncing to see what changed in the skill files diff --git a/docs-site/src/tutorial/skills.md b/docs-site/src/tutorial/skills.md index 8a120e80c8..464b95fa77 100644 --- a/docs-site/src/tutorial/skills.md +++ b/docs-site/src/tutorial/skills.md @@ -154,7 +154,8 @@ You can create your own skills to teach the AI about your project's patterns and ``` `mxcli init --sync-skills` only rewrites the skills mxcli itself ships, so your -own directories survive every upgrade. +own directories survive every upgrade. It also refreshes the bundled lint rules +and the mxcli section of `CLAUDE.md` — see [Syncing with Updates](../ide/syncing.md). A custom skill is a markdown document with two lines of frontmatter. Write the body the way you would explain something to a new team member, and write the From aeaf0801e2d78e609d0ff064e0cc4fdac16df15a Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:03:17 +0000 Subject: [PATCH 04/18] fix(docker): run docker check on a temporary copy of the project (#951) docker check ran mx update-widgets on the user's project with only the MPRv2 storage snapshotted, so an MPRv1 .mpr was rewritten permanently, and mx check itself rewrote theme-cache/ and created deployment/sass/ on both formats, with or without --no-update-widgets. Both mx steps now run on a temporary copy (build output, caches and VCS folders skipped), mx output is rewritten to the project's own paths, and the output says that widgets were normalised on a copy and what that hides (#568, #646). docker build keeps its snapshot. Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + .claude/skills/mendix/custom-widgets/SKILL.md | 15 +- .../mendix/migrate-design-prototype/SKILL.md | 8 +- CHANGELOG.md | 1 + cmd/mxcli/docker.go | 15 +- cmd/mxcli/docker/check.go | 74 ++++-- cmd/mxcli/docker/check_copy.go | 158 ++++++++++++ cmd/mxcli/docker/check_integration_test.go | 56 ++++ cmd/mxcli/docker/check_readonly_test.go | 239 ++++++++++++++++++ cmd/mxcli/docker/update_widgets.go | 6 +- docs-site/src/guides/marketplace.md | 2 +- 11 files changed, 545 insertions(+), 30 deletions(-) create mode 100644 cmd/mxcli/docker/check_copy.go create mode 100644 cmd/mxcli/docker/check_readonly_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index 3e132fcc23..d914eb9edb 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -152,3 +152,4 @@ {"date": "2026-10-03", "area": "cmd/mxcli/check", "symptom": "`mxcli check -p` prints MDL067 (bare commit now WITH events) for a create-or-modify flow already stored with events, which `exec -p` no longer prints", "cause": "cmd_check ran ValidateProgram without the DropSettledCommitNotes filter exec_preflight applies; the project was only connected later, for the reference tier", "fix": "check connects to the project before the semantic report when -p is given, applies DropSettledCommitNotes and StoredTaskClaimViolations, and reuses that connection for the reference tier", "insight": "Two gates over one rule set drift whenever a post-filter lives in only one of them; grep for every caller of ValidateProgram when adding a filter. Control: a flow stored without events still notes", "issue": "ako/mxcli#943", "file": "cmd/mxcli/cmd_check.go", "test": "cmd/mxcli/check_stored_semantics_test.go"} {"date": "2026-10-03", "area": "cmd/mxcli/test", "symptom": "every `mxcli test` run prints 2x MDL-DEPR001 and 2x MDL-V1-SLASH about a script the user never wrote", "cause": "GenerateEndpointMDL emitted a headerless mdl 0 script with `create or replace` and `/` terminators; the test-flow generators had already moved to the version-aware writeScriptHeader/createFlow/writeFlowEnd", "fix": "GenerateEndpointMDL writes mdl 1 through the same helpers (header, create or modify, `;` only); endpoint script is independent of the suite's version", "insight": "A generated script is checked like a user's one; pin it with a test that parses it and asserts ValidateProgram returns nothing. Verified end to end with `mxcli test --local` on a fresh 11.13 app", "issue": "ako/mxcli#943", "file": "cmd/mxcli/testrunner/endpoint.go", "test": "cmd/mxcli/testrunner/endpoint_clean_test.go"} {"date": "2026-10-03", "area": "cmd/mxcli/theme", "symptom": "`theme create acme --from design.css` with `--mxt-font: \"Inter\", system-ui, sans-serif` prints nothing about Inter; the theme ships no woff2 and no @font-face for it and renders in the fallback font wherever Inter is not installed", "cause": "planFonts only decided which VENDORED families to drop; a seeded family outside the vendored set was never looked at, so the silent outcome was the default", "fix": "unvendoredSeededFamilies takes the primary (first) family of each seeded font stack, skips generic families and var() and the families the base partial loads, and CreateResult.UnvendoredFonts carries them to cmd_theme.go, which prints a note per family naming mxcli-fonts/ and the partial", "insight": "Only the first family of a stack is the design's choice; flagging the fallbacks (Helvetica, Arial) would make the note noise. The controls are a vendored family (IBM Plex Mono) and a generic stack, which must stay silent", "issue": "ako/mxcli#944", "file": "cmd/mxcli/theme/create_seeded.go (unvendoredSeededFamilies, planFonts); cmd/mxcli/cmd_theme.go", "test": "cmd/mxcli/theme/create_seeded_test.go (TestCreate_NamesSeededFontsItDoesNotVendor)"} +{"area": "cmd/mxcli/docker", "date": "2026-10-03", "symptom": "`mxcli docker check` (a check) rewrote an MPRv1 project's .mpr permanently, and on v1 and v2 alike rewrote theme-cache/web/theme.compiled.css(.map) and created deployment/sass/main.scss \u2014 with or without --no-update-widgets", "cause": "update-widgets ran on the user's project, protected only by a snapshot/restore of the v2 storage (.mpr + mprcontents/), so v1 had no protection; and `mx check` itself compiles the theme into theme-cache/ and writes deployment/sass/, which nothing guarded", "file": "`cmd/mxcli/docker/check.go` (`Check`), `cmd/mxcli/docker/check_copy.go` (`copyProjectForCheck`)", "insight": "A snapshot of the files you expect a tool to touch protects only those files; measure with a whole-tree hash+mtime diff before/after, which is what showed that plain `mx check` writes too. The fix is to run both mx steps on a temporary copy (skipping deployment/, releases/, theme-cache/, .git, node_modules) and rewrite the copy's path back in mx output. Control: a CE0117 microflow is still reported on both formats with the tree unchanged. `docker build` keeps runUpdateWidgets because it is expected to write deployment/ \u2014 but it still rewrites a v1 .mpr", "refs": ["ako/mxcli#951", "ako/mxcli#568", "ako/mxcli#646"]} diff --git a/.claude/skills/mendix/custom-widgets/SKILL.md b/.claude/skills/mendix/custom-widgets/SKILL.md index 7826dc847a..96b2087562 100644 --- a/.claude/skills/mendix/custom-widgets/SKILL.md +++ b/.claude/skills/mendix/custom-widgets/SKILL.md @@ -303,15 +303,18 @@ MDL032). **CE0463 "update this widget" is EXPECTED after generating charts.** mxcli writes the WidgetType from an embedded 11.6 baseline; the installed Charts.mpk is a -different version, so Studio Pro/mxbuild flags drift. Clear it with **`mxcli docker -check`/`build`** (they normalize the widgets and preserve your storage format). The -whole `mdl-examples/doctype-tests/34-chart-widget-examples.mdl` builds **0 errors** -after normalization. +different version, so Studio Pro/mxbuild flags drift. Clear it with **`mxcli fix +widgets`**, which normalizes the stored widgets and preserves your storage format. +`mxcli docker check` normalizes only a temporary copy before checking, so it reports +0 errors while the stored project still fails `run --local`; `docker check +--no-update-widgets` checks the project as stored. The whole +`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` builds **0 errors** after +normalization. **Do NOT run bare `mx update-widgets` on an MPRv2 project** (an `mprcontents/`-folder project — what `mxcli new` creates): it converts the project to single-file v1 and **deletes `mprcontents/`**, corrupting git, breaking a running `mxcli run --local` -loop, and sometimes making the project unopenable in Studio Pro. `mxcli docker -check`/`build` snapshot/restore the v2 files around the normalization; raw +loop, and sometimes making the project unopenable in Studio Pro. `mxcli fix widgets` +writes the result back as v2, `mxcli docker check` runs on a temporary copy; raw `mx update-widgets` is only safe on a v1 project or a throwaway diagnostic copy. **DESCRIBE round-trips** series/line/scalecolor object-lists (item names are diff --git a/.claude/skills/mendix/migrate-design-prototype/SKILL.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md index b45d5e165c..f9fc503679 100644 --- a/.claude/skills/mendix/migrate-design-prototype/SKILL.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -347,8 +347,8 @@ too — each `series`/`line` binds its own OQL-view datasource + X/Y attributes; Pie/HeatMap bind at the widget level (`ValueAttribute:`, Pie needs `SeriesName:`). See **[Custom & Pluggable Widgets → Charts](../custom-widgets/SKILL.md)** for the chart-type → id table, per-chart required-property gotchas (TimeSeries needs a datetime X, -Bubble needs a size attribute), and the **CE0463 → `mxcli docker check`/`build`** step -(these normalize widgets *and* preserve MPRv2 storage — never run bare +Bubble needs a size attribute), and the **CE0463 → `mxcli fix widgets`** step +(it normalizes the stored widgets *and* preserves MPRv2 storage — never run bare `mx update-widgets` on a `mxcli new` project; it deletes `mprcontents/`). `mdl-examples/doctype-tests/34-chart-widget-examples.mdl` is the full showcase. @@ -659,8 +659,8 @@ the fast index so a design migration doesn't rediscover them. (`Charts.mpk`: column/bar/line/area/pie)** now author via MDL — each `series` (an object-list item inside the chart) binds a datasource plus X/Y attributes: `series s1 (staticDataSource: database from Module.View, staticXAttribute: "X", staticYAttribute: "Y")` - (a per-series OQL view works too). `mxcli docker check`/`build` clear the - widget-version-drift CE0463 (they normalize the widgets and preserve MPRv2 storage — + (a per-series OQL view works too). `mxcli fix widgets` clears the + widget-version-drift CE0463 (it normalizes the stored widgets and preserves MPRv2 storage — do not run bare `mx update-widgets`, which deletes `mprcontents/`). Still lighter when the design allows: a **CSS-background SVG** container (or `HTMLElement`) for sparklines/trends — no datasource — and `ProgressCircle` (`type: expression`, `expressionCurrentValue: '$currentObject/Rate'`, min `'0'` / max `'100'`, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e79ebef40..06feea8c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`docker check` no longer modifies the project** (ako/mxcli#951) — `mx update-widgets` and `mx check` now run on a temporary copy, for MPR v1 and v2 alike, and what mx prints names the project's own paths. Before, a check rewrote an MPR v1 project's `.mpr` permanently (only v2 was restored from a snapshot), and `mx check` itself rewrote `theme-cache/` and created `deployment/sass/` even with `--no-update-widgets`. The output now says that widget definitions were normalised on a copy, and that a CE0463 the stored project still has is therefore not reported: `--no-update-widgets` checks the project as stored, `mxcli fix widgets` applies the normalisation (ako/mxcli#568, #646). Build output, caches and VCS folders are not copied; the copy goes to `$TMPDIR` and is removed afterwards. - **Parallel mxcli runs on one project no longer fail to save the catalog cache** (ako/mxcli#951) — eight parallel `lint` runs on a fresh copy printed `failed to create table catalog_meta: table catalog_meta already exists` or `database is locked`. The cache is now written to a temporary file next to it and renamed into place, so every run saves and a reader sees the old cache or the new one, never a half-written file; opening a current cache no longer writes to it. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". diff --git a/cmd/mxcli/docker.go b/cmd/mxcli/docker.go index 1c41ee9187..ea2ac40cc9 100644 --- a/cmd/mxcli/docker.go +++ b/cmd/mxcli/docker.go @@ -169,9 +169,18 @@ This catches project errors (broken references, missing attributes, etc.) early, before the slower MxBuild step. The 'docker build' command runs this automatically unless --skip-check is used. -By default, 'mx update-widgets' runs before 'mx check' to normalize -pluggable widget definitions and prevent false CE0463 errors. Use ---no-update-widgets to skip this step. +The check never modifies the project: both mx steps run on a temporary +copy (in $TMPDIR), because mx writes into the project it checks — it +compiles the theme into theme-cache/ and deployment/, and update-widgets +rewrites the model. Build output, caches and VCS folders (deployment/, +releases/, theme-cache/, .git/, node_modules/, ...) are not copied. + +By default, 'mx update-widgets' runs before 'mx check', on that copy, to +normalize pluggable widget definitions and prevent false CE0463 errors. +A CE0463 that normalisation clears is then not reported, although it still +fails MxBuild and 'mxcli run --local' on the stored project. Use +--no-update-widgets to check the project as stored, and 'mxcli fix widgets' +to apply the normalisation to the project. The mx binary is located from the same directory as mxbuild. diff --git a/cmd/mxcli/docker/check.go b/cmd/mxcli/docker/check.go index 91815a0afb..0167520123 100644 --- a/cmd/mxcli/docker/check.go +++ b/cmd/mxcli/docker/check.go @@ -21,8 +21,9 @@ type CheckOptions struct { MxBuildPath string // SkipUpdateWidgets skips the 'mx update-widgets' step before checking. - // By default, update-widgets runs first to normalize pluggable widget - // definitions and prevent false CE0463 errors. + // By default, update-widgets runs first, on the temporary copy the check + // uses, to normalize pluggable widget definitions and prevent false CE0463 + // errors. SkipUpdateWidgets bool // Stdout for output messages. @@ -56,7 +57,24 @@ func copyFile(src, dst string) error { return out.Close() } +// resolveMxForCheck and mxCheckCmd are seams for tests, which substitute stubs +// that behave like the real tools without needing mx. +var resolveMxForCheck = ResolveMxForVersion + +var mxCheckCmd = func(mxPath, mprPath string, w, stderr io.Writer) error { + cmd := exec.Command(mxPath, "check", mprPath) + cmd.Stdout = w + cmd.Stderr = stderr + PrepareMxCommand(cmd) + return cmd.Run() +} + // Check runs 'mx check' on the project to validate it before building. +// +// It never modifies the project. `mx update-widgets` rewrites the model (and +// turns an MPRv2 project into MPRv1), and `mx check` itself writes theme-cache/ +// and deployment/sass/ — so both run on a temporary copy, and what mx prints is +// reported against the project's own paths (ako/mxcli#951). func Check(opts CheckOptions) error { w := opts.Stdout if w == nil { @@ -76,30 +94,56 @@ func Check(opts CheckOptions) error { } } - mxPath, err := ResolveMxForVersion(opts.MxBuildPath, projectVersion) + mxPath, err := resolveMxForCheck(opts.MxBuildPath, projectVersion) if err != nil { return err } fmt.Fprintf(w, "Using mx: %s\n", mxPath) - // Normalize pluggable widget definitions so `mx check` does not report false - // CE0463 ("widget definition changed") errors. runUpdateWidgets preserves the - // project's on-disk storage format; restore is deferred so the check below still - // runs against the widget-normalized model. + projectPath := opts.ProjectPath + if abs, err := filepath.Abs(projectPath); err == nil { + projectPath = abs + } + workMpr, cleanup, err := copyProjectForCheck(projectPath) + if err != nil { + return fmt.Errorf("copy the project to a temporary directory for checking: %w\n"+ + " mx writes into the project it checks, so docker check never runs it on the original;\n"+ + " set TMPDIR to a disk with room for the project", err) + } + defer cleanup() + out := newPathRewriter(w, filepath.Dir(workMpr), filepath.Dir(projectPath)) + errOut := newPathRewriter(stderr, filepath.Dir(workMpr), filepath.Dir(projectPath)) + defer out.Flush() + defer errOut.Flush() + fmt.Fprintln(w, "Checking a temporary copy (mx writes into the project it checks; the project on disk is not changed).") + + // Normalize pluggable widget definitions so `mx check` does not report + // CE0463 ("widget definition changed") for definitions that only need a + // resync. On the copy, so neither the model nor its storage format changes. if !opts.SkipUpdateWidgets { - restore := runUpdateWidgets(mxPath, opts.ProjectPath, w, stderr) - defer restore() + fmt.Fprintln(w, "Normalising widget definitions on the temporary copy...") + if err := updateWidgetsCmd(mxPath, workMpr, out, errOut); err != nil { + out.Flush() + fmt.Fprintf(w, "Warning: update-widgets failed (continuing): %v\n", err) + } } // Run mx check fmt.Fprintf(w, "Checking project %s...\n", opts.ProjectPath) - cmd := exec.Command(mxPath, "check", opts.ProjectPath) - cmd.Stdout = w - cmd.Stderr = stderr - PrepareMxCommand(cmd) + checkErr := mxCheckCmd(mxPath, workMpr, out, errOut) + out.Flush() + errOut.Flush() - if err := cmd.Run(); err != nil { - return fmt.Errorf("project check failed: %w", err) + if !opts.SkipUpdateWidgets { + // #568 / #646: the normalisation can hide a CE0463 the stored project + // still has, which then fails `run --local` and MxBuild. + fmt.Fprintln(w, "Note: widget definitions were normalised on a temporary copy before checking, so a") + fmt.Fprintln(w, " CE0463 the stored project still has is not reported here; it fails MxBuild and") + fmt.Fprintln(w, " `mxcli run --local`. Use --no-update-widgets to check the project as stored, and") + fmt.Fprintln(w, " `mxcli fix widgets` to apply the normalisation to the project.") + } + if checkErr != nil { + return fmt.Errorf("project check failed: %w", checkErr) } fmt.Fprintln(w, "Project check passed.") diff --git a/cmd/mxcli/docker/check_copy.go b/cmd/mxcli/docker/check_copy.go new file mode 100644 index 0000000000..3cb65bdf23 --- /dev/null +++ b/cmd/mxcli/docker/check_copy.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "bytes" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" +) + +// copyProjectForCheck copies the project that holds mprPath into a fresh +// temporary directory, so `docker check` can run `mx update-widgets` and +// `mx check` there instead of on the user's project (ako/mxcli#951). +// +// Both tools write into the project they are given: update-widgets rewrites the +// model (and converts MPRv2 to MPRv1), and mx check compiles the theme into +// theme-cache/ and writes deployment/sass/. A snapshot/restore of the v2 storage +// covered only the first half of that, and only on v2 — an MPRv1 project's .mpr +// was rewritten permanently by a "check". +// +// Only what mx reads is copied: build output, caches and VCS metadata are skipped +// (copyProjectTree), so the cost is the model plus the widget, theme and source +// folders. cleanup removes the copy; it is never nil and safe to defer. +func copyProjectForCheck(mprPath string) (workMpr string, cleanup func(), err error) { + cleanup = func() {} + abs, err := filepath.Abs(mprPath) + if err != nil { + return "", cleanup, err + } + tmp, err := os.MkdirTemp("", "mxcli-check-*") + if err != nil { + return "", cleanup, err + } + cleanup = func() { os.RemoveAll(tmp) } + + projectDir := filepath.Dir(abs) + dst := filepath.Join(tmp, filepath.Base(projectDir)) + if err := copyProjectTree(projectDir, dst); err != nil { + cleanup() + return "", func() {}, err + } + return filepath.Join(dst, filepath.Base(abs)), cleanup, nil +} + +// checkCopySkipRoot are top-level project folders mx check neither needs nor +// should see: build output and caches it regenerates. +var checkCopySkipRoot = map[string]bool{ + "deployment": true, // MxBuild / mx check output + "releases": true, // exported .mda packages + "theme-cache": true, // compiled theme, regenerated by mx check + ".mendix-cache": true, + ".mxcli": true, // catalog cache, widget docs + ".docker": true, // docker init output +} + +// checkCopySkipAnywhere are folder names skipped at any depth. +var checkCopySkipAnywhere = map[string]bool{ + ".git": true, + ".svn": true, + ".hg": true, + "node_modules": true, +} + +// copyProjectTree copies src to dst, skipping the folders above. A symlinked +// file is copied as a file, so a tool writing to the copy cannot write through +// it into the original; a symlinked folder is recreated as a link. +func copyProjectTree(src, dst string) error { + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + if rel != "." && (checkCopySkipAnywhere[d.Name()] || (!strings.ContainsRune(rel, filepath.Separator) && checkCopySkipRoot[rel])) { + return filepath.SkipDir + } + return os.MkdirAll(target, 0o755) + } + if d.Type()&fs.ModeSymlink != 0 { + info, err := os.Stat(p) + if err != nil { + return nil // dangling link: nothing mx could read either + } + if info.IsDir() { + link, err := os.Readlink(p) + if err != nil { + return err + } + return os.Symlink(link, target) + } + } else if !d.Type().IsRegular() { + return nil // sockets, pipes, devices + } + return copyFile(p, target) + }) +} + +// pathRewriter forwards output line by line with the temporary copy's path +// replaced by the project's own, so what mx reports names the files the user +// has, not a directory that is deleted when the check ends. +type pathRewriter struct { + mu sync.Mutex + w io.Writer + from []string + to string + buf bytes.Buffer +} + +func newPathRewriter(w io.Writer, copyDir, projectDir string) *pathRewriter { + from := []string{copyDir} + if resolved, err := filepath.EvalSymlinks(copyDir); err == nil && resolved != copyDir { + from = append(from, resolved) + } + return &pathRewriter{w: w, from: from, to: projectDir} +} + +func (r *pathRewriter) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.buf.Write(p) + for { + i := bytes.IndexByte(r.buf.Bytes(), '\n') + if i < 0 { + break + } + line := string(r.buf.Next(i + 1)) + if _, err := io.WriteString(r.w, r.rewrite(line)); err != nil { + return len(p), err + } + } + return len(p), nil +} + +// Flush writes a trailing partial line. +func (r *pathRewriter) Flush() { + r.mu.Lock() + defer r.mu.Unlock() + if r.buf.Len() > 0 { + io.WriteString(r.w, r.rewrite(r.buf.String())) + r.buf.Reset() + } +} + +func (r *pathRewriter) rewrite(s string) string { + for _, f := range r.from { + s = strings.ReplaceAll(s, f, r.to) + } + return s +} + diff --git a/cmd/mxcli/docker/check_integration_test.go b/cmd/mxcli/docker/check_integration_test.go index 981d1244c1..fcfe2bf82e 100644 --- a/cmd/mxcli/docker/check_integration_test.go +++ b/cmd/mxcli/docker/check_integration_test.go @@ -6,6 +6,8 @@ package docker import ( "bytes" + "fmt" + "io" "os" "os/exec" "path/filepath" @@ -79,3 +81,57 @@ func mprStorageVersion(t *testing.T, mprPath string) types.MPRVersion { defer reader.Disconnect() return reader.Version() } + +// TestCheck_LeavesProjectUntouched is the end-to-end guard for ako/mxcli#951: +// with real mx, a default `docker check` and a --no-update-widgets one leave +// every file of an MPRv1 and an MPRv2 project byte-identical with the same +// mtime. Before the fix, update-widgets rewrote a v1 .mpr permanently, and mx +// check wrote theme-cache/ and deployment/sass/ in both formats. +func TestCheck_LeavesProjectUntouched(t *testing.T) { + mxPath, err := ResolveMx("") + if err != nil { + t.Skipf("mx not resolvable: %v", err) + } + scaffold := func(t *testing.T) string { + // Not t.TempDir(): the subtest names make it long enough for + // create-project's template extraction to hit PathTooLongException. + dir, err := os.MkdirTemp("", "chk") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + cmd := exec.Command(mxPath, "create-project") + cmd.Dir = dir + PrepareMxCommand(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("mx create-project failed: %v\n%s", err, out) + } + return filepath.Join(dir, "App.mpr") + } + + for _, format := range []string{"v2", "v1"} { + for _, skip := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/no-update-widgets=%v", format, skip), func(t *testing.T) { + mprPath := scaffold(t) + if format == "v1" { + // update-widgets converts a v2 project to v1 — the fixture we want. + if err := updateWidgetsCmd(mxPath, mprPath, io.Discard, io.Discard); err != nil { + t.Skipf("could not produce a v1 fixture: %v", err) + } + if v := mprStorageVersion(t, mprPath); v != types.MPRVersionV1 { + t.Skipf("fixture is %v, not v1", v) + } + } + dir := filepath.Dir(mprPath) + before := treeState(t, dir) + var stdout bytes.Buffer + if err := Check(CheckOptions{ProjectPath: mprPath, SkipUpdateWidgets: skip, Stdout: &stdout, Stderr: &stdout}); err != nil { + t.Fatalf("Check: %v\n%s", err, stdout.String()) + } + if d := diffStates(before, treeState(t, dir)); len(d) > 0 { + t.Errorf("docker check modified the project:\n %v", d) + } + }) + } + } +} diff --git a/cmd/mxcli/docker/check_readonly_test.go b/cmd/mxcli/docker/check_readonly_test.go new file mode 100644 index 0000000000..21571a6805 --- /dev/null +++ b/cmd/mxcli/docker/check_readonly_test.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#951 item 1: `docker check` must never modify the user's project. +// +// `mx update-widgets` rewrites the model (and turns an MPRv2 project into +// MPRv1), and `mx check` itself compiles the theme into theme-cache/ and writes +// deployment/sass/. Measured on an 11.13 project: an MPRv1 project's .mpr was +// rewritten permanently by a plain `docker check`, and theme-cache/ and +// deployment/ were touched on v1 and v2 alike, with or without +// --no-update-widgets. Check now runs both tools on a temporary copy. +// +// The stubs below do to the directory they are given what the real tools do, +// so these tests fail the moment a tool is pointed at the project itself. +package docker + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// treeState records every file's content hash and mtime, and every directory, +// under root. +func treeState(t *testing.T, root string) map[string]string { + t.Helper() + state := map[string]string{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(root, p) + info, err := d.Info() + if err != nil { + return err + } + if d.IsDir() { + state[rel+"/"] = "dir" + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + sum := sha256.Sum256(b) + state[rel] = hex.EncodeToString(sum[:]) + " " + info.ModTime().UTC().Format(time.RFC3339Nano) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + return state +} + +func diffStates(before, after map[string]string) []string { + var out []string + for k, v := range before { + if a, ok := after[k]; !ok { + out = append(out, "removed "+k) + } else if a != v { + out = append(out, "changed "+k) + } + } + for k := range after { + if _, ok := before[k]; !ok { + out = append(out, "added "+k) + } + } + return out +} + +// addProjectDirs gives a fixture the directories mx touches or reads. +func addProjectDirs(t *testing.T, dir string) { + t.Helper() + for _, f := range []string{ + "widgets/Some.Widget.mpk", + "theme-cache/web/theme.compiled.css", + "theme/web/main.scss", + "javasource/app/Action.java", + } { + p := filepath.Join(dir, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("original "+f), 0o644); err != nil { + t.Fatal(err) + } + } +} + +// stubTools replaces mx resolution and both mx invocations with stubs that +// mutate the project they are pointed at the way the real tools do, and record +// the paths they were given. +func stubTools(t *testing.T) (seen *[]string) { + t.Helper() + var paths []string + origResolve, origUW, origCheck := resolveMxForCheck, updateWidgetsCmd, mxCheckCmd + t.Cleanup(func() { resolveMxForCheck, updateWidgetsCmd, mxCheckCmd = origResolve, origUW, origCheck }) + + resolveMxForCheck = func(string, string) (string, error) { return "mx", nil } + updateWidgetsCmd = func(_, mprPath string, w, _ io.Writer) error { + paths = append(paths, "update-widgets "+mprPath) + dir := filepath.Dir(mprPath) + // Rewrites the model (inlining units on v2) and drops mprcontents/. + if err := os.WriteFile(mprPath, []byte("rewritten by update-widgets"), 0o644); err != nil { + return err + } + return os.RemoveAll(filepath.Join(dir, "mprcontents")) + } + mxCheckCmd = func(_, mprPath string, w, _ io.Writer) error { + paths = append(paths, "check "+mprPath) + dir := filepath.Dir(mprPath) + // Compiles the theme and writes the sass entry point. + if err := os.MkdirAll(filepath.Join(dir, "theme-cache/web"), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "theme-cache/web/theme.compiled.css"), []byte("recompiled"), 0o644); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(dir, "deployment/sass"), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "deployment/sass/main.scss"), []byte("x"), 0o644); err != nil { + return err + } + // mx reports the project it loaded by path in some messages. + fmt.Fprintf(w, "Loading %s\nThe app contains: 0 errors.\n", mprPath) + return nil + } + return &paths +} + +func TestCheck_DoesNotModifyProject(t *testing.T) { + for _, tc := range []struct { + name string + fixture func(*testing.T) string + skipUpdate bool + wantUWOnACopy bool + }{ + {"v1", v1Fixture, false, true}, + {"v2", v2Fixture, false, true}, + {"v1 --no-update-widgets", v1Fixture, true, false}, + {"v2 --no-update-widgets", v2Fixture, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + mprPath := tc.fixture(t) + projectDir := filepath.Dir(mprPath) + addProjectDirs(t, projectDir) + seen := stubTools(t) + + tmpRoot := t.TempDir() + t.Setenv("TMPDIR", tmpRoot) + + before := treeState(t, projectDir) + var out bytes.Buffer + if err := Check(CheckOptions{ + ProjectPath: mprPath, + SkipUpdateWidgets: tc.skipUpdate, + Stdout: &out, + Stderr: io.Discard, + }); err != nil { + t.Fatalf("Check: %v\n%s", err, out.String()) + } + after := treeState(t, projectDir) + if d := diffStates(before, after); len(d) > 0 { + t.Errorf("docker check modified the project:\n %s", strings.Join(d, "\n ")) + } + + // Every tool ran, and none of them on the project itself. + if len(*seen) == 0 { + t.Fatal("no mx tool was invoked") + } + for _, s := range *seen { + if strings.HasPrefix(strings.Fields(s)[1], projectDir+string(filepath.Separator)) { + t.Errorf("mx was pointed at the project itself: %s", s) + } + } + ranUW := strings.HasPrefix((*seen)[0], "update-widgets ") + if ranUW != tc.wantUWOnACopy { + t.Errorf("update-widgets ran = %v, want %v (%v)", ranUW, tc.wantUWOnACopy, *seen) + } + + // The output names the project, not the copy, and says what happened. + got := out.String() + if strings.Contains(got, tmpRoot) { + t.Errorf("output leaks the temporary copy's path:\n%s", got) + } + if !strings.Contains(got, "Loading "+mprPath) { + t.Errorf("mx output not reported against the original path:\n%s", got) + } + if tc.wantUWOnACopy && !strings.Contains(got, "normalised on a temporary copy") { + t.Errorf("output does not say widgets were normalised on a copy:\n%s", got) + } + + // The copy is cleaned up. + if entries, _ := os.ReadDir(tmpRoot); len(entries) != 0 { + t.Errorf("temporary copy left behind in %s: %v", tmpRoot, entries) + } + }) + } +} + +// TestCopyProjectForCheck_SkipsOutputs pins that build output, VCS metadata and +// caches are not copied: a large project's deployment/ or .git can dwarf the +// model, and mx check needs neither. +func TestCopyProjectForCheck_SkipsOutputs(t *testing.T) { + src := t.TempDir() + for _, f := range []string{ + "App.mpr", "mprcontents/ab/cd/x.mxunit", "widgets/W.mpk", + "themesource/m/web/main.scss", "theme/web/main.scss", "javasource/a/B.java", + "deployment/run/x.jar", ".git/HEAD", "releases/App.mda", "theme-cache/web/t.css", + ".mxcli/catalog.db", "theme/node_modules/pkg/index.js", ".mendix-cache/x", + } { + p := filepath.Join(src, f) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(f), 0o644) + } + dst := t.TempDir() + if err := copyProjectTree(src, dst); err != nil { + t.Fatal(err) + } + for _, f := range []string{"App.mpr", "mprcontents/ab/cd/x.mxunit", "widgets/W.mpk", "themesource/m/web/main.scss", "theme/web/main.scss", "javasource/a/B.java"} { + if _, err := os.Stat(filepath.Join(dst, f)); err != nil { + t.Errorf("%s not copied: %v", f, err) + } + } + for _, f := range []string{"deployment", ".git", "releases", "theme-cache", ".mxcli", "theme/node_modules", ".mendix-cache"} { + if _, err := os.Stat(filepath.Join(dst, f)); err == nil { + t.Errorf("%s was copied; it is output, VCS metadata or a cache", f) + } + } +} diff --git a/cmd/mxcli/docker/update_widgets.go b/cmd/mxcli/docker/update_widgets.go index 189e3f9275..91c6bfc555 100644 --- a/cmd/mxcli/docker/update_widgets.go +++ b/cmd/mxcli/docker/update_widgets.go @@ -62,7 +62,11 @@ var updateWidgetsCmd = func(mxPath, pathArg string, w, stderr io.Writer) error { // // This lives on the operation, not on a call site, because it was previously // implemented in `Check` only — `Build` carried its own bare invocation and kept -// converting projects (mendixlabs/mxcli#763, then #808). +// converting projects (mendixlabs/mxcli#763, then #808). `Check` no longer uses +// it: it runs update-widgets on a temporary copy (copyProjectForCheck), because a +// check must not modify the project at all — this restores only the v2 storage, +// and an MPRv1 project was rewritten permanently (ako/mxcli#951). `Build` is +// expected to write the project's deployment/, and still uses it. func runUpdateWidgets(mxPath, projectPath string, w, stderr io.Writer) (restore func()) { restore = func() {} if projectPath == "" { diff --git a/docs-site/src/guides/marketplace.md b/docs-site/src/guides/marketplace.md index ef1f957d2b..97242be174 100644 --- a/docs-site/src/guides/marketplace.md +++ b/docs-site/src/guides/marketplace.md @@ -313,7 +313,7 @@ Re-running is free: a second run reports 0 units changed, because [idempotent wr |---|---|---| | `mxcli fix widgets` | **yes** | the fix — after any headless install | | `mxcli fix design-properties` | **yes** | the fix — after any headless install | -| `mxcli docker check` | no | runs the widget resync under a snapshot so the *check* is not tripped by CE0463; the stored model stays stale | +| `mxcli docker check` | no | runs the widget resync on a temporary copy so the *check* is not tripped by CE0463; the stored model stays stale (`--no-update-widgets` checks it as stored) | | `mxcli widget sync` | yes, partial | reconciles widget schemas in mxcli's own code; clears 7 of 40 on the reference fixture | CE6087 is distinct from `CE6083`, which is a *missing* design-property declaration and is fixed by installing everything the package ships — something `install` and `update` already do. From d94fcfbee885f1d90575af9d96a3cec6fb8348a8 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:23:37 +0000 Subject: [PATCH 05/18] fix(docker): refuse a missing project and never copy the check copy into itself (#951) Check copies the project's directory, so a missing .mpr (or a path in /tmp) would copy an unrelated directory: fail early instead, and give the existing fake-mx tests a project file of their own. With TMPDIR inside the project the walk met its own copy and recursed until the path was too long; skip it. Trim the custom-widgets skill back under the 700-line limit. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/mendix/custom-widgets/SKILL.md | 9 ++--- cmd/mxcli/docker/check_copy.go | 11 +++++- cmd/mxcli/docker/check_readonly_test.go | 36 +++++++++++++++++++ cmd/mxcli/docker/check_test.go | 17 +++++++-- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/.claude/skills/mendix/custom-widgets/SKILL.md b/.claude/skills/mendix/custom-widgets/SKILL.md index 96b2087562..5073ff1b55 100644 --- a/.claude/skills/mendix/custom-widgets/SKILL.md +++ b/.claude/skills/mendix/custom-widgets/SKILL.md @@ -304,12 +304,9 @@ MDL032). **CE0463 "update this widget" is EXPECTED after generating charts.** mxcli writes the WidgetType from an embedded 11.6 baseline; the installed Charts.mpk is a different version, so Studio Pro/mxbuild flags drift. Clear it with **`mxcli fix -widgets`**, which normalizes the stored widgets and preserves your storage format. -`mxcli docker check` normalizes only a temporary copy before checking, so it reports -0 errors while the stored project still fails `run --local`; `docker check ---no-update-widgets` checks the project as stored. The whole -`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` builds **0 errors** after -normalization. +widgets`** (keeps your storage format); `docker check` only normalizes a temp copy, +so check the stored project with `--no-update-widgets`. The whole +`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` builds **0 errors** after. **Do NOT run bare `mx update-widgets` on an MPRv2 project** (an `mprcontents/`-folder project — what `mxcli new` creates): it converts the project to single-file v1 and **deletes `mprcontents/`**, corrupting git, breaking a running `mxcli run --local` diff --git a/cmd/mxcli/docker/check_copy.go b/cmd/mxcli/docker/check_copy.go index 3cb65bdf23..4c9106749a 100644 --- a/cmd/mxcli/docker/check_copy.go +++ b/cmd/mxcli/docker/check_copy.go @@ -4,6 +4,7 @@ package docker import ( "bytes" + "fmt" "io" "io/fs" "os" @@ -31,6 +32,11 @@ func copyProjectForCheck(mprPath string) (workMpr string, cleanup func(), err er if err != nil { return "", cleanup, err } + if info, err := os.Stat(abs); err != nil { + return "", cleanup, err + } else if info.IsDir() { + return "", cleanup, fmt.Errorf("%s is a directory, not a project file", abs) + } tmp, err := os.MkdirTemp("", "mxcli-check-*") if err != nil { return "", cleanup, err @@ -79,6 +85,10 @@ func copyProjectTree(src, dst string) error { } target := filepath.Join(dst, rel) if d.IsDir() { + if p == dst || p == filepath.Dir(dst) { + // $TMPDIR inside the project: do not copy the copy into itself. + return filepath.SkipDir + } if rel != "." && (checkCopySkipAnywhere[d.Name()] || (!strings.ContainsRune(rel, filepath.Separator) && checkCopySkipRoot[rel])) { return filepath.SkipDir } @@ -155,4 +165,3 @@ func (r *pathRewriter) rewrite(s string) string { } return s } - diff --git a/cmd/mxcli/docker/check_readonly_test.go b/cmd/mxcli/docker/check_readonly_test.go index 21571a6805..edf4a717df 100644 --- a/cmd/mxcli/docker/check_readonly_test.go +++ b/cmd/mxcli/docker/check_readonly_test.go @@ -237,3 +237,39 @@ func TestCopyProjectForCheck_SkipsOutputs(t *testing.T) { } } } + +// TestCopyProjectForCheck_TempDirInsideProject: with $TMPDIR inside the project +// the walk meets its own copy; it must not copy the copy into itself. +func TestCopyProjectForCheck_TempDirInsideProject(t *testing.T) { + proj := t.TempDir() + mpr := filepath.Join(proj, "App.mpr") + if err := os.WriteFile(mpr, []byte("model"), 0o644); err != nil { + t.Fatal(err) + } + tmp := filepath.Join(proj, "tmp") + if err := os.MkdirAll(tmp, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("TMPDIR", tmp) + work, cleanup, err := copyProjectForCheck(mpr) + if err != nil { + t.Fatalf("copyProjectForCheck: %v", err) + } + defer cleanup() + if b, err := os.ReadFile(work); err != nil || string(b) != "model" { + t.Fatalf("copy of the model = %q, %v", b, err) + } + filepath.WalkDir(filepath.Dir(work), func(p string, d fs.DirEntry, err error) error { + if err == nil && strings.HasPrefix(d.Name(), "mxcli-check-") { + t.Errorf("the copy contains a copy of itself: %s", p) + return filepath.SkipDir + } + return nil + }) +} + +func TestCopyProjectForCheck_MissingProject(t *testing.T) { + if _, _, err := copyProjectForCheck(filepath.Join(t.TempDir(), "nope.mpr")); err == nil { + t.Error("want an error for a project file that does not exist") + } +} diff --git a/cmd/mxcli/docker/check_test.go b/cmd/mxcli/docker/check_test.go index 9ce1d2a4aa..b1ee6d7163 100644 --- a/cmd/mxcli/docker/check_test.go +++ b/cmd/mxcli/docker/check_test.go @@ -157,7 +157,7 @@ func TestCheck_UpdateWidgetsBeforeCheck(t *testing.T) { var stdout, stderr bytes.Buffer opts := CheckOptions{ - ProjectPath: "/tmp/fake.mpr", + ProjectPath: fakeProject(t), MxBuildPath: mxDir, Stdout: &stdout, Stderr: &stderr, @@ -195,7 +195,7 @@ func TestCheck_SkipUpdateWidgetsFlag(t *testing.T) { var stdout, stderr bytes.Buffer opts := CheckOptions{ - ProjectPath: "/tmp/fake.mpr", + ProjectPath: fakeProject(t), MxBuildPath: mxDir, SkipUpdateWidgets: true, Stdout: &stdout, @@ -251,6 +251,7 @@ func TestCheck_UpdateWidgetsReceivesAbsolutePath(t *testing.T) { var stdout, stderr bytes.Buffer // Bare filename — the crash trigger. + t.Chdir(filepath.Dir(fakeProject(t))) Check(CheckOptions{ProjectPath: "fake.mpr", MxBuildPath: dir, Stdout: &stdout, Stderr: &stderr}) logBytes, err := os.ReadFile(logFile) @@ -304,3 +305,15 @@ func TestResolveMxForVersion_PrefersExactCachedVersion(t *testing.T) { t.Errorf("expected exact cached mx %s, got %s", expected, result) } } + +// fakeProject creates an (empty) project file in its own directory. Check copies +// the project's directory before running mx (ako/mxcli#951), so the file has to +// exist, and a directory such as /tmp would be copied whole. +func fakeProject(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "fake.mpr") + if err := os.WriteFile(p, nil, 0o644); err != nil { + t.Fatal(err) + } + return p +} From 8964f2860303eaae0819172e53d0ca3aa0ab2a9d Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:26:11 +0000 Subject: [PATCH 06/18] fix(check): MDL-BUTTON01 skips a grid nested in a data container A control-bar button of a data grid nested inside a data view or list view passing $currentObject was refused as CE1571, but there the control bar's $currentObject is the enclosing object and mxbuild 11.13 builds it clean. The walk now carries whether an ancestor data container supplies an object context; the grid's own data source still never scopes its control bar, so a top-level grid is still reported. Part of #953 (item 2). Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + .../mendix/create-page/reference/widgets.md | 4 +- CHANGELOG.md | 1 + mdl/executor/validate_page_button_context.go | 37 +++++++++++++++---- .../validate_page_button_context_test.go | 34 +++++++++++++++++ 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index b9b64c0ccd..3fbddf994a 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -844,3 +844,4 @@ {"date": "2026-10-03", "area": "mdl/executor/drop", "symptom": "`drop microflow M.F;` in one `mxcli exec` run and `create microflow M.F …` in the next leaves M.F with no module-role grants (CE0106 on the pages calling it); the drop printed only \"Dropped microflow: M.F\"", "cause": "the grants carry only through the session cache (rememberDroppedMicroflow / consumeDroppedMicroflow), which a later process does not have; nothing told the user the carry was session-scoped. `drop page` never remembers its AllowedRoles at all", "fix": "writeDroppedGrantsNote (mdl/executor/drop_grants_note.go), called from execDropMicroflow / execDropNanoflow / execDropPage, prints the removed roles, whether a create carries them (same script or session for flows; never for a page), and the `grant` that restores them; flowRefusal's rebuild advice says drop + create in the same script", "insight": "A carry that lives in a session cache is invisible at the statement that creates it; the place to say so is the drop, which is the last moment the roles are known. Snippets have no access roles, so they need nothing", "issue": "ako/mxcli#944", "file": "mdl/executor/drop_grants_note.go; cmd_microflows_drop.go; cmd_nanoflows_drop.go; cmd_pages_builder.go; flow_verdict.go", "test": "mdl/executor/drop_grants_note_test.go; flow_verdict_test.go (TestFlowRefusalNamesTheFlowAndTheReason)"} {"date": "2026-10-03", "area": "mdl/executor/settings", "symptom": "after `alter settings language (DefaultLanguageCode: 'de_DE')`, `docker check` fails with CE4899 \"Empty caption. [German, Germany]\" at Tab page 'tabPage2' (Administration.Account_Overview, en_US only) while `check -p --references`, `lint` and exec are silent; a page created AFTER the switch in the same script fails the same way", "cause": "nothing compared required captions with DefaultLanguageCode (QUAL005 compares languages with each other, and `mxcli lint` does not even run it); and describeDefaultLanguage cached the authoring language once per session, so the switch did not reach later creates", "fix": "translations.MissingRequiredCaptions (measured set: Forms$TabPage.Caption in pages, snippets, layouts; templates and building blocks skipped) feeds lint QUAL006, the note printed by alterSettings (defaultLanguageChanged, which also drops the cached authoring language) and check -p MDL-I18N01 (CheckDefaultLanguageCaptions simulates which documents the script writes before/after the switch)", "insight": "Measure which caption kinds the build requires before flagging: of eleven kinds written en_US-only, only the tab page caption failed; flagging the rest would have made an error rule wrong ten times out of eleven. The first lint run also flagged 22 page-template tab pages mxbuild never reported, caught only by comparing lint's count with docker check's (1 vs 1 after the fix, 3 vs 3 on the e2e script)", "issue": "ako/mxcli#944", "file": "mdl/translations/required.go; mdl/linter/rules/required_captions.go; mdl/executor/default_language_captions.go; mdl/executor/cmd_settings.go (defaultLanguageChanged)", "test": "mdl/translations/required_test.go; mdl/linter/rules/required_captions_test.go; mdl/executor/default_language_captions_test.go"} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#944: describe printed `clear $L;` and `set $L = $M;` for a Change list Clear/Set (Replace) action; `clear` did not parse, and `set` on a list variable executed to a Change variable action that passes mxcli check but mx check refuses (CE7247 \"Variable 'A' does not have a primitive type\")", "cause": "the grammar had only add/remove for Change list, and the builder routed every `set $X = …` to ChangeVariableAction regardless of the target's type", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (clearListStatement), `mdl/executor/cmd_microflows_builder_graph.go` (MfSetStmt → addReplaceListAction when isListVariable)", "insight": "A describe → exec round trip that compares only MDL text passes when the action TYPE changes but prints the same — the Set revert check stayed green until the test also read the stored $Type/Type from the unit. Assert the stored shape, not just the re-described text", "refs": ["ako/mxcli#944"]} +{"area": "mdl/executor", "date": "2026-10-03", "symptom": "`check` refuses (MDL-BUTTON01, an error, so `exec` refuses too) a control-bar button of a data grid nested inside a data view or a list view passing `$currentObject`, which mxbuild 11.13 builds with 0 errors; the same grid at top level is CE1571 and correctly flagged", "cause": "`checkButtonContextTree` carried only 'inside a control bar of X', never whether an ancestor data container already supplies an object; the control bar of a nested grid inherits the ENCLOSING object as `$currentObject`", "file": "`mdl/executor/validate_page_button_context.go` (`checkButtonContextTree`, `isObjectContextContainer`)", "insight": "The grid's own data source never scopes its control bar, but its ancestors' do: carry an inContext flag that a data view / list view / gallery / grid sets for its non-control-bar children, and pass the PARENT's context (not the grid's) into the control bar. Measured both containers and the top-level control on the same page, so the rule kept its true positive", "refs": ["ako/mxcli#953"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index 736c3c711f..ba5e87430f 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -1013,7 +1013,9 @@ controlbar { **A control bar is not row-scoped.** It sits above the rows, so the grid's current object is not in scope there — an action calling a flow with a parameter gets **CE1571** unless you give it an argument, and `$currentObject` is unbound -(`mxcli check` reports MDL-BUTTON01). The remedy is the grid's **selection**, +(`mxcli check` reports MDL-BUTTON01). The exception is a grid nested inside a +data view or list view item: there `$currentObject` is the *enclosing* object, +not a grid row, and it builds clean. The remedy is the grid's **selection**, addressed by the widget's own name and available once `selection:` is set: ```sql diff --git a/CHANGELOG.md b/CHANGELOG.md index f78ba22a38..f0b939c4fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`check` no longer refuses a control-bar button of a grid nested in a data view or list view** (ako/mxcli#953) — **MDL-BUTTON01** fired when such a button passed `$currentObject`, which there is the enclosing object and builds clean (measured, mxbuild 11.13). A grid with no enclosing data container is still CE1571 and still reported. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". - **A required caption with no text in the default language is foreseen** (ako/mxcli#944) — making de_DE the default left a stock app's `Administration.Account_Overview` `tabPage2` (en_US only) empty in de_DE, and mxbuild refused it with CE4899 "Empty caption. [German, Germany]" while `check -p`, `lint` and `exec` said nothing. Measured on 11.14, the tab page caption is the one caption kind the build requires (page titles, buttons, labels, group boxes, column headers, menu items, enumeration captions and messages build without it; page templates and building blocks are not checked). Now: **lint rule QUAL006** (error) lists every tab page caption without the default language; **`alter settings language (DefaultLanguageCode: …)`** prints how many there are and where, with the `alter page … { set (Caption: …) on … }` that fixes one; **`check -p` reports MDL-I18N01** for a script that changes the default — stored captions, and captions the script wrote before the change. And a page created **after** the change in the same script is now written in the new default: the authoring language was resolved once per session, so it was still written in the old one and failed the build too. diff --git a/mdl/executor/validate_page_button_context.go b/mdl/executor/validate_page_button_context.go index 4155c4b097..fe9071b091 100644 --- a/mdl/executor/validate_page_button_context.go +++ b/mdl/executor/validate_page_button_context.go @@ -18,47 +18,68 @@ import ( // ValidatePageButtonContext warns (MDL-BUTTON01) when a button inside a control // bar passes $currentObject to its action. A control bar sits above the grid and // is not bound to a row, so $currentObject is unbound there — MxBuild reports -// CE1571. Row-scoped buttons (inside a grid column / list item) are fine. +// CE1571. Row-scoped buttons (inside a grid column / list item) are fine, and so +// is a control bar of a grid nested inside a data view or list view item, where +// $currentObject is the enclosing object (ako/mxcli#953). func ValidatePageButtonContext(prog *ast.Program) []linter.Violation { var out []linter.Violation for _, stmt := range prog.Statements { if label, widgets, ok := documentWidgets(stmt); ok { - out = append(out, checkButtonContextTree(widgets, "", label)...) + out = append(out, checkButtonContextTree(widgets, "", false, label)...) } } return out } // checkButtonContextTree walks the widget tree, carrying the name of the data -// widget whose control bar it is inside ("" when it is not inside one). +// widget whose control bar it is inside ("" when it is not inside one), and +// whether an enclosing data container already supplies an object context. // // The name is carried rather than just a flag because it IS the remedy: a data // widget's selection is addressed by the widget's own name, so `$dgMaterials` is // only spellable from here. Advice that stops at "move it into a column" sends // an author looking for syntax that does not need to exist — which is how // mendixlabs/mxcli#1082 was filed. -func checkButtonContextTree(widgets []*ast.WidgetV3, controlBarOf, locationPrefix string) []linter.Violation { +// +// inContext is the enclosing-container half (ako/mxcli#953): a grid nested in a +// data view, a list view item or a grid column sits in that container's object +// context, and its control bar's $currentObject is that object — mxbuild builds +// it clean. The grid's OWN data source never scopes its control bar, so the +// control bar inherits the context from above the grid, not from the grid. +func checkButtonContextTree(widgets []*ast.WidgetV3, controlBarOf string, inContext bool, locationPrefix string) []linter.Violation { var out []linter.Violation for _, w := range widgets { if w == nil { continue } - if controlBarOf != "" { + if controlBarOf != "" && !inContext { if a := w.GetAction(); a != nil { out = append(out, checkControlBarAction(a, w.Name, controlBarOf, locationPrefix)...) } } + childContext := inContext || isObjectContextContainer(w) for _, c := range w.Children { - childOf := controlBarOf + childOf, ctx := controlBarOf, childContext if c != nil && strings.EqualFold(c.Type, "controlbar") { - childOf = w.Name + childOf, ctx = w.Name, inContext } - out = append(out, checkButtonContextTree([]*ast.WidgetV3{c}, childOf, locationPrefix)...) + out = append(out, checkButtonContextTree([]*ast.WidgetV3{c}, childOf, ctx, locationPrefix)...) } } return out } +// isObjectContextContainer reports whether w gives its (non-control-bar) +// children a current object: a data view's object, a list view or gallery +// item, a grid row (column content). +func isObjectContextContainer(w *ast.WidgetV3) bool { + switch strings.ToLower(w.Type) { + case "dataview", "listview", "gallery", "templategrid", "datagrid", "datagrid2": + return true + } + return false +} + // checkControlBarAction flags any $currentObject argument on an action (and its // chained THEN action) that sits inside a control bar. func checkControlBarAction(a *ast.ActionV3, widgetName, controlBarOf, locationPrefix string) []linter.Violation { diff --git a/mdl/executor/validate_page_button_context_test.go b/mdl/executor/validate_page_button_context_test.go index 6663d5fa73..b2a7cf59b8 100644 --- a/mdl/executor/validate_page_button_context_test.go +++ b/mdl/executor/validate_page_button_context_test.go @@ -101,3 +101,37 @@ func TestValidatePageButtonContext_SuggestionNamesTheGridSelection(t *testing.T) t.Errorf("message should name the widget carrying the action:\n%s", vs[0].Message) } } + +// ako/mxcli#953 item 2: a grid nested inside a data view (or a list view item) +// sits in that container's object context, so a control-bar button passing +// $currentObject is bound to the ENCLOSING object — mxbuild 11.13 builds it with +// 0 errors. Only a grid with no enclosing data container is CE1571. +// Measured on the JTSBootLogboek app: btnOpen (nested) clean, btnOpen2 (top +// level, same page) CE1571. +func TestValidatePageButtonContext_NestedInDataContainerClean(t *testing.T) { + for _, container := range []string{ + "dataview dv1 (DataSource: $Order)", + "listview lv1 (DataSource: database from P.Order)", + } { + src := `create page P.Detail ( Title: 'Order', Layout: Atlas_Core.Atlas_Default, Params: ( $Order: P.Order ) ) { + ` + container + ` { + datagrid dgInner ( DataSource: database from P.Line ) { + column Qty (Attribute: Qty) { } + controlbar cb1 { + actionbutton btnOpen (Caption: 'Open', Action: show_page P.Detail (Order: $currentObject)) + } + } + } + datagrid dgTop ( DataSource: database from P.Line ) { + column Qty (Attribute: Qty) { } + controlbar cb2 { + actionbutton btnOpen2 (Caption: 'Open', Action: show_page P.Detail (Order: $currentObject)) + } + } +};` + msgs := buttonContextMessages(t, src) + if len(msgs) != 1 || !strings.Contains(msgs[0], "btnOpen2") { + t.Fatalf("%s: expected only the top-level btnOpen2 flagged (control), got %v", container, msgs) + } + } +} From a423d4640d1688a0d468f4229f8191a2c69193ab Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:26:21 +0000 Subject: [PATCH 07/18] fix(lint): MPR012 and MDL-WIDGET40 stay off native pages CE0582 is the React client's error. Measured on mxbuild 11.13.0: a staticimage and a classic dropdown on a NativePhone_Default page build clean, the same widgets on an Atlas_Default page are CE0582. A textbox on an enumeration is CE2421 on both, so MDL-WIDGET39 is unchanged. Nothing recorded a layout's platform: ListLayouts left Layout.Native false for every layout, and LayoutType cannot tell the platforms apart. - backend: Native is the content wrapper's type (NativeLayoutContent) - catalog: layouts.Platform ("Web" / "Native"), schema 18 - linter: NativePages() joins pages.LayoutRef to layouts without the Marketplace filter (the native layouts live in Atlas_Core); MPR012 skips those pages; Starlark layout.platform - check: MDL-WIDGET40 skips a page whose layout resolves as native Part of #953 (item 3). Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/mdl-other.jsonl | 1 + .../mendix/create-page/reference/widgets.md | 4 +- .../mendix/write-lint-rules/catalog-tables.md | 1 + CHANGELOG.md | 1 + docs-site/src/internals/catalog-schema.md | 15 ++++ docs-site/src/tools/catalog-tables.md | 23 ++++++ mdl/backend/modelsdk/page.go | 8 +++ mdl/backend/modelsdk/page_layouttype_test.go | 20 ++++++ mdl/catalog/builder_layout_platform_test.go | 71 +++++++++++++++++++ mdl/catalog/builder_pages.go | 24 ++++++- .../lint_rule_doc_catalog_tables_test.go | 1 + mdl/catalog/tables.go | 8 ++- mdl/executor/validate.go | 4 +- .../validate_widget_attribute_scope.go | 4 +- .../validate_widget_attribute_scope_test.go | 6 +- .../validate_widget_attribute_type.go | 26 +++++++ .../validate_widget_attribute_type_test.go | 40 ++++++++++- mdl/linter/context_catalog_tables.go | 40 ++++++++++- mdl/linter/rules/legacy_image_widget.go | 11 +++ mdl/linter/rules/legacy_image_widget_test.go | 54 +++++++++++++- mdl/linter/starlark_catalog_tables.go | 1 + mdl/linter/starlark_catalog_tables_test.go | 10 +-- 22 files changed, 351 insertions(+), 22 deletions(-) create mode 100644 mdl/catalog/builder_layout_platform_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-other.jsonl b/.claude/skills/fix-issue/findings/mdl-other.jsonl index 261f84b4f3..7177eb7307 100644 --- a/.claude/skills/fix-issue/findings/mdl-other.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-other.jsonl @@ -90,3 +90,4 @@ {"area": "mdl/catalog", "date": "2026-10-03", "symptom": "`delete $Order` / `change $Order (…)` inside `loop $Order in $Orders` produce no refs row (no delete/change edge to the entity), while `delete $Orders` on the list does; same for the output of `retrieve $Cust from $Order/Mod.Assoc`. show references / impact under-report batch flows.", "cause": "buildVarEntityMap seeded only object/list parameters and create / database-retrieve outputs, from a flattened action list that had already lost the loop's IterableList; loop iterators and association-retrieve outputs never got an entity, so microflowVarActionRef could not resolve them.", "file": "`mdl/catalog/builder_references.go` (buildVarEntityMap, associationTarget, associationEnds)", "insight": "Walk the object collection, not the flattened actions: the iterator's type lives on the LoopedActivity. Objects is not flow order, so map to a fixpoint (first assignment wins, which also bounds it). An association retrieve's output is the OTHER end from the start variable's entity; when the start is neither end (a specialization) leave it unmapped rather than guess. Control in the test: the list delete that always resolved.", "refs": ["mendixlabs/mxcli#1266"]} {"area": "mdl/linter", "date": "2026-10-03", "symptom": "lint CONV013 reports \"Java action call ... uses '' error handling instead of Custom\" on calls that have `on error { \u2026 }`, and CONV014 never fires on `on error continue` on an action", "cause": "Both rules read BaseActivity.ErrorHandlingType, which the model reader never fills: Mendix stores an action activity's error handling on the ACTION (Microflows$JavaActionCallAction.ErrorHandlingType). The '' in the message was the empty field", "file": "`mdl/linter/rules/conv_error_handling.go`; shared reader `sdk/microflows/error_handling.go` (`ObjectErrorHandlingType`)", "insight": "The unit tests had always set the activity field by hand, so they passed against a shape the reader never produces. Build test objects the way flowObjectFromGen does. Two private reflection helpers (executor DESCRIBE, MCP backend) already read the action correctly; the rules had a third, wrong copy. A '' interpolated into a diagnostic is the cheapest tell of a never-populated field", "refs": ["mendixlabs/mxcli#1202"], "rules": ["CONV013", "CONV014"]} {"area": "mdl/catalog", "date": "2026-10-03", "symptom": "activities table / activities_for() has no rows for anything inside a loop (nested loops included) in a microflow, nanoflow or rule; a Starlark rule cannot find a retrieve, commit or delete in a loop", "cause": "buildMicroflows had three near-copy loops (microflow, nanoflow, rule) over ObjectCollection.Objects that never recursed into LoopedActivity.ObjectCollection, although the reader fills it; countDecisionPoints beside them did recurse", "file": "`mdl/catalog/builder_microflows.go` (`insertFlowActivities`, `countFlowActivities`)", "insight": "Three copies of one walk is how the gap stayed in all three flavours. One shared walker writes ParentLoopId/LoopDepth; activities_for() keeps its top-level default (filtering ParentLoopId = '') so bundled rules such as CONV010 keep their counts, and ActivityCount keeps its top-level meaning beside a new TotalActivityCount. Raw SQL over activities now sees loop-body rows", "refs": ["mendixlabs/mxcli#1266"]} +{"area": "mdl/linter", "date": "2026-10-03", "symptom": "MPR012 (legacy static/dynamic image, CE0582) fires on pages with a native layout (Atlas_Core.NativePhone_Default), where mxbuild 11.13 builds them clean; `check --references` likewise refuses a classic `dropdown` on a native page as MDL-WIDGET40 (CE0582), also clean in mxbuild", "cause": "Both rules assume every page is rendered by the React client. Nothing recorded a layout's platform: ListLayouts left pages.Layout.Native false for every layout, and catalog layouts had only LayoutType, which cannot tell the platforms apart (native uses Default/Popup)", "file": "`mdl/backend/modelsdk/page.go` (`layoutIsNative`), `mdl/catalog/builder_pages.go` (layouts.Platform), `mdl/linter/context_catalog_tables.go` (`NativePages`), `mdl/linter/rules/legacy_image_widget.go`, `mdl/executor/validate_widget_attribute_type.go` (`layoutIsNative`)", "insight": "The platform is the content wrapper's TYPE (Forms$NativeLayoutContent), not a property. The native layouts live in Atlas_Core, a Marketplace module, so the page->layout join must not apply the notPlatformModule filter the iterators use. Sibling check MDL-WIDGET39 (CE2421, textbox on an enumeration) is NOT React-only: measured CE2421 on the native page too, so it keeps firing there", "refs": ["ako/mxcli#953"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index ba5e87430f..c55355aed3 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -749,7 +749,9 @@ the React client — which Mendix added in **10.7** and which is the only client widget, which takes the same `Image:`; Studio Pro offers the conversion from the CE0582 error's context menu. mxcli still writes it, because round-tripping a model that already contains one is the point — and `mxcli lint` reports it as -**MPR012** so a new page does not reach for it by accident. +**MPR012** so a new page does not reach for it by accident. A page on a **native** +layout (e.g. `Atlas_Core.NativePhone_Default`) is not rendered by the React client: +mxbuild builds a `staticimage` there clean, and MPR012 stays silent on it. #### `DataSource:` — which object a DYNAMICIMAGE shows diff --git a/.claude/skills/mendix/write-lint-rules/catalog-tables.md b/.claude/skills/mendix/write-lint-rules/catalog-tables.md index a079f79500..8b50f78549 100644 --- a/.claude/skills/mendix/write-lint-rules/catalog-tables.md +++ b/.claude/skills/mendix/write-lint-rules/catalog-tables.md @@ -96,6 +96,7 @@ Returned by `layouts()`. | `module_name` | string | `"Atlas_Core"` | | `folder` | string | Folder path within module | | `layout_type` | string | `"Responsive"`, `"Phone"`, `"Tablet"`, `"Popup"`, `"ModalPopup"`, `"Default"`, `"Legacy"` | +| `platform` | string | `"Web"` or `"Native"` — the platform the layout (and every page on it) renders on. `layout_type` cannot tell: a native popup shares its value with a web one. React-client errors (CE0582) apply to `"Web"` only | | `description` | string | Documentation text | ### published_rest_operation diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b939c4fe..7514101e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **MPR012 and MDL-WIDGET40 stay off native pages** (ako/mxcli#953) — CE0582 is the React client's error, and a page on a native layout builds a `staticimage` or a classic `dropdown` clean (measured, 11.13). The catalog's `layouts` table gains `Platform` (`Web` / `Native`), exposed to Starlark as `layout.platform`; catalog schema 18. MDL-WIDGET39 (CE2421) is not React-only and still applies to native pages. - **`check` no longer refuses a control-bar button of a grid nested in a data view or list view** (ako/mxcli#953) — **MDL-BUTTON01** fired when such a button passed `$currentObject`, which there is the enclosing object and builds clean (measured, mxbuild 11.13). A grid with no enclosing data container is still CE1571 and still reported. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". diff --git a/docs-site/src/internals/catalog-schema.md b/docs-site/src/internals/catalog-schema.md index 9f0ee7c711..c55e849c25 100644 --- a/docs-site/src/internals/catalog-schema.md +++ b/docs-site/src/internals/catalog-schema.md @@ -97,6 +97,21 @@ CREATE TABLE PAGES ( ); ``` +### LAYOUTS + +```text +CREATE TABLE LAYOUTS ( + Id TEXT PRIMARY KEY, + Name TEXT, + QualifiedName TEXT, + ModuleName TEXT, + Folder TEXT, + LayoutType TEXT, -- Responsive / Phone / Tablet / ModalPopup / Default / Popup + Platform TEXT, -- 'Web' or 'Native' (the content wrapper's type) + Description TEXT +); +``` + ### SNIPPETS ```sql diff --git a/docs-site/src/tools/catalog-tables.md b/docs-site/src/tools/catalog-tables.md index 8ee232a95f..6eb59ed312 100644 --- a/docs-site/src/tools/catalog-tables.md +++ b/docs-site/src/tools/catalog-tables.md @@ -217,6 +217,29 @@ There is no `DESCRIBE PAGE TEMPLATE`, so a template is indexed but not describable — tools that walk a module report it as *unknown*, never as unchanged. +### CATALOG.LAYOUTS + +Page layouts (`Forms$Layout`), including those in Marketplace modules such as +Atlas_Core. + +| Column | Description | +|--------|-------------| +| `Id` | Unique identifier | +| `Name` | Layout name | +| `QualifiedName` | Full qualified name | +| `ModuleName` | Module containing the layout | +| `Folder` | Folder path within the module | +| `LayoutType` | `Responsive`, `Phone`, `Tablet`, `ModalPopup` (web); `Default`, `Popup` (native) | +| `Platform` | `Web` or `Native` — the platform every page on this layout renders on. React-client errors such as CE0582 apply to `Web` pages only | +| `Description` | Documentation text | + +```sql +-- pages on a native layout (needs a full build for LayoutRef) +SELECT p.QualifiedName +FROM CATALOG.PAGES p JOIN CATALOG.LAYOUTS l ON l.QualifiedName = p.LayoutRef +WHERE l.Platform = 'Native'; +``` + ### CATALOG.ACCESS_RULES Information about entity access rules (available after full refresh). diff --git a/mdl/backend/modelsdk/page.go b/mdl/backend/modelsdk/page.go index 54148bddfa..580f899b12 100644 --- a/mdl/backend/modelsdk/page.go +++ b/mdl/backend/modelsdk/page.go @@ -240,6 +240,7 @@ func (b *Backend) ListLayouts() ([]*pages.Layout, error) { Name: u.Element.Name(), Documentation: u.Element.Documentation(), LayoutType: layoutTypeOf(u.Element), + Native: layoutIsNative(u.Element), Class: appearanceClassOf(u.Element), } l.ID = model.ID(u.Element.ID()) @@ -284,6 +285,13 @@ func layoutTypeOf(l *genPg.Layout) pages.LayoutType { return "" } +// layoutIsNative reports whether a layout is a native-mobile one, which is the +// TYPE of its content wrapper (Forms$NativeLayoutContent), not a property. +func layoutIsNative(l *genPg.Layout) bool { + _, ok := l.Content().(*genPg.NativeLayoutContent) + return ok +} + // GetLayout returns a single layout by ID (shallow). func (b *Backend) GetLayout(id model.ID) (*pages.Layout, error) { layouts, err := b.ListLayouts() diff --git a/mdl/backend/modelsdk/page_layouttype_test.go b/mdl/backend/modelsdk/page_layouttype_test.go index d992c72c25..46afe398be 100644 --- a/mdl/backend/modelsdk/page_layouttype_test.go +++ b/mdl/backend/modelsdk/page_layouttype_test.go @@ -38,3 +38,23 @@ func TestLayoutTypeOf_ReadsTheContentWrapper(t *testing.T) { t.Errorf("no content: got %q, want empty", got) } } + +// ako/mxcli#953: the platform is the content wrapper's TYPE, and the lint rules +// that predict React-client CE0582 need it — a native page builds a static +// image or a classic drop-down clean (measured, 11.13.0). ListLayouts left +// Native false for every layout, so nothing downstream could tell. +func TestLayoutIsNative_ReadsTheContentWrapperType(t *testing.T) { + l := genPg.NewLayout() + l.SetContent(genPg.NewNativeLayoutContent()) + if !layoutIsNative(l) { + t.Error("native content: want native") + } + w := genPg.NewLayout() + w.SetContent(genPg.NewWebLayoutContent()) + if layoutIsNative(w) { + t.Error("web content: want not native") + } + if layoutIsNative(genPg.NewLayout()) { + t.Error("no content: want not native") + } +} diff --git a/mdl/catalog/builder_layout_platform_test.go b/mdl/catalog/builder_layout_platform_test.go new file mode 100644 index 0000000000..cae00a57b6 --- /dev/null +++ b/mdl/catalog/builder_layout_platform_test.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#953: a layout's platform is what decides whether the React-client +// rules apply to a page at all — a native page builds a static image and a +// classic drop-down clean (measured, mxbuild 11.13.0). LayoutType cannot tell: +// native layouts store "Default" / "Popup", and "Popup" reads as a web value. +func TestLayoutPlatformColumn_Issue953(t *testing.T) { + const modID = model.ID("mod-l") + newLayout := func(id, name string, lt pages.LayoutType, native bool) *pages.Layout { + l := &pages.Layout{Name: name, LayoutType: lt, Native: native} + l.ID = model.ID(id) + l.ContainerID = modID + return l + } + cat, err := New() + if err != nil { + t.Fatal(err) + } + defer cat.Close() + tx, err := cat.CatalogDB().Begin() + if err != nil { + t.Fatal(err) + } + b := &Builder{ + catalog: cat, + reader: &mock.MockBackend{ + ListLayoutsFunc: func() ([]*pages.Layout, error) { + return []*pages.Layout{ + newLayout("l-web", "Atlas_Default", pages.LayoutTypeResponsive, false), + newLayout("l-native", "NativePhone_Default", pages.LayoutTypeDefault, true), + newLayout("l-native-popup", "NativePhone_PopOver", pages.LayoutTypePopup, true), + }, nil + }, + }, + snapshot: &Snapshot{ID: "snap"}, + hierarchy: &hierarchy{ + moduleIDs: map[model.ID]bool{modID: true}, + moduleNames: map[model.ID]string{modID: "L"}, + containerParent: map[model.ID]model.ID{}, + folderNames: map[model.ID]string{}, + }, + tx: tx, + } + if err := b.buildLayouts(); err != nil { + t.Fatalf("buildLayouts: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + got := queryStrings(t, cat, `SELECT QualifiedName, Platform FROM layouts_data`) + want := map[string]string{ + "L.Atlas_Default": "Web", + "L.NativePhone_Default": "Native", + "L.NativePhone_PopOver": "Native", + } + for qn, w := range want { + if got[qn] != w { + t.Errorf("%s Platform = %q, want %q", qn, got[qn], w) + } + } +} diff --git a/mdl/catalog/builder_pages.go b/mdl/catalog/builder_pages.go index 92e96a696d..bc23e38f3f 100644 --- a/mdl/catalog/builder_pages.go +++ b/mdl/catalog/builder_pages.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + "github.com/mendixlabs/mxcli/sdk/pages" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -764,6 +765,22 @@ func bytesToHex(data []byte) string { return string(result) } +// layoutPlatform is "Native" for a native-mobile layout and "Web" otherwise. +// Pages inherit it from their layout; the React-client CE0582 rules apply to +// web pages only (ako/mxcli#953). +func layoutPlatform(l *pages.Layout) string { + if l.Native { + return LayoutPlatformNative + } + return LayoutPlatformWeb +} + +// The values of layouts.Platform. +const ( + LayoutPlatformWeb = "Web" + LayoutPlatformNative = "Native" +) + func (b *Builder) buildLayouts() error { // Get all layouts layoutList, err := b.reader.ListLayouts() @@ -772,9 +789,9 @@ func (b *Builder) buildLayouts() error { } layoutStmt, err := b.tx.Prepare(` - INSERT INTO layouts_data (Id, Name, QualifiedName, ModuleName, Folder, LayoutType, Description, - ProjectId, SnapshotId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO layouts_data (Id, Name, QualifiedName, ModuleName, Folder, LayoutType, Platform, + Description, ProjectId, SnapshotId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -798,6 +815,7 @@ func (b *Builder) buildLayouts() error { moduleName, folder, string(l.LayoutType), + layoutPlatform(l), l.Documentation, projectID, snapshotID, ) diff --git a/mdl/catalog/lint_rule_doc_catalog_tables_test.go b/mdl/catalog/lint_rule_doc_catalog_tables_test.go index 327561436d..1aefc39daf 100644 --- a/mdl/catalog/lint_rule_doc_catalog_tables_test.go +++ b/mdl/catalog/lint_rule_doc_catalog_tables_test.go @@ -100,6 +100,7 @@ func TestSkillDocumentsRealCatalogTableVocabulary(t *testing.T) { domainmodel.EventTypeDelete, domainmodel.EventTypeRollback)}, {"layout", "layout_type", stringSet(pages.LayoutTypeResponsive, pages.LayoutTypeDefault, pages.LayoutTypeTablet, pages.LayoutTypePhone, pages.LayoutTypeModalPopup, pages.LayoutTypePopup, pages.LayoutTypeLegacy)}, + {"layout", "platform", stringSet(LayoutPlatformWeb, LayoutPlatformNative)}, {"published_rest_operation", "http_method", stringSet(metamodel.RestHTTPMethodGet, metamodel.RestHTTPMethodPost, metamodel.RestHTTPMethodPut, metamodel.RestHTTPMethodPatch, metamodel.RestHTTPMethodDelete, metamodel.RestHTTPMethodHead, metamodel.RestHTTPMethodOptions)}, diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index 75a9586203..e89ac53f71 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,11 @@ package catalog // // History: // +// 18 — layouts_data.Platform ("Web" / "Native"), the content wrapper's +// type. LayoutType cannot tell the platforms apart ("Popup" is native), +// and MPR012 needs it to stay off native pages, where CE0582 does not +// apply (ako/mxcli#953). Without the bump a cached catalog fails every +// layouts() with "no such column". // 17 (activities in loops and their properties): activities_data gains the // rows inside loop bodies, with ParentLoopId / LoopDepth, and the // property columns lint rules asked for (real Caption, Description, @@ -96,7 +101,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "17" +const CatalogSchemaVersion = "18" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. @@ -379,6 +384,7 @@ func (c *Catalog) createTables() error { ModuleName TEXT, Folder TEXT, LayoutType TEXT, + Platform TEXT, Description TEXT, ProjectId TEXT, SnapshotId TEXT diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index f2ca49a78e..cf61cbffd8 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -771,7 +771,7 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext } // A pluggable widget attribute of another entity than the one its // property binds to — the rule exec writes by (ako/mxcli#647). - if scopeErrors := validatePluggableAttributeScopes(ctx, s.Parameters, pageWidgets, sc); len(scopeErrors) > 0 { + if scopeErrors := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, pageWidgets, sc); len(scopeErrors) > 0 { return mdlerrors.NewValidationf("page '%s' has attribute binding errors:\n - %s", s.Name.String(), strings.Join(scopeErrors, "\n - ")) } @@ -815,7 +815,7 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext return mdlerrors.NewValidationf("snippet '%s' has context errors:\n - %s", s.Name.String(), strings.Join(ctxErrors, "\n - ")) } - if scopeErrors := validatePluggableAttributeScopes(ctx, s.Parameters, s.Widgets, sc); len(scopeErrors) > 0 { + if scopeErrors := validatePluggableAttributeScopes(ctx, "", s.Parameters, s.Widgets, sc); len(scopeErrors) > 0 { return mdlerrors.NewValidationf("snippet '%s' has attribute binding errors:\n - %s", s.Name.String(), strings.Join(scopeErrors, "\n - ")) } diff --git a/mdl/executor/validate_widget_attribute_scope.go b/mdl/executor/validate_widget_attribute_scope.go index b39a6beb3d..353de89d03 100644 --- a/mdl/executor/validate_widget_attribute_scope.go +++ b/mdl/executor/validate_widget_attribute_scope.go @@ -42,7 +42,7 @@ import ( // (MDL-WIDGET39, CE2421) and the classic drop-down on a React-client project // (MDL-WIDGET40, CE0582). See validate_widget_attribute_type.go. Those need // neither the widget registry nor the templates, so they run without them. -func validatePluggableAttributeScopes(ctx *ExecContext, params []ast.PageParameter, widgets []*ast.WidgetV3, sc *scriptContext) []string { +func validatePluggableAttributeScopes(ctx *ExecContext, layout string, params []ast.PageParameter, widgets []*ast.WidgetV3, sc *scriptContext) []string { if ctx == nil || !ctx.Connected() || len(widgets) == 0 { return nil } @@ -72,7 +72,7 @@ func validatePluggableAttributeScopes(ctx *ExecContext, params []ast.PageParamet pageParams: pageParams, index: checkAttributeIndex(ctx, sc), types: checkMemberTypeIndex(ctx, sc), - reactClient: usesReactClient(ctx), + reactClient: usesReactClient(ctx) && !layoutIsNative(ctx, layout), } for _, w := range widgets { v.walk(w, dataContext{}) diff --git a/mdl/executor/validate_widget_attribute_scope_test.go b/mdl/executor/validate_widget_attribute_scope_test.go index 301238f8f3..fead61bbf1 100644 --- a/mdl/executor/validate_widget_attribute_scope_test.go +++ b/mdl/executor/validate_widget_attribute_scope_test.go @@ -78,7 +78,7 @@ func scopeCheckPage(t *testing.T, props string) *ast.CreatePageStmtV3 { func TestCheck_AttributeOfAnotherScopeIsRefused(t *testing.T) { ctx := scopeCheckCtx(t) s := scopeCheckPage(t, `staticAttribute: Name`) - errs := validatePluggableAttributeScopes(ctx, s.Parameters, allPageWidgets(s), newScriptContext()) + errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), newScriptContext()) if len(errs) != 1 { t.Fatalf("want one binding error, got %q", errs) } @@ -109,7 +109,7 @@ func TestCheck_AttributesTheRulePlacesPass(t *testing.T) { `optionsSourceDatabaseValueAttribute: Name`, } { s := scopeCheckPage(t, props) - if errs := validatePluggableAttributeScopes(ctx, s.Parameters, allPageWidgets(s), newScriptContext()); len(errs) > 0 { + if errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), newScriptContext()); len(errs) > 0 { t.Errorf("%s: unexpected refusal %q", props, errs) } } @@ -120,7 +120,7 @@ func TestCheck_AttributesTheRulePlacesPass(t *testing.T) { func TestCheck_LinkedPropertyGivenTheEnclosingAttributeIsRefused(t *testing.T) { ctx := scopeCheckCtx(t) s := scopeCheckPage(t, `optionsSourceAssociationCaptionAttribute: Reference`) - errs := validatePluggableAttributeScopes(ctx, s.Parameters, allPageWidgets(s), newScriptContext()) + errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), newScriptContext()) if len(errs) != 1 || !strings.Contains(errs[0], "Sales.Order") || !strings.Contains(errs[0], "Sales.Invoice (the enclosing data container)") { t.Fatalf("want one refusal naming Sales.Order and the enclosing Sales.Invoice, got %q", errs) diff --git a/mdl/executor/validate_widget_attribute_type.go b/mdl/executor/validate_widget_attribute_type.go index 0c9b518d7d..7e4aab3de0 100644 --- a/mdl/executor/validate_widget_attribute_type.go +++ b/mdl/executor/validate_widget_attribute_type.go @@ -386,6 +386,32 @@ func usesReactClient(ctx *ExecContext) bool { return ps.WebUI.UseOptimizedClient == "Yes" } +// layoutIsNative reports whether the named layout (Module.Name) is a native +// one. A page on it is not rendered by the React client, so the React-only +// CE0582 does not apply: measured on 11.13.0, a classic drop-down on a +// NativePhone_Default page builds clean (ako/mxcli#953). An empty or +// unresolvable name — a snippet, a layout this script creates — reads as web, +// which keeps the check exactly as loud as it was. +func layoutIsNative(ctx *ExecContext, layout string) bool { + if ctx == nil || ctx.Backend == nil || layout == "" { + return false + } + h, err := getHierarchy(ctx) + if err != nil || h == nil { + return false + } + layouts, err := ctx.Backend.ListLayouts() + if err != nil { + return false + } + for _, l := range layouts { + if strings.EqualFold(h.GetQualifiedName(l.ContainerID, l.Name), layout) { + return l.Native + } + } + return false +} + // checkReactUnsupported refuses the classic drop-down on a React-client project // (MDL-WIDGET40). // diff --git a/mdl/executor/validate_widget_attribute_type_test.go b/mdl/executor/validate_widget_attribute_type_test.go index 526e288f9d..6d190a8f93 100644 --- a/mdl/executor/validate_widget_attribute_type_test.go +++ b/mdl/executor/validate_widget_attribute_type_test.go @@ -12,6 +12,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" ) // MDL-WIDGET39 (CE2421) and MDL-WIDGET40 (CE0582): two page errors only mxbuild @@ -95,7 +96,7 @@ func typeErrs(t *testing.T, ctx *ExecContext, body string, sc *scriptContext) [] if sc == nil { sc = newScriptContext() } - return validatePluggableAttributeScopes(ctx, s.Parameters, allPageWidgets(s), sc) + return validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), sc) } // The reported case: a text box on an enumeration attribute. @@ -207,7 +208,7 @@ create page Sales.P2 (title: 'P', layout: Atlas_Core.Atlas_Default, params: ($Sh sc := newScriptContext() sc.collectDefinitions(prog) s := prog.Statements[3].(*ast.CreatePageStmtV3) - errs := validatePluggableAttributeScopes(ctx, s.Parameters, allPageWidgets(s), sc) + errs := validatePluggableAttributeScopes(ctx, s.Layout, s.Parameters, allPageWidgets(s), sc) if len(errs) != 2 || !strings.Contains(strings.Join(errs, "\n"), "tbSize") || !strings.Contains(strings.Join(errs, "\n"), "tbShade") { t.Fatalf("want errors on tbSize and tbShade only, got %q", errs) } @@ -257,3 +258,38 @@ func TestCheck_AttributeTypeIsPartOfThePageReferencePass(t *testing.T) { t.Fatalf("want the page refused for MDL-WIDGET39, got %v", err) } } + +// ako/mxcli#953: CE0582 is the React client's, and a native page is not +// rendered by it. Measured on mxbuild 11.13.0 (UseOptimizedClient Yes): a +// classic drop-down on a NativePhone_Default page builds clean, the same +// drop-down on an Atlas_Default page is CE0582 (the control). MDL-WIDGET39 is +// NOT React-only — a textbox on an enumeration is CE2421 on the native page +// too — so it must keep firing there. +func TestCheck_ClassicDropDownOnNativePageIsClean(t *testing.T) { + ctx := typeCheckCtx(t, "Yes") + b := ctx.Backend.(*mock.MockBackend) + atlas := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-atlas")}, Name: "Atlas_Core"} + sales := &model.Module{BaseElement: model.BaseElement{ID: model.ID("mod-sales")}, Name: "Sales"} + b.ListModulesFunc = func() ([]*model.Module, error) { return []*model.Module{sales, atlas}, nil } + layout := func(id, name string, native bool) *pages.Layout { + l := &pages.Layout{ContainerID: atlas.ID, Name: name, Native: native} + l.ID = model.ID(id) + return l + } + b.ListLayoutsFunc = func() ([]*pages.Layout, error) { + return []*pages.Layout{layout("l-web", "Atlas_Default", false), layout("l-nat", "NativePhone_Default", true)}, nil + } + body := `dataview dv (datasource: $Thing) { dropdown ddColor (label: 'C', attribute: Color) }` + s := typeCheckPage(t, body) + + if errs := validatePluggableAttributeScopes(ctx, "Atlas_Core.Atlas_Default", s.Parameters, allPageWidgets(s), newScriptContext()); len(errs) != 1 || !strings.Contains(errs[0], "MDL-WIDGET40") { + t.Fatalf("control: web page should report MDL-WIDGET40, got %q", errs) + } + if errs := validatePluggableAttributeScopes(ctx, "Atlas_Core.NativePhone_Default", s.Parameters, allPageWidgets(s), newScriptContext()); len(errs) != 0 { + t.Fatalf("native page: want no error, got %q", errs) + } + enum := typeCheckPage(t, `dataview dv (datasource: $Thing) { textbox tbColor (label: 'C', attribute: Color) }`) + if errs := validatePluggableAttributeScopes(ctx, "Atlas_Core.NativePhone_Default", enum.Parameters, allPageWidgets(enum), newScriptContext()); len(errs) != 1 || !strings.Contains(errs[0], "MDL-WIDGET39") { + t.Fatalf("native page: MDL-WIDGET39 (CE2421) still applies, got %q", errs) + } +} diff --git a/mdl/linter/context_catalog_tables.go b/mdl/linter/context_catalog_tables.go index 3eea4fe66e..54d3a331b2 100644 --- a/mdl/linter/context_catalog_tables.go +++ b/mdl/linter/context_catalog_tables.go @@ -284,6 +284,7 @@ type Layout struct { ModuleName string Folder string LayoutType string // "Responsive", "Phone", "Tablet", "Popup", "ModalPopup", "Default", "Legacy" + Platform string // "Web" or "Native" — LayoutType alone cannot tell ("Popup" is native) Description string } @@ -292,7 +293,7 @@ func (ctx *LintContext) Layouts() iter.Seq[Layout] { return func(yield func(Layout) bool) { rows, err := ctx.db.Query(fmt.Sprintf(` SELECT l.Name, l.QualifiedName, l.ModuleName, COALESCE(l.Folder, ''), - COALESCE(l.LayoutType, ''), COALESCE(l.Description, '') + COALESCE(l.LayoutType, ''), COALESCE(l.Platform, ''), COALESCE(l.Description, '') FROM layouts l LEFT JOIN modules m ON l.ModuleName = m.Name WHERE %s AND %s @@ -307,7 +308,7 @@ func (ctx *LintContext) Layouts() iter.Seq[Layout] { for rows.Next() { var l Layout if err := rows.Scan(&l.Name, &l.QualifiedName, &l.ModuleName, &l.Folder, - &l.LayoutType, &l.Description); err != nil { + &l.LayoutType, &l.Platform, &l.Description); err != nil { ctx.recordQueryError("Layouts (row scan)", err) continue } @@ -321,6 +322,41 @@ func (ctx *LintContext) Layouts() iter.Seq[Layout] { } } +// NativePages returns the qualified names of the pages whose layout is a +// native-mobile one. React-client rules (CE0582) do not apply to them: a native +// page is not rendered by the React client, and mxbuild builds a legacy widget +// there clean (ako/mxcli#953). +// +// Unlike Layouts() it does NOT filter Marketplace modules out of the layouts: +// the native layouts almost every app uses live in Atlas_Core, and a page in a +// user module on one of them is the case that matters. The page's layout is +// recorded by a FULL catalog build only (pages.LayoutRef), so a rule calling +// this needs CatalogFull; on a fast catalog it returns an empty set, and the +// rule behaves as it did before. +func (ctx *LintContext) NativePages() map[string]bool { + out := map[string]bool{} + rows, err := ctx.db.Query(` + SELECT p.QualifiedName + FROM pages p + JOIN layouts l ON l.QualifiedName = p.LayoutRef + WHERE l.Platform = 'Native' + `) + if err != nil { + ctx.recordQueryError("NativePages", err) + return out + } + defer rows.Close() + for rows.Next() { + var qn string + if err := rows.Scan(&qn); err != nil { + ctx.recordQueryError("NativePages (row scan)", err) + continue + } + out[qn] = true + } + return out +} + // PublishedRestOperation is one operation of a published REST service. type PublishedRestOperation struct { Service string // qualified service name diff --git a/mdl/linter/rules/legacy_image_widget.go b/mdl/linter/rules/legacy_image_widget.go index d5f436764b..8a7f36258b 100644 --- a/mdl/linter/rules/legacy_image_widget.go +++ b/mdl/linter/rules/legacy_image_widget.go @@ -41,6 +41,11 @@ import ( // "this page still holds a widget your client cannot render" is exactly the // finding wanted, once. // +// Native pages are out of scope too: the React client renders web pages only, +// and mxbuild reports no CE0582 on a page whose layout is native (measured, +// 11.13.0, ako/mxcli#953). Snippets are still reported — a snippet records no +// layout, and whether a native snippet builds a legacy image clean is unmeasured. +// // Marketplace modules are already out of scope: ctx.Widgets() filters them // (notPlatformModule excludes any module with a Source), which is what keeps the // rule off the Studio Pro-authored static images a blank app inherits from @@ -94,6 +99,7 @@ func LegacyImageWidget(widgetType string) (LegacyImage, bool) { // Check reports one violation per legacy image widget found. func (r *LegacyImageWidgetRule) Check(ctx *linter.LintContext) []linter.Violation { var violations []linter.Violation + native := ctx.NativePages() for w := range ctx.Widgets() { if ctx.IsExcluded(w.ModuleName) { @@ -103,6 +109,11 @@ func (r *LegacyImageWidgetRule) Check(ctx *linter.LintContext) []linter.Violatio if !ok { continue } + // A native page is not rendered by the React client; mxbuild 11.13.0 + // builds a static image on a NativePhone_Default page clean (#953). + if w.ContainerType != "SNIPPET" && native[w.ContainerQualifiedName] { + continue + } docType := "page" if w.ContainerType == "SNIPPET" { diff --git a/mdl/linter/rules/legacy_image_widget_test.go b/mdl/linter/rules/legacy_image_widget_test.go index 41025a884e..3ceb8214ec 100644 --- a/mdl/linter/rules/legacy_image_widget_test.go +++ b/mdl/linter/rules/legacy_image_widget_test.go @@ -2,7 +2,13 @@ package rules -import "testing" +import ( + "database/sql" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" +) // The two legacy image widgets are not supported by the React client, which // Mendix added in 10.7 and which is the only client on 11. mxbuild reports @@ -76,3 +82,49 @@ func TestLegacyImageWidgetRule_Identity(t *testing.T) { t.Error("a rule with no name or description cannot be configured or explained") } } + +// ako/mxcli#953 item 3: CE0582 is the REACT client's error, and a native page +// is not rendered by it. Measured on mxbuild 11.13.0 (JTSBootLogboek): a +// staticimage on a page with layout Atlas_Core.NativePhone_Default builds +// clean, the same widget on an Atlas_Default page is CE0582. The layout's +// platform is what tells them apart — note that the native layout lives in a +// Marketplace module (Atlas_Core), so the lookup must not apply the +// platform-module filter the iterators use. The web page is the control. +func TestLegacyImageWidgetRule_SkipsNativePages(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, stmt := range []string{ + `CREATE TABLE modules (Id TEXT, Name TEXT PRIMARY KEY, Source TEXT)`, + `INSERT INTO modules VALUES ('m1', 'MyFirstModule', ''), ('m2', 'Atlas_Core', 'Atlas_Core.mpk')`, + `CREATE TABLE layouts (Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + LayoutType TEXT, Platform TEXT, Description TEXT)`, + `INSERT INTO layouts VALUES + ('l1', 'Atlas_Default', 'Atlas_Core.Atlas_Default', 'Atlas_Core', '', 'Responsive', 'Web', ''), + ('l2', 'NativePhone_Default', 'Atlas_Core.NativePhone_Default', 'Atlas_Core', '', 'Default', 'Native', '')`, + `CREATE TABLE pages (Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + Title TEXT, URL TEXT, LayoutRef TEXT, Description TEXT, WidgetCount INTEGER)`, + `INSERT INTO pages VALUES + ('p1', 'Logboek_Images', 'MyFirstModule.Logboek_Images', 'MyFirstModule', '', '', '', 'Atlas_Core.Atlas_Default', '', 1), + ('p2', 'Login_Native', 'MyFirstModule.Login_Native', 'MyFirstModule', '', '', '', 'Atlas_Core.NativePhone_Default', '', 1)`, + `CREATE TABLE widgets (Id TEXT, Name TEXT, WidgetType TEXT, ContainerId TEXT, ContainerQualifiedName TEXT, + ContainerType TEXT, ModuleName TEXT, EntityRef TEXT, AttributeRef TEXT, MicroflowRef TEXT, NanoflowRef TEXT)`, + `INSERT INTO widgets VALUES + ('w1', 'imgWith', 'Forms$StaticImageViewer', 'p1', 'MyFirstModule.Logboek_Images', 'PAGE', 'MyFirstModule', '', '', '', ''), + ('w2', 'imgNative', 'Forms$StaticImageViewer', 'p2', 'MyFirstModule.Login_Native', 'PAGE', 'MyFirstModule', '', '', '', '')`, + } { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + violations := NewLegacyImageWidgetRule().Check(linter.NewLintContextFromDB(catalog.WrapSqlDB(db))) + if len(violations) != 1 || violations[0].Location.DocumentName != "Logboek_Images" { + var got []string + for _, v := range violations { + got = append(got, v.Location.DocumentName) + } + t.Fatalf("want only the web page Logboek_Images reported, got %v", got) + } +} diff --git a/mdl/linter/starlark_catalog_tables.go b/mdl/linter/starlark_catalog_tables.go index 2bbe48bd9b..c52875e933 100644 --- a/mdl/linter/starlark_catalog_tables.go +++ b/mdl/linter/starlark_catalog_tables.go @@ -187,6 +187,7 @@ func layoutToStarlark(l Layout) starlark.Value { "module_name": starlark.String(l.ModuleName), "folder": starlark.String(l.Folder), "layout_type": starlark.String(l.LayoutType), + "platform": starlark.String(l.Platform), "description": starlark.String(l.Description), }) } diff --git a/mdl/linter/starlark_catalog_tables_test.go b/mdl/linter/starlark_catalog_tables_test.go index 763f9b5f98..ea6c7bec1d 100644 --- a/mdl/linter/starlark_catalog_tables_test.go +++ b/mdl/linter/starlark_catalog_tables_test.go @@ -61,10 +61,10 @@ func catalogTablesFixture(t *testing.T) *catalog.Catalog { IsIncluded, ProjectId, SnapshotId) VALUES (?,?,?,?,?,?,?,?,?)`, "j-"+mod, mod, "org.example", "lib", "org.example:lib", "1.2.3", 0, "p", "s") - exec(`INSERT INTO layouts_data (Id, Name, QualifiedName, ModuleName, Folder, LayoutType, Description, - ProjectId, SnapshotId) - VALUES (?,?,?,?,?,?,?,?,?)`, - "l-"+mod, "Atlas_Popup", mod+".Atlas_Popup", mod, "Layouts", "ModalPopup", "A popup", "p", "s") + exec(`INSERT INTO layouts_data (Id, Name, QualifiedName, ModuleName, Folder, LayoutType, Platform, + Description, ProjectId, SnapshotId) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + "l-"+mod, "Atlas_Popup", mod+".Atlas_Popup", mod, "Layouts", "ModalPopup", "Web", "A popup", "p", "s") exec(`INSERT INTO published_rest_operations_data (Id, ServiceId, ServiceQualifiedName, ResourceName, HttpMethod, Path, Summary, Microflow, Deprecated, ModuleName, ProjectId, SnapshotId) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, @@ -166,7 +166,7 @@ func TestCatalogTableBuiltins(t *testing.T) { {`strings("de_DE")`, nil}, {"layouts()", []string{ "description=A popup|folder=Layouts|layout_type=ModalPopup|module_name=Sales|name=Atlas_Popup|" + - "qualified_name=Sales.Atlas_Popup", + "platform=Web|qualified_name=Sales.Atlas_Popup", }}, {"published_rest_operations()", []string{ "deprecated=True|http_method=Get|microflow=Sales.PRS_GetOrder|module_name=Sales|path=/{id}|" + From c4a14bbc6de99e31967acff9e3ccba1484e3e705 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:26:32 +0000 Subject: [PATCH 08/18] fix(lint): MPR002 does not call a return-only flow empty A microflow or nanoflow whose only content is `return ;` computes its value in the end event, which ActivityCount does not count. A non-Void return type is the witness that the end event returns a value, so those flows are no longer reported. Part of #953 (item 4). Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/mdl-other.jsonl | 1 + CHANGELOG.md | 1 + mdl/linter/rules/empty.go | 11 ++++++- mdl/linter/rules/empty_test.go | 29 ++++++++++++++++++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.claude/skills/fix-issue/findings/mdl-other.jsonl b/.claude/skills/fix-issue/findings/mdl-other.jsonl index 7177eb7307..52b0369625 100644 --- a/.claude/skills/fix-issue/findings/mdl-other.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-other.jsonl @@ -91,3 +91,4 @@ {"area": "mdl/linter", "date": "2026-10-03", "symptom": "lint CONV013 reports \"Java action call ... uses '' error handling instead of Custom\" on calls that have `on error { \u2026 }`, and CONV014 never fires on `on error continue` on an action", "cause": "Both rules read BaseActivity.ErrorHandlingType, which the model reader never fills: Mendix stores an action activity's error handling on the ACTION (Microflows$JavaActionCallAction.ErrorHandlingType). The '' in the message was the empty field", "file": "`mdl/linter/rules/conv_error_handling.go`; shared reader `sdk/microflows/error_handling.go` (`ObjectErrorHandlingType`)", "insight": "The unit tests had always set the activity field by hand, so they passed against a shape the reader never produces. Build test objects the way flowObjectFromGen does. Two private reflection helpers (executor DESCRIBE, MCP backend) already read the action correctly; the rules had a third, wrong copy. A '' interpolated into a diagnostic is the cheapest tell of a never-populated field", "refs": ["mendixlabs/mxcli#1202"], "rules": ["CONV013", "CONV014"]} {"area": "mdl/catalog", "date": "2026-10-03", "symptom": "activities table / activities_for() has no rows for anything inside a loop (nested loops included) in a microflow, nanoflow or rule; a Starlark rule cannot find a retrieve, commit or delete in a loop", "cause": "buildMicroflows had three near-copy loops (microflow, nanoflow, rule) over ObjectCollection.Objects that never recursed into LoopedActivity.ObjectCollection, although the reader fills it; countDecisionPoints beside them did recurse", "file": "`mdl/catalog/builder_microflows.go` (`insertFlowActivities`, `countFlowActivities`)", "insight": "Three copies of one walk is how the gap stayed in all three flavours. One shared walker writes ParentLoopId/LoopDepth; activities_for() keeps its top-level default (filtering ParentLoopId = '') so bundled rules such as CONV010 keep their counts, and ActivityCount keeps its top-level meaning beside a new TotalActivityCount. Raw SQL over activities now sees loop-body rows", "refs": ["mendixlabs/mxcli#1266"]} {"area": "mdl/linter", "date": "2026-10-03", "symptom": "MPR012 (legacy static/dynamic image, CE0582) fires on pages with a native layout (Atlas_Core.NativePhone_Default), where mxbuild 11.13 builds them clean; `check --references` likewise refuses a classic `dropdown` on a native page as MDL-WIDGET40 (CE0582), also clean in mxbuild", "cause": "Both rules assume every page is rendered by the React client. Nothing recorded a layout's platform: ListLayouts left pages.Layout.Native false for every layout, and catalog layouts had only LayoutType, which cannot tell the platforms apart (native uses Default/Popup)", "file": "`mdl/backend/modelsdk/page.go` (`layoutIsNative`), `mdl/catalog/builder_pages.go` (layouts.Platform), `mdl/linter/context_catalog_tables.go` (`NativePages`), `mdl/linter/rules/legacy_image_widget.go`, `mdl/executor/validate_widget_attribute_type.go` (`layoutIsNative`)", "insight": "The platform is the content wrapper's TYPE (Forms$NativeLayoutContent), not a property. The native layouts live in Atlas_Core, a Marketplace module, so the page->layout join must not apply the notPlatformModule filter the iterators use. Sibling check MDL-WIDGET39 (CE2421, textbox on an enumeration) is NOT React-only: measured CE2421 on the native page too, so it keeps firing there", "refs": ["ako/mxcli#953"]} +{"area": "mdl/linter", "date": "2026-10-03", "symptom": "MPR002 'Microflow X has no activities' on a microflow or nanoflow whose only content is `return ;` (e.g. a label formatter, `return $currentUser;`)", "cause": "ActivityCount excludes start and end events, so a flow that computes its result in the end event's return value counts 0 activities", "file": "`mdl/linter/rules/empty.go` (`returnsValue`)", "insight": "A non-Void ReturnType is the catalog's witness that the end event returns a value (mxbuild requires it on every end event), so no new column was needed; '' and 'Void' stay reported", "refs": ["ako/mxcli#953"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7514101e57..74ff3ca824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **MPR002 no longer calls a return-only flow empty** (ako/mxcli#953) — a microflow or nanoflow whose only content is `return ;` computes its value in the end event; flows with a non-Void return type are not reported. - **MPR012 and MDL-WIDGET40 stay off native pages** (ako/mxcli#953) — CE0582 is the React client's error, and a page on a native layout builds a `staticimage` or a classic `dropdown` clean (measured, 11.13). The catalog's `layouts` table gains `Platform` (`Web` / `Native`), exposed to Starlark as `layout.platform`; catalog schema 18. MDL-WIDGET39 (CE2421) is not React-only and still applies to native pages. - **`check` no longer refuses a control-bar button of a grid nested in a data view or list view** (ako/mxcli#953) — **MDL-BUTTON01** fired when such a button passed `$currentObject`, which there is the enclosing object and builds clean (measured, mxbuild 11.13). A grid with no enclosing data container is still CE1571 and still reported. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. diff --git a/mdl/linter/rules/empty.go b/mdl/linter/rules/empty.go index 2f3890c194..dfbef3dd09 100644 --- a/mdl/linter/rules/empty.go +++ b/mdl/linter/rules/empty.go @@ -30,12 +30,21 @@ func (r *EmptyMicroflowRule) Description() string { return "Checks for microflows, nanoflows and rules that have no activities" } +// returnsValue reports whether a flow's end event returns a value. A flow whose +// only content is `return ;` has no activities — ActivityCount excludes +// the start and end events — but it computes something and is not empty +// (ako/mxcli#953). A non-Void return type is the witness: mxbuild requires +// every end event of such a flow to return a value. +func returnsValue(mf linter.Microflow) bool { + return mf.ReturnType != "" && mf.ReturnType != "Void" +} + // Check runs the empty microflow check. func (r *EmptyMicroflowRule) Check(ctx *linter.LintContext) []linter.Violation { var violations []linter.Violation for mf := range ctx.Microflows() { - if mf.ActivityCount == 0 { + if mf.ActivityCount == 0 && !returnsValue(mf) { violations = append(violations, linter.Violation{ RuleID: r.ID(), Severity: r.DefaultSeverity(), diff --git a/mdl/linter/rules/empty_test.go b/mdl/linter/rules/empty_test.go index 777e55bc2d..4b05cb9faa 100644 --- a/mdl/linter/rules/empty_test.go +++ b/mdl/linter/rules/empty_test.go @@ -116,7 +116,9 @@ func TestEmptyMicroflowRule_NamesTheDocumentType(t *testing.T) { db := setupMicroflowsDB(t, [][]any{ {"id1", "ACT_Process", "MyModule.ACT_Process", "MyModule", "", "MICROFLOW", "", "Void", 0, 0, 0}, {"id2", "NF_Refresh", "MyModule.NF_Refresh", "MyModule", "", "NANOFLOW", "", "Void", 0, 0, 0}, - {"id3", "Rule1", "MyModule.Rule1", "MyModule", "", "RULE", "", "Boolean", 1, 0, 0}, + // No return type: a rule that returns a value is not empty (#953), so + // the noun is pinned on one that reads as returning nothing. + {"id3", "Rule1", "MyModule.Rule1", "MyModule", "", "RULE", "", "", 1, 0, 0}, // An unknown type must still be reported, under the generic noun: a // finding with an imprecise label beats no finding at all. {"id4", "Mystery", "MyModule.Mystery", "MyModule", "", "SOMETHING_NEW", "", "Void", 0, 0, 0}, @@ -171,3 +173,28 @@ func TestDocumentNounCoversEveryCatalogType(t *testing.T) { } } } + +// ako/mxcli#953 item 4: a flow whose only content is `return ;` computes +// a value in its end event and is not empty — ActivityCount excludes the start +// and end events, so it reads 0. A non-Void return type is the catalog's +// witness that the end event returns a value. The Void and unset rows are the +// control: they stay reported. +func TestEmptyMicroflowRule_SkipsReturnValueOnlyFlows(t *testing.T) { + db := setupMicroflowsDB(t, [][]any{ + {"id1", "Logboek_Label", "M.Logboek_Label", "M", "", "MICROFLOW", "", "String", 1, 0, 1}, + {"id2", "GetCurrentUser", "M.GetCurrentUser", "M", "", "NANOFLOW", "", "Object:System.User", 0, 0, 1}, + {"id3", "IsValid", "M.IsValid", "M", "", "RULE", "", "Boolean", 1, 0, 1}, + {"id4", "Empty", "M.Empty", "M", "", "MICROFLOW", "", "Void", 0, 0, 1}, + {"id5", "EmptyUnset", "M.EmptyUnset", "M", "", "NANOFLOW", "", "", 0, 0, 1}, + }) + defer db.Close() + + violations := NewEmptyMicroflowRule().Check(linter.NewLintContextFromDB(db)) + got := map[string]bool{} + for _, v := range violations { + got[v.Location.DocumentName] = true + } + if len(violations) != 2 || !got["Empty"] || !got["EmptyUnset"] { + t.Fatalf("want only the Void/unset flows reported (Empty, EmptyUnset), got %v", got) + } +} From 6763b4a7a4e28ba838130ae60624ef70e4cd7530 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:26:32 +0000 Subject: [PATCH 09/18] fix(lint): CONV006 reports once per entity One finding per entity x role x CREATE/DELETE (111 on a mid-sized app) becomes one per entity, listing the de-duplicated roles per right. The test runs both the .claude and the embedded copy of the rule. Part of #953 (item 5). Co-Authored-By: Claude Opus 5.5 --- .../conv006_no_create_delete_rights.star | 45 +++++++---- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + CHANGELOG.md | 1 + mdl/linter/starlark_conv006_test.go | 81 +++++++++++++++++++ 4 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 mdl/linter/starlark_conv006_test.go diff --git a/.claude/lint-rules/conv006_no_create_delete_rights.star b/.claude/lint-rules/conv006_no_create_delete_rights.star index 7d12a95ca7..3a8012e405 100644 --- a/.claude/lint-rules/conv006_no_create_delete_rights.star +++ b/.claude/lint-rules/conv006_no_create_delete_rights.star @@ -4,6 +4,10 @@ # create and delete operations should go through microflows that enforce # business logic. Only READ and WRITE should be granted via access rules. # +# One finding per entity, naming every role per right: the advice is the same +# for each role, and one row per entity x role x right buried the report (111 +# findings on a mid-sized app, ako/mxcli#953). +# # Requires FULL catalog (REFRESH CATALOG FULL). RULE_ID = "CONV006" @@ -12,6 +16,8 @@ DESCRIPTION = "Entity access rules should not grant CREATE or DELETE directly; u CATEGORY = "security" SEVERITY = "warning" +RIGHTS = ("CREATE", "DELETE") + def check(): violations = [] @@ -19,20 +25,31 @@ def check(): if entity.entity_type != "Persistent" or entity.is_external: continue + # right -> sorted, de-duplicated roles (a role can hold several rules) + roles = {} for perm in permissions_for(entity.qualified_name): - if perm.access_type in ("CREATE", "DELETE"): - violations.append(violation( - message="Entity '{}' grants {} to role '{}'. Use a microflow to enforce business logic.".format( - entity.qualified_name, perm.access_type, perm.module_role_name - ), - location=location( - module=entity.module_name, - document_type="Entity", - document_name=entity.qualified_name, - ), - suggestion="Remove the {} right and implement a microflow (ACT_) with security checks".format( - perm.access_type - ), - )) + if perm.access_type in RIGHTS: + held = roles.setdefault(perm.access_type, []) + if perm.module_role_name not in held: + held.append(perm.module_role_name) + + granted = [r for r in RIGHTS if r in roles] + if not granted: + continue + + parts = ["{} ({})".format(r, ", ".join(sorted(roles[r]))) for r in granted] + violations.append(violation( + message="Entity '{}' grants {}. Use a microflow to enforce business logic.".format( + entity.qualified_name, "; ".join(parts) + ), + location=location( + module=entity.module_name, + document_type="Entity", + document_name=entity.qualified_name, + ), + suggestion="Remove the {} right{} and implement a microflow (ACT_) with security checks".format( + " and ".join(granted), "s" if len(granted) > 1 else "" + ), + )) return violations diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index 3e132fcc23..b59191cb18 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -152,3 +152,4 @@ {"date": "2026-10-03", "area": "cmd/mxcli/check", "symptom": "`mxcli check -p` prints MDL067 (bare commit now WITH events) for a create-or-modify flow already stored with events, which `exec -p` no longer prints", "cause": "cmd_check ran ValidateProgram without the DropSettledCommitNotes filter exec_preflight applies; the project was only connected later, for the reference tier", "fix": "check connects to the project before the semantic report when -p is given, applies DropSettledCommitNotes and StoredTaskClaimViolations, and reuses that connection for the reference tier", "insight": "Two gates over one rule set drift whenever a post-filter lives in only one of them; grep for every caller of ValidateProgram when adding a filter. Control: a flow stored without events still notes", "issue": "ako/mxcli#943", "file": "cmd/mxcli/cmd_check.go", "test": "cmd/mxcli/check_stored_semantics_test.go"} {"date": "2026-10-03", "area": "cmd/mxcli/test", "symptom": "every `mxcli test` run prints 2x MDL-DEPR001 and 2x MDL-V1-SLASH about a script the user never wrote", "cause": "GenerateEndpointMDL emitted a headerless mdl 0 script with `create or replace` and `/` terminators; the test-flow generators had already moved to the version-aware writeScriptHeader/createFlow/writeFlowEnd", "fix": "GenerateEndpointMDL writes mdl 1 through the same helpers (header, create or modify, `;` only); endpoint script is independent of the suite's version", "insight": "A generated script is checked like a user's one; pin it with a test that parses it and asserts ValidateProgram returns nothing. Verified end to end with `mxcli test --local` on a fresh 11.13 app", "issue": "ako/mxcli#943", "file": "cmd/mxcli/testrunner/endpoint.go", "test": "cmd/mxcli/testrunner/endpoint_clean_test.go"} {"date": "2026-10-03", "area": "cmd/mxcli/theme", "symptom": "`theme create acme --from design.css` with `--mxt-font: \"Inter\", system-ui, sans-serif` prints nothing about Inter; the theme ships no woff2 and no @font-face for it and renders in the fallback font wherever Inter is not installed", "cause": "planFonts only decided which VENDORED families to drop; a seeded family outside the vendored set was never looked at, so the silent outcome was the default", "fix": "unvendoredSeededFamilies takes the primary (first) family of each seeded font stack, skips generic families and var() and the families the base partial loads, and CreateResult.UnvendoredFonts carries them to cmd_theme.go, which prints a note per family naming mxcli-fonts/ and the partial", "insight": "Only the first family of a stack is the design's choice; flagging the fallbacks (Helvetica, Arial) would make the note noise. The controls are a vendored family (IBM Plex Mono) and a generic stack, which must stay silent", "issue": "ako/mxcli#944", "file": "cmd/mxcli/theme/create_seeded.go (unvendoredSeededFamilies, planFonts); cmd/mxcli/cmd_theme.go", "test": "cmd/mxcli/theme/create_seeded_test.go (TestCreate_NamesSeededFontsItDoesNotVendor)"} +{"area": "cmd/mxcli", "date": "2026-10-03", "symptom": "CONV006 emits one finding per entity x role x CREATE/DELETE (111 on a mid-sized app), the same advice repeated per role, and the per-finding Security score is driven by role count rather than by entities", "cause": "The Starlark rule appended a violation inside the permissions_for() loop", "file": "`.claude/lint-rules/conv006_no_create_delete_rights.star` (synced to `cmd/mxcli/lint-rules/`)", "insight": "Group per entity and per right with de-duplicated sorted roles (a role can hold several access rules on one entity). Test both rule copies (.claude and the embedded one) like SEC008's test does", "refs": ["ako/mxcli#953"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ff3ca824..31deb49307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **CONV006 reports once per entity** (ako/mxcli#953) — listing the roles per right (`grants CREATE (M.Admin, M.User); DELETE (M.Admin)`) instead of one finding per entity × role × right. - **MPR002 no longer calls a return-only flow empty** (ako/mxcli#953) — a microflow or nanoflow whose only content is `return ;` computes its value in the end event; flows with a non-Void return type are not reported. - **MPR012 and MDL-WIDGET40 stay off native pages** (ako/mxcli#953) — CE0582 is the React client's error, and a page on a native layout builds a `staticimage` or a classic `dropdown` clean (measured, 11.13). The catalog's `layouts` table gains `Platform` (`Web` / `Native`), exposed to Starlark as `layout.platform`; catalog schema 18. MDL-WIDGET39 (CE2421) is not React-only and still applies to native pages. - **`check` no longer refuses a control-bar button of a grid nested in a data view or list view** (ako/mxcli#953) — **MDL-BUTTON01** fired when such a button passed `$currentObject`, which there is the enclosing object and builds clean (measured, mxbuild 11.13). A grid with no enclosing data container is still CE1571 and still reported. diff --git a/mdl/linter/starlark_conv006_test.go b/mdl/linter/starlark_conv006_test.go new file mode 100644 index 0000000000..3ccbb5fd9e --- /dev/null +++ b/mdl/linter/starlark_conv006_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" + _ "modernc.org/sqlite" +) + +// ako/mxcli#953 item 5: CONV006 emitted one finding per entity × role × +// CREATE/DELETE — 111 on the JTSBootLogboek app, most of them the same advice +// about the same entity. One finding per entity names every role and right. +// Logboek is the grouped case; Note has READ/WRITE only and is the control +// (no finding); Draft is non-persistent and out of scope. +func TestCONV006GroupsByEntity(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + for _, s := range []string{ + `CREATE TABLE modules (Id TEXT, Name TEXT, Source TEXT)`, + `INSERT INTO modules VALUES ('m1', 'App', '')`, + `CREATE TABLE entities ( + Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + EntityType TEXT, Description TEXT, Generalization TEXT, + AttributeCount INTEGER, AccessRuleCount INTEGER, ValidationRuleCount INTEGER, + HasEventHandlers INTEGER, IsExternal INTEGER, + HasCreatedDate INTEGER, HasChangedDate INTEGER, + HasOwner INTEGER, HasChangedBy INTEGER)`, + `INSERT INTO entities VALUES + ('e1','Logboek','App.Logboek','App','','PERSISTENT','','',1,3,0,0,0, 0,0,0,0), + ('e2','Note','App.Note','App','','PERSISTENT','','',1,1,0,0,0, 0,0,0,0), + ('e3','Draft','App.Draft','App','','NON_PERSISTENT','','',1,1,0,0,0, 0,0,0,0)`, + `CREATE TABLE permissions (ModuleRoleName TEXT, ElementType TEXT, ElementName TEXT, + MemberName TEXT, AccessType TEXT, XPathConstraint TEXT, + DefaultMemberAccessRights TEXT, ModuleName TEXT)`, + // Two access rules for App.User on Logboek: its CREATE must be listed once. + `INSERT INTO permissions VALUES + ('App.Admin','ENTITY','App.Logboek',NULL,'CREATE','','ReadWrite','App'), + ('App.Admin','ENTITY','App.Logboek',NULL,'DELETE','','ReadWrite','App'), + ('App.Admin','ENTITY','App.Logboek',NULL,'READ','','ReadWrite','App'), + ('App.User','ENTITY','App.Logboek',NULL,'CREATE','','ReadWrite','App'), + ('App.User','ENTITY','App.Logboek',NULL,'CREATE','[Owner = ''[%CurrentUser%]'']','ReadWrite','App'), + ('App.Viewer','ENTITY','App.Logboek',NULL,'READ','','ReadOnly','App'), + ('App.Admin','ENTITY','App.Note',NULL,'READ','','ReadWrite','App'), + ('App.Admin','ENTITY','App.Note',NULL,'WRITE','','ReadWrite','App'), + ('App.Admin','ENTITY','App.Draft',NULL,'CREATE','','ReadWrite','App')`, + } { + if _, err := db.Exec(s); err != nil { + t.Fatalf("fixture %q: %v", s, err) + } + } + + for _, dir := range []string{".claude/lint-rules", "cmd/mxcli/lint-rules"} { + t.Run(dir, func(t *testing.T) { + r, err := linter.LoadStarlarkRule(filepath.Join("..", "..", dir, "conv006_no_create_delete_rights.star")) + if err != nil { + t.Fatal(err) + } + got := r.Check(linter.NewLintContextFromDB(catalog.WrapSqlDB(db))) + if len(got) != 1 { + t.Fatalf("got %d violations, want 1 (one per entity): %v", len(got), got) + } + msg := got[0].Message + for _, want := range []string{"App.Logboek", "CREATE (App.Admin, App.User)", "DELETE (App.Admin)"} { + if !strings.Contains(msg, want) { + t.Errorf("message does not contain %q:\n%s", want, msg) + } + } + if strings.Contains(msg, "App.Viewer") { + t.Errorf("message names a role without CREATE/DELETE:\n%s", msg) + } + }) + } +} From 0977b5a321e82696e1187d404fac8ca0553f2227 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:26:33 +0000 Subject: [PATCH 10/18] feat(report): --modules scores only the selected modules `lint` had -m, `report` only --exclude. `report --modules A,B` applies the same LintContext module filter and scores only the findings located in a selected module, so project-wide findings do not weigh on a module score. The selection is printed in markdown, HTML and JSON output. Part of #953 (item 6). Co-Authored-By: Claude Opus 5.5 --- .../skills/fix-issue/findings/cmd-mxcli.jsonl | 1 + .claude/skills/mendix/assess-quality/SKILL.md | 3 + CHANGELOG.md | 1 + cmd/mxcli/cmd_report.go | 24 +++++++- cmd/mxcli/main.go | 1 + docs-site/src/appendixes/quick-reference.md | 2 +- docs-site/src/tools/mxcli-report.md | 17 ++++++ mdl/linter/report.go | 29 +++++++++ mdl/linter/report_format.go | 10 +++ mdl/linter/report_scope_test.go | 61 +++++++++++++++++++ 10 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 mdl/linter/report_scope_test.go diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index b59191cb18..a29d3d2193 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -153,3 +153,4 @@ {"date": "2026-10-03", "area": "cmd/mxcli/test", "symptom": "every `mxcli test` run prints 2x MDL-DEPR001 and 2x MDL-V1-SLASH about a script the user never wrote", "cause": "GenerateEndpointMDL emitted a headerless mdl 0 script with `create or replace` and `/` terminators; the test-flow generators had already moved to the version-aware writeScriptHeader/createFlow/writeFlowEnd", "fix": "GenerateEndpointMDL writes mdl 1 through the same helpers (header, create or modify, `;` only); endpoint script is independent of the suite's version", "insight": "A generated script is checked like a user's one; pin it with a test that parses it and asserts ValidateProgram returns nothing. Verified end to end with `mxcli test --local` on a fresh 11.13 app", "issue": "ako/mxcli#943", "file": "cmd/mxcli/testrunner/endpoint.go", "test": "cmd/mxcli/testrunner/endpoint_clean_test.go"} {"date": "2026-10-03", "area": "cmd/mxcli/theme", "symptom": "`theme create acme --from design.css` with `--mxt-font: \"Inter\", system-ui, sans-serif` prints nothing about Inter; the theme ships no woff2 and no @font-face for it and renders in the fallback font wherever Inter is not installed", "cause": "planFonts only decided which VENDORED families to drop; a seeded family outside the vendored set was never looked at, so the silent outcome was the default", "fix": "unvendoredSeededFamilies takes the primary (first) family of each seeded font stack, skips generic families and var() and the families the base partial loads, and CreateResult.UnvendoredFonts carries them to cmd_theme.go, which prints a note per family naming mxcli-fonts/ and the partial", "insight": "Only the first family of a stack is the design's choice; flagging the fallbacks (Helvetica, Arial) would make the note noise. The controls are a vendored family (IBM Plex Mono) and a generic stack, which must stay silent", "issue": "ako/mxcli#944", "file": "cmd/mxcli/theme/create_seeded.go (unvendoredSeededFamilies, planFonts); cmd/mxcli/cmd_theme.go", "test": "cmd/mxcli/theme/create_seeded_test.go (TestCreate_NamesSeededFontsItDoesNotVendor)"} {"area": "cmd/mxcli", "date": "2026-10-03", "symptom": "CONV006 emits one finding per entity x role x CREATE/DELETE (111 on a mid-sized app), the same advice repeated per role, and the per-finding Security score is driven by role count rather than by entities", "cause": "The Starlark rule appended a violation inside the permissions_for() loop", "file": "`.claude/lint-rules/conv006_no_create_delete_rights.star` (synced to `cmd/mxcli/lint-rules/`)", "insight": "Group per entity and per right with de-duplicated sorted roles (a role can hold several access rules on one entity). Test both rule copies (.claude and the embedded one) like SEC008's test does", "refs": ["ako/mxcli#953"]} +{"area": "cmd/mxcli", "date": "2026-10-03", "symptom": "`mxcli report` could not score a project's own modules: `lint` has --modules, `report` had only --exclude", "cause": "Feature gap; and the LintContext module filter alone would not make the score exact, because project-level findings (CONV008 role mappings, project security) carry no module and are reported regardless", "file": "`cmd/mxcli/cmd_report.go`, `mdl/linter/report.go` (`ScopeToModules`, Report.Modules)", "insight": "Filter the scored violations to those located in a selected module, and print the selection in every format so a module score is not mistaken for the project's", "refs": ["ako/mxcli#953"]} diff --git a/.claude/skills/mendix/assess-quality/SKILL.md b/.claude/skills/mendix/assess-quality/SKILL.md index 81d0e3a1b7..d070f81a75 100644 --- a/.claude/skills/mendix/assess-quality/SKILL.md +++ b/.claude/skills/mendix/assess-quality/SKILL.md @@ -30,6 +30,9 @@ mxcli lint -p app.mpr # generate the scored best practices report mxcli report -p app.mpr --format markdown + +# score only the app's own modules (not Marketplace / platform modules) +mxcli report -p app.mpr --modules MyModule,OtherModule --format markdown ``` The report covers 6 categories with scores: **Naming**, **Security**, **Quality**, **Architecture**, **Performance**, **Design**. diff --git a/CHANGELOG.md b/CHANGELOG.md index 31deb49307..73344cc446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **`mxcli report --modules A,B`** (ako/mxcli#953) — scores only the named modules, as `lint -m` lints them: findings outside the selection, including project-wide ones, are not scored, and the report names the selection. - **Starlark lint builtins over the catalog tables that had none** (mendixlabs/mxcli#1265) — `associations()`, `entity_event_handlers()`, `navigation_menu_items()`, `jar_dependencies()`, `strings(language = None)`, `layouts()`, `published_rest_operations()` and `modules()`, each filtered like the other builtins (no System or Marketplace modules, `--modules` / `--exclude`, `--documents` for the document-scoped ones). A rule calling `strings()` gets a full catalog automatically; an untranslated language has no row. Fields are documented in the write-lint-rules skill. - **Association delete behaviour, domain model documentation and the admin user for lint rules** (mendixlabs/mxcli#1269) — `associations()` carries `to_delete_behavior` / `from_delete_behavior` and their error messages (raw Mendix values; CATALOG.ASSOCIATIONS gains the matching columns), `modules()` carries `domain_model_documentation` (CATALOG.MODULES.DomainModelDocumentation; mxcli read it nowhere before, and its write paths keep it), and `project_security()` gains `admin_user_name` and `admin_user_role` — never the password. Catalog schema 17: a cached catalog rebuilds once. - **Activity properties for lint rules** (mendixlabs/mxcli#1266, mendixlabs/mxcli#1267) — `activities_for(name, nested = True)` returns the activities inside loops with `parent_loop_id` and `loop_depth`, and every activity now carries its real `caption` (a split's caption, an annotation's text; it was the placeholder `Activity` on every row), `auto_generate_caption`, `description`, `condition_expression` / `condition_rule` (exclusive splits), `error_handling_type` (`Rollback`, `Custom`, `CustomWithoutRollBack` — capital B — `Continue`, `Abort`), `log_level` / `log_node_expression` / `log_message`, `commit_type` / `with_events`, and `retrieve_source` (`database` / `association`, with `entity_ref` for a database retrieve). A web service call now fills `service_ref`, `action_ref` and its timeout. The same values are columns on the catalog's `activities` table. A stored action mxcli does not model is labelled by its Mendix type (`GenerateJumpToOptionsAction`) instead of `UnsupportedAction`. diff --git a/cmd/mxcli/cmd_report.go b/cmd/mxcli/cmd_report.go index 3e1b826456..e87ef8e209 100644 --- a/cmd/mxcli/cmd_report.go +++ b/cmd/mxcli/cmd_report.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/mendixlabs/mxcli/mdl/linter" @@ -34,12 +35,19 @@ Examples: mxcli report -p app.mpr --format json mxcli report -p app.mpr --format html --output report.html mxcli report -p app.mpr --format markdown --output report.md + mxcli report -p app.mpr --modules Sales,Orders + +--modules scores only the named modules — a project's own modules, without the +Marketplace and platform modules beside them. Only findings located in a +selected module count; project-wide findings (project security, user-role +mappings) are left out, and the report names the selection. `, Run: func(cmd *cobra.Command, args []string) { projectPath, _ := cmd.Flags().GetString("project") format := resolveFormat(cmd, "markdown") outputPath, _ := cmd.Flags().GetString("output") excludeModules, _ := cmd.Flags().GetStringSlice("exclude") + moduleFilter, _ := cmd.Flags().GetStringSlice("modules") if projectPath == "" { fmt.Fprintln(os.Stderr, "Error: --project (-p) is required") @@ -86,6 +94,9 @@ Examples: // Create lint context ctx := linter.NewLintContext(cat, exec.Backend()) ctx.SetExcludedModules(excludeModules) + if len(moduleFilter) > 0 { + ctx.SetIncludedModules(moduleFilter) + } // The rule set and the config come from the same two helpers `mxcli // lint` uses. This command used to build both itself: its inline copy of @@ -100,8 +111,15 @@ Examples: for _, rule := range projectLintRules(projectDir, os.Stderr) { lint.AddRule(rule) } - if cfg, _ := applyLintConfig(lint, projectDir, os.Stderr); cfg != nil && len(cfg.ExcludeModules) > 0 { + if cfg, configPath := applyLintConfig(lint, projectDir, os.Stderr); cfg != nil && len(cfg.ExcludeModules) > 0 { ctx.SetExcludedModules(append(excludeModules, cfg.ExcludeModules...)) + // The exclude wins, as in `lint`: say so rather than score an + // empty selection 100. + 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 scored for %s. Remove it from excludeModules to score it.\n", + strings.Join(shadowed, ", "), pluralIsAre(len(shadowed)), configPath, pluralItThem(len(shadowed))) + } } // Run all rules @@ -116,11 +134,13 @@ Examples: projectName = projectName[:len(projectName)-len(filepath.Ext(projectName))] // Build report + // --modules: score the selection only (ako/mxcli#953). report := linter.BuildReport( projectName, time.Now().Format("2006-01-02 15:04:05"), - violations, + linter.ScopeToModules(violations, moduleFilter), ) + report.Modules = moduleFilter // Format and output formatter := linter.GetReportFormatter(format) diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 2a2e7db586..8c566c16df 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -370,6 +370,7 @@ func init() { reportCmd.Flags().StringP("format", "f", "markdown", "Output format: markdown, json, html") reportCmd.Flags().StringP("output", "o", "", "Output file path (default: stdout)") reportCmd.Flags().StringSliceP("exclude", "e", nil, "Modules to exclude from report") + reportCmd.Flags().StringSliceP("modules", "m", nil, "Only score the specified modules (comma-separated or repeated)") // Graph-report command flags graphReportCmd.Flags().StringP("format", "f", "markdown", "Output format: markdown, json") diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 6aefe5b177..2ed15ef828 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -547,7 +547,7 @@ Cross-reference commands require `REFRESH CATALOG FULL` to populate reference da | Check syntax | `mxcli check script.mdl` | Parse-only validation | | Check references | `mxcli check script.mdl -p app.mpr --references` | With reference validation | | Lint project | `mxcli lint -p app.mpr [--format json\|sarif]` | 19 built-in + 31 Starlark rules | -| Report | `mxcli report -p app.mpr [--format markdown\|json\|html]` | Best practices report | +| Report | `mxcli report -p app.mpr [--format markdown\|json\|html] [--modules A,B]` | Best practices report (`--modules` scores only those modules) | | Test | `mxcli test tests/ -p app.mpr` | `.test.mdl` / `.test.md` files | | Diff script | `mxcli diff -p app.mpr changes.mdl` | Compare script vs project | | Diff local | `mxcli diff-local -p app.mpr --ref HEAD` | Git diff for MPR v2 | diff --git a/docs-site/src/tools/mxcli-report.md b/docs-site/src/tools/mxcli-report.md index ef06f1e059..2d4a50b7a1 100644 --- a/docs-site/src/tools/mxcli-report.md +++ b/docs-site/src/tools/mxcli-report.md @@ -9,6 +9,23 @@ The `mxcli report` command generates a scored best practices report for a Mendix mxcli report -p app.mpr ``` +## Scoring Selected Modules + +```bash +# score only your own modules +mxcli report -p app.mpr --modules Sales,Orders +mxcli report -p app.mpr -m Sales -m Orders +``` + +`--modules` (`-m`, as on `mxcli lint`) scores the named modules and nothing else: +the rules skip every other module, and only findings **located in a selected +module** count toward the score. Findings that belong to no module — project +security, user-role mappings — are left out, because they would weigh the same +on every module's score. The report names the selection under the date, so a +module score is not read as the project's. `--exclude` and `excludeModules` in +`lint-config.yaml` still win over `--modules`; a module named by both is warned +about and not scored. + ## Output Formats ### Markdown diff --git a/mdl/linter/report.go b/mdl/linter/report.go index 4337046890..ae2c06b20e 100644 --- a/mdl/linter/report.go +++ b/mdl/linter/report.go @@ -15,6 +15,9 @@ type Report struct { Categories []CategoryScore `json:"categories"` Violations []Violation `json:"-"` Summary Summary `json:"summary"` + // Modules is the --modules selection the report was scored over; empty + // for a whole-project report. + Modules []string `json:"modules,omitempty"` } // CategoryScore tracks the score for a lint category. @@ -99,6 +102,32 @@ var categoryWeight = map[string]float64{ "Other": 0.05, } +// ScopeToModules keeps the violations located in one of the given modules, so +// a report scores only the selection (ako/mxcli#953). With no modules it +// returns vs unchanged. +// +// A finding with no module — project security, a user role's mapping — is not +// about any one module and is dropped: counting it would make every module's +// score carry the project's settings. The LintContext module filter already +// keeps most rules inside the selection; this is what makes the score exact +// for the rest. +func ScopeToModules(vs []Violation, modules []string) []Violation { + if len(modules) == 0 { + return vs + } + keep := make(map[string]bool, len(modules)) + for _, m := range modules { + keep[m] = true + } + var out []Violation + for _, v := range vs { + if keep[v.Location.Module] { + out = append(out, v) + } + } + return out +} + // BuildReport creates a Report from a list of violations. func BuildReport(projectName, date string, violations []Violation) *Report { report := &Report{ diff --git a/mdl/linter/report_format.go b/mdl/linter/report_format.go index 989140f890..e70180982e 100644 --- a/mdl/linter/report_format.go +++ b/mdl/linter/report_format.go @@ -5,6 +5,7 @@ package linter import ( "encoding/json" "fmt" + "html" "io" "strings" ) @@ -35,6 +36,9 @@ func (f *MarkdownReportFormatter) FormatReport(report *Report, w io.Writer) erro fmt.Fprintf(w, "# Mendix Best Practices Report\n\n") fmt.Fprintf(w, "**Project:** %s \n", report.ProjectName) fmt.Fprintf(w, "**Date:** %s \n", report.Date) + if len(report.Modules) > 0 { + fmt.Fprintf(w, "**Modules:** %s (findings outside these modules are not scored) \n", strings.Join(report.Modules, ", ")) + } fmt.Fprintf(w, "**Overall Score:** %s %.0f/100\n\n", scoreBar(report.OverallScore), report.OverallScore) // Summary @@ -111,6 +115,7 @@ type JSONReportFormatter struct{} type JSONReport struct { ProjectName string `json:"projectName"` Date string `json:"date"` + Modules []string `json:"modules,omitempty"` OverallScore float64 `json:"overallScore"` Summary JSONSummary `json:"summary"` Categories []CategoryScore `json:"categories"` @@ -129,6 +134,7 @@ func (f *JSONReportFormatter) FormatReport(report *Report, w io.Writer) error { jr := JSONReport{ ProjectName: report.ProjectName, Date: report.Date, + Modules: report.Modules, OverallScore: report.OverallScore, Summary: JSONSummary{ Total: report.Summary.Total, @@ -196,6 +202,10 @@ func (f *HTMLReportFormatter) FormatReport(report *Report, w io.Writer) error { fmt.Fprintf(w, "

Mendix Best Practices Report

\n") fmt.Fprintf(w, "

Project: %s

\n", report.ProjectName) fmt.Fprintf(w, "

Date: %s

\n", report.Date) + if len(report.Modules) > 0 { + fmt.Fprintf(w, "

Modules: %s (findings outside these modules are not scored)

\n", + html.EscapeString(strings.Join(report.Modules, ", "))) + } // Overall score scoreClass := "score-good" diff --git a/mdl/linter/report_scope_test.go b/mdl/linter/report_scope_test.go new file mode 100644 index 0000000000..a5c6513149 --- /dev/null +++ b/mdl/linter/report_scope_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// ako/mxcli#953 item 6: `report --modules A,B` scores the project's own +// modules. The rules already skip unselected modules through the LintContext +// filter, but some findings are not about a module at all (project security, +// role mappings) or are reported in a module other than the one iterated; the +// score is computed over the findings located in a selected module only. +func TestScopeToModules(t *testing.T) { + vs := []Violation{ + {RuleID: "SEC001", Severity: SeverityWarning, Location: Location{Module: "MyFirstModule"}}, + {RuleID: "SEC001", Severity: SeverityWarning, Location: Location{Module: "Administration"}}, + {RuleID: "CONV008", Severity: SeverityInfo, Location: Location{Module: ""}}, + {RuleID: "MPR002", Severity: SeverityWarning, Location: Location{Module: "Other"}}, + } + got := ScopeToModules(vs, []string{"MyFirstModule", "Other"}) + if len(got) != 2 || got[0].Location.Module != "MyFirstModule" || got[1].Location.Module != "Other" { + t.Fatalf("want the MyFirstModule and Other findings only, got %+v", got) + } + // Control: no selection is no scoping. + if all := ScopeToModules(vs, nil); len(all) != len(vs) { + t.Fatalf("no module selection must keep every finding, got %d of %d", len(all), len(vs)) + } + + // The score moves with the scope: the unscoped report counts the + // Administration finding against Security, the scoped one does not. + full := BuildReport("App", "d", vs) + scoped := BuildReport("App", "d", got) + if scoped.OverallScore <= full.OverallScore { + t.Fatalf("scoped score %v should exceed unscoped %v", scoped.OverallScore, full.OverallScore) + } + + // The scope is stated in every format, so a module score is never read as + // the project's. + scoped.Modules = []string{"MyFirstModule", "Other"} + for _, format := range []string{"markdown", "html", "json"} { + var buf bytes.Buffer + if err := GetReportFormatter(format).FormatReport(scoped, &buf); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "MyFirstModule") || !strings.Contains(buf.String(), "Other") { + t.Errorf("%s output does not name the selected modules:\n%s", format, buf.String()) + } + if format == "json" { + var jr struct { + Modules []string `json:"modules"` + } + if err := json.Unmarshal(buf.Bytes(), &jr); err != nil || len(jr.Modules) != 2 { + t.Errorf("json modules = %v (err %v)", jr.Modules, err) + } + } + } +} From 7481a07b49bd054a76acaeb7aa8002cf35a5f9e0 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:27:33 +0000 Subject: [PATCH 11/18] fix: delete close page keeps ClosePage on write (#950) The visitor set ActionV3.ClosePage for `delete close page` but the page builder's delete case did not copy it, so a describe -> exec round trip stored ClosePage=false: the button stopped closing its page, with check, exec and mx check clean. Audited the other page-action cases: save and cancel already copy it; no other visitor flag is dropped. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + CHANGELOG.md | 1 + .../cmd_pages_action_close_page_test.go | 53 +++++++++++++++++++ mdl/executor/cmd_pages_builder_v3.go | 4 ++ 4 files changed, 59 insertions(+) create mode 100644 mdl/executor/cmd_pages_action_close_page_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index b9b64c0ccd..d1ebd3d9d3 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -844,3 +844,4 @@ {"date": "2026-10-03", "area": "mdl/executor/drop", "symptom": "`drop microflow M.F;` in one `mxcli exec` run and `create microflow M.F …` in the next leaves M.F with no module-role grants (CE0106 on the pages calling it); the drop printed only \"Dropped microflow: M.F\"", "cause": "the grants carry only through the session cache (rememberDroppedMicroflow / consumeDroppedMicroflow), which a later process does not have; nothing told the user the carry was session-scoped. `drop page` never remembers its AllowedRoles at all", "fix": "writeDroppedGrantsNote (mdl/executor/drop_grants_note.go), called from execDropMicroflow / execDropNanoflow / execDropPage, prints the removed roles, whether a create carries them (same script or session for flows; never for a page), and the `grant` that restores them; flowRefusal's rebuild advice says drop + create in the same script", "insight": "A carry that lives in a session cache is invisible at the statement that creates it; the place to say so is the drop, which is the last moment the roles are known. Snippets have no access roles, so they need nothing", "issue": "ako/mxcli#944", "file": "mdl/executor/drop_grants_note.go; cmd_microflows_drop.go; cmd_nanoflows_drop.go; cmd_pages_builder.go; flow_verdict.go", "test": "mdl/executor/drop_grants_note_test.go; flow_verdict_test.go (TestFlowRefusalNamesTheFlowAndTheReason)"} {"date": "2026-10-03", "area": "mdl/executor/settings", "symptom": "after `alter settings language (DefaultLanguageCode: 'de_DE')`, `docker check` fails with CE4899 \"Empty caption. [German, Germany]\" at Tab page 'tabPage2' (Administration.Account_Overview, en_US only) while `check -p --references`, `lint` and exec are silent; a page created AFTER the switch in the same script fails the same way", "cause": "nothing compared required captions with DefaultLanguageCode (QUAL005 compares languages with each other, and `mxcli lint` does not even run it); and describeDefaultLanguage cached the authoring language once per session, so the switch did not reach later creates", "fix": "translations.MissingRequiredCaptions (measured set: Forms$TabPage.Caption in pages, snippets, layouts; templates and building blocks skipped) feeds lint QUAL006, the note printed by alterSettings (defaultLanguageChanged, which also drops the cached authoring language) and check -p MDL-I18N01 (CheckDefaultLanguageCaptions simulates which documents the script writes before/after the switch)", "insight": "Measure which caption kinds the build requires before flagging: of eleven kinds written en_US-only, only the tab page caption failed; flagging the rest would have made an error rule wrong ten times out of eleven. The first lint run also flagged 22 page-template tab pages mxbuild never reported, caught only by comparing lint's count with docker check's (1 vs 1 after the fix, 3 vs 3 on the e2e script)", "issue": "ako/mxcli#944", "file": "mdl/translations/required.go; mdl/linter/rules/required_captions.go; mdl/executor/default_language_captions.go; mdl/executor/cmd_settings.go (defaultLanguageChanged)", "test": "mdl/translations/required_test.go; mdl/linter/rules/required_captions_test.go; mdl/executor/default_language_captions_test.go"} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#944: describe printed `clear $L;` and `set $L = $M;` for a Change list Clear/Set (Replace) action; `clear` did not parse, and `set` on a list variable executed to a Change variable action that passes mxcli check but mx check refuses (CE7247 \"Variable 'A' does not have a primitive type\")", "cause": "the grammar had only add/remove for Change list, and the builder routed every `set $X = …` to ChangeVariableAction regardless of the target's type", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (clearListStatement), `mdl/executor/cmd_microflows_builder_graph.go` (MfSetStmt → addReplaceListAction when isListVariable)", "insight": "A describe → exec round trip that compares only MDL text passes when the action TYPE changes but prints the same — the Set revert check stayed green until the test also read the stored $Type/Type from the unit. Assert the stored shape, not just the re-described text", "refs": ["ako/mxcli#944"]} +{"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#950 item 1: a page button described as `Action: delete close page` executes to a Forms$DeleteClientAction with ClosePage=false — a describe → exec round trip silently stops the button closing its page; check, exec and mx check are all clean", "cause": "buildClientActionV3Base's `delete` case did not copy action.ClosePage, although the visitor sets it and the writer (clientActionToGen) writes it; save/cancel copied it", "file": "`mdl/executor/cmd_pages_builder_v3.go` (buildClientActionV3Base, case \"delete\")", "insight": "The audit of the other cases found no other dropped visitor flag, but two hard-coded ones describe cannot express: complete task always writes ClosePage/Commit true and show page/create object write NumberOfPagesToClose2 \"\" — every Studio Pro instance in TestApp/PedApp has those values, so they are latent, not live. A text round trip could not have caught this: describe printed the flag correctly and the second describe of the exec'd page printed `delete`, which looks like a user edit — read the stored ClosePage", "refs": ["ako/mxcli#950"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index f78ba22a38..c870a81a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`delete close page` closes the page again after a describe → exec** (ako/mxcli#950) — a page button's `Action: delete close page` was stored with "Close page" off, so executing the description of a page whose delete button closes it changed what the button does, with `check`, `exec` and `mx check` all clean. Existing pages written by `exec` from such a script need the script run again. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". - **A required caption with no text in the default language is foreseen** (ako/mxcli#944) — making de_DE the default left a stock app's `Administration.Account_Overview` `tabPage2` (en_US only) empty in de_DE, and mxbuild refused it with CE4899 "Empty caption. [German, Germany]" while `check -p`, `lint` and `exec` said nothing. Measured on 11.14, the tab page caption is the one caption kind the build requires (page titles, buttons, labels, group boxes, column headers, menu items, enumeration captions and messages build without it; page templates and building blocks are not checked). Now: **lint rule QUAL006** (error) lists every tab page caption without the default language; **`alter settings language (DefaultLanguageCode: …)`** prints how many there are and where, with the `alter page … { set (Caption: …) on … }` that fixes one; **`check -p` reports MDL-I18N01** for a script that changes the default — stored captions, and captions the script wrote before the change. And a page created **after** the change in the same script is now written in the new default: the authoring language was resolved once per session, so it was still written in the old one and failed the build too. diff --git a/mdl/executor/cmd_pages_action_close_page_test.go b/mdl/executor/cmd_pages_action_close_page_test.go new file mode 100644 index 0000000000..8b71bb7b48 --- /dev/null +++ b/mdl/executor/cmd_pages_action_close_page_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#950 item 1: describe prints a stored delete-with-close-page as +// `delete close page`, and exec of that wrote ClosePage=false — the visitor set +// the flag, the builder's delete case dropped it. A describe → exec round trip +// silently changed what the button does. Every page action that stores a +// ClosePage flag must carry it describe → parse → build, in both directions +// (the false half is the control: a builder that always wrote true would pass +// the true half). +func TestPageActionClosePage_DescribeParsesBackToTheSameFlag(t *testing.T) { + ctx := (&Executor{}).newExecContext(context.Background()) + for _, typ := range []string{"Forms$DeleteClientAction", "Forms$SaveChangesClientAction", "Forms$CancelChangesClientAction"} { + for _, closePage := range []bool{true, false} { + stored := map[string]any{"$Type": typ, "ClosePage": closePage, "DisabledDuringExecution": true} + mdl := renderClientActionMDL(ctx, stored) + prog, errs := visitor.Build("create page M.P (Title: 'T', Layout: A.L) {\n" + + " actionbutton b1 (Caption: 'Go', Action: " + mdl + ")\n}") + if len(errs) > 0 { + t.Fatalf("%s: describe output %q does not parse: %v", typ, mdl, errs) + } + a := prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0].Properties["Action"].(*ast.ActionV3) + built, err := (&pageBuilder{}).buildClientActionV3(a) + if err != nil { + t.Fatalf("%s: build %q: %v", typ, mdl, err) + } + var got bool + switch x := built.(type) { + case *pages.DeleteClientAction: + got = x.ClosePage + case *pages.SaveChangesClientAction: + got = x.ClosePage + case *pages.CancelChangesClientAction: + got = x.ClosePage + default: + t.Fatalf("%s: %q built %T", typ, mdl, built) + } + if got != closePage { + t.Errorf("%s ClosePage=%v: describe printed %q, which builds ClosePage=%v", typ, closePage, mdl, got) + } + } + } +} diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 3747e8cccd..ab2245b31d 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -1647,6 +1647,10 @@ func (pb *pageBuilder) buildClientActionV3Base(action *ast.ActionV3) (pages.Clie ID: model.ID(types.GenerateID()), TypeName: "Forms$DeleteClientAction", }, + // The visitor sets it for `delete close page`; dropping it here + // turned a describe → exec round trip into a button that no longer + // closes its page (ako/mxcli#950). + ClosePage: action.ClosePage, }, nil case "create": From b6efd014643fbcf182d97759cada91c0a6145bbc Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:27:41 +0000 Subject: [PATCH 12/18] fix: describe adds $ only to a bare returned variable (#950) The return formatter prefixed $ to any stored return value without one of + ' " ( ), so [%CurrentUser%] became $[%CurrentUser%] and `if ... then ... else ...` became `$if ...`, neither of which parses. Restore the sigil only for the shape a legacy writer stripped it from: a bare name with an optional attribute path. The other $-adding sites in describe prefix variable-name fields, not expressions. Strikes two TestApp WorkflowCommons microflows from the allowlist. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + CHANGELOG.md | 1 + mdl/executor/cmd_microflows_format_action.go | 20 +++++++++-- .../cmd_microflows_format_action_test.go | 33 +++++++++++++++++++ mdl/roundtrip/testapp_allowlist_test.go | 2 -- 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index d1ebd3d9d3..ed596d6119 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -845,3 +845,4 @@ {"date": "2026-10-03", "area": "mdl/executor/settings", "symptom": "after `alter settings language (DefaultLanguageCode: 'de_DE')`, `docker check` fails with CE4899 \"Empty caption. [German, Germany]\" at Tab page 'tabPage2' (Administration.Account_Overview, en_US only) while `check -p --references`, `lint` and exec are silent; a page created AFTER the switch in the same script fails the same way", "cause": "nothing compared required captions with DefaultLanguageCode (QUAL005 compares languages with each other, and `mxcli lint` does not even run it); and describeDefaultLanguage cached the authoring language once per session, so the switch did not reach later creates", "fix": "translations.MissingRequiredCaptions (measured set: Forms$TabPage.Caption in pages, snippets, layouts; templates and building blocks skipped) feeds lint QUAL006, the note printed by alterSettings (defaultLanguageChanged, which also drops the cached authoring language) and check -p MDL-I18N01 (CheckDefaultLanguageCaptions simulates which documents the script writes before/after the switch)", "insight": "Measure which caption kinds the build requires before flagging: of eleven kinds written en_US-only, only the tab page caption failed; flagging the rest would have made an error rule wrong ten times out of eleven. The first lint run also flagged 22 page-template tab pages mxbuild never reported, caught only by comparing lint's count with docker check's (1 vs 1 after the fix, 3 vs 3 on the e2e script)", "issue": "ako/mxcli#944", "file": "mdl/translations/required.go; mdl/linter/rules/required_captions.go; mdl/executor/default_language_captions.go; mdl/executor/cmd_settings.go (defaultLanguageChanged)", "test": "mdl/translations/required_test.go; mdl/linter/rules/required_captions_test.go; mdl/executor/default_language_captions_test.go"} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#944: describe printed `clear $L;` and `set $L = $M;` for a Change list Clear/Set (Replace) action; `clear` did not parse, and `set` on a list variable executed to a Change variable action that passes mxcli check but mx check refuses (CE7247 \"Variable 'A' does not have a primitive type\")", "cause": "the grammar had only add/remove for Change list, and the builder routed every `set $X = …` to ChangeVariableAction regardless of the target's type", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (clearListStatement), `mdl/executor/cmd_microflows_builder_graph.go` (MfSetStmt → addReplaceListAction when isListVariable)", "insight": "A describe → exec round trip that compares only MDL text passes when the action TYPE changes but prints the same — the Set revert check stayed green until the test also read the stored $Type/Type from the unit. Assert the stored shape, not just the re-described text", "refs": ["ako/mxcli#944"]} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#950 item 1: a page button described as `Action: delete close page` executes to a Forms$DeleteClientAction with ClosePage=false — a describe → exec round trip silently stops the button closing its page; check, exec and mx check are all clean", "cause": "buildClientActionV3Base's `delete` case did not copy action.ClosePage, although the visitor sets it and the writer (clientActionToGen) writes it; save/cancel copied it", "file": "`mdl/executor/cmd_pages_builder_v3.go` (buildClientActionV3Base, case \"delete\")", "insight": "The audit of the other cases found no other dropped visitor flag, but two hard-coded ones describe cannot express: complete task always writes ClosePage/Commit true and show page/create object write NumberOfPagesToClose2 \"\" — every Studio Pro instance in TestApp/PedApp has those values, so they are latent, not live. A text round trip could not have caught this: describe printed the flag correctly and the second describe of the exec'd page printed `delete`, which looks like a user edit — read the stored ClosePage", "refs": ["ako/mxcli#950"]} +{"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#950 item 2: describe of a flow ending `return [%CurrentUser%];` prints `return $[%CurrentUser%];`, which does not parse; `return if … then … else …` likewise became `return $if …` (two TestApp WorkflowCommons microflows)", "cause": "formatActivity's EndEvent branch added `$` to any return value without one of + ' \" ( ) — a character blacklist standing in for 'is a bare variable name'", "file": "`mdl/executor/cmd_microflows_format_action.go` (isBareReturnVariable)", "insight": "Restore a stripped sigil only for the positive shape it was stripped from (a bare name, optionally /path); a blacklist of characters lets every new expression form through. The other `$`-adding sites in describe prefix variable-NAME fields, not expressions, and are safe", "refs": ["ako/mxcli#950"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index c870a81a77..db87001692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **`delete close page` closes the page again after a describe → exec** (ako/mxcli#950) — a page button's `Action: delete close page` was stored with "Close page" off, so executing the description of a page whose delete button closes it changed what the button does, with `check`, `exec` and `mx check` all clean. Existing pages written by `exec` from such a script need the script run again. +- **`describe` of a flow no longer puts `$` in front of a returned expression** (ako/mxcli#950) — `return [%CurrentUser%];` was described as `return $[%CurrentUser%];` and `return if … then … else …` as `return $if …`, neither of which parses. Only a bare variable name stored without its `$` gets one. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". - **A required caption with no text in the default language is foreseen** (ako/mxcli#944) — making de_DE the default left a stock app's `Administration.Account_Overview` `tabPage2` (en_US only) empty in de_DE, and mxbuild refused it with CE4899 "Empty caption. [German, Germany]" while `check -p`, `lint` and `exec` said nothing. Measured on 11.14, the tab page caption is the one caption kind the build requires (page titles, buttons, labels, group boxes, column headers, menu items, enumeration captions and messages build without it; page templates and building blocks are not checked). Now: **lint rule QUAL006** (error) lists every tab page caption without the default language; **`alter settings language (DefaultLanguageCode: …)`** prints how many there are and where, with the `alter page … { set (Caption: …) on … }` that fixes one; **`check -p` reports MDL-I18N01** for a script that changes the default — stored captions, and captions the script wrote before the change. And a page created **after** the change in the same script is now written in the new default: the authoring language was resolved once per session, so it was still written in the old one and failed the build too. diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index d09161ca24..6f9a1879b8 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "fmt" + "regexp" "sort" "strings" @@ -191,9 +192,12 @@ func formatActivity( case *microflows.EndEvent: if activity.ReturnValue != "" { returnVal := describeExpr(ctx, activity.ReturnValue) - // Only add $ prefix for bare identifiers (no operators, quotes, or parens) - if !strings.HasPrefix(returnVal, "$") && !isMendixKeyword(returnVal) && !isQualifiedEnumLiteral(returnVal) && - !strings.ContainsAny(returnVal, "+'\"()") && !isNumericLiteral(returnVal) { + // A legacy writer stored a returned variable without its `$`; add it + // back for exactly that shape — a bare name, optionally with an + // attribute path — and leave every other expression as stored. A + // blacklist of characters here turned `[%CurrentUser%]` into + // `$[%CurrentUser%]`, which does not parse (ako/mxcli#950). + if isBareReturnVariable(returnVal) { returnVal = "$" + returnVal } return fmt.Sprintf("return %s;", returnVal) @@ -1608,6 +1612,16 @@ func isMendixKeyword(s string) bool { return false } +// bareReturnVariable is a variable name without its `$`, optionally followed +// by an attribute path (`Order/Total`, `Order/Module.Assoc/Name`). +var bareReturnVariable = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(/[A-Za-z_][A-Za-z0-9_.]*)*$`) + +// isBareReturnVariable reports whether a stored return value is a variable +// reference missing its `$` — the only shape describe may prefix. +func isBareReturnVariable(s string) bool { + return bareReturnVariable.MatchString(s) && !isMendixKeyword(s) +} + // isQualifiedEnumLiteral returns true for qualified enum literals (e.g., "Module.Enum.Value") // that must not be prefixed with "$" when serialized as a RETURN value. func isQualifiedEnumLiteral(s string) bool { diff --git a/mdl/executor/cmd_microflows_format_action_test.go b/mdl/executor/cmd_microflows_format_action_test.go index 3e1d907035..c7835cb695 100644 --- a/mdl/executor/cmd_microflows_format_action_test.go +++ b/mdl/executor/cmd_microflows_format_action_test.go @@ -1512,3 +1512,36 @@ func TestFormatAction_Retrieve_NestedPredicateReparses(t *testing.T) { } } } + +// ako/mxcli#950 item 2: the `$` prefix is for a bare variable name a legacy +// writer stored without one, and nothing else. It used to go on anything +// without + ' " ( ), so `[%CurrentUser%]` became `$[%CurrentUser%]`, which does +// not parse — and so did an `if … then … else …` or a bare `and` expression. +// Each case below must describe as stored and parse back as a return value. +func TestFormatActivity_ReturnExpressionIsNotPrefixed(t *testing.T) { + e := newTestExecutor() + for _, v := range []string{ + "[%CurrentUser%]", + "[%CurrentDateTime%]", + "if $Ok then 1 else 2", + "$A and $B", + "empty", + "MyModule.Status.Open", + } { + got := e.formatActivity(µflows.EndEvent{ReturnValue: v}, nil, nil) + if want := "return " + v + ";"; got != want { + t.Errorf("ReturnValue %q: got %q, want %q", v, got, want) + } + script := "create microflow M.F () returns String begin\n " + got + "\nend;" + if _, errs := visitor.Build(script); len(errs) > 0 { + t.Errorf("%q does not parse: %v", got, errs[0]) + } + } + // The control: a bare (legacy) variable name is still prefixed, and a + // bare one with an attribute path too. + for in, want := range map[string]string{"MyVar": "return $MyVar;", "Order/Total": "return $Order/Total;"} { + if got := e.formatActivity(µflows.EndEvent{ReturnValue: in}, nil, nil); got != want { + t.Errorf("ReturnValue %q: got %q, want %q", in, got, want) + } + } +} diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index b02cbb8565..42eb678af2 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -135,8 +135,6 @@ var testAppKnownFailures = map[string]knownFailure{ "javascript action WebActions.TakePicture": {laws: []law{lawParse}, issue: "#721", why: "javascript action: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "menu Atlas_Core.Phone_Menu": {laws: []law{lawGetPut}, issue: "#721", why: "menu: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "menu Atlas_Core.Tablet_Menu": {laws: []law{lawGetPut}, issue: "#721", why: "menu: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "microflow WorkflowCommons.SUB_WorkflowTask_AverageHandlingTime": {laws: []law{lawParse}, issue: "#721", why: "microflow: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "microflow WorkflowCommons.SUB_Workflow_AverageHandlingTime": {laws: []law{lawParse}, issue: "#721", why: "microflow: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page Administration.Account_Edit": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page Administration.Account_New": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "page Administration.Account_Overview": {laws: []law{lawGetPut}, issue: "#721", why: "page: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, From 9254eda30c09575fd706abb88eafffc68ee6d3c6 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:28:02 +0000 Subject: [PATCH 13/18] fix: navigation-list items describe and re-execute (#950) Item actions were printed by a private copy of the page-action renderer in the legacy `show_page 'M.P'` form, which does not parse; they now use the shared client-action renderer, as buttons do, for every action kind. With that fixed, exec refused the description because Studio Pro leaves items unnamed, and the writer then wrote Name "" and no visibility slot where Studio Pro stores no Name key and a null ConditionalVisibilitySettings. An unnamed item is now accepted, described as `item (...)` and written as Studio Pro writes it (mx check 11.14.0: 0 errors). Strikes TestApp's Rules.Entity_Menu from the allowlist. Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + CHANGELOG.md | 1 + .../widget_navlist_item_write_test.go | 52 ++++++++++++++++ mdl/backend/modelsdk/widget_write.go | 14 ++++- mdl/executor/cmd_pages_builder_v3_widgets.go | 14 +++-- .../cmd_pages_describe_navlist_action_test.go | 61 +++++++++++++++++++ mdl/executor/cmd_pages_describe_output.go | 5 +- mdl/executor/cmd_pages_describe_parse.go | 41 ++----------- mdl/roundtrip/testapp_allowlist_test.go | 5 +- 9 files changed, 150 insertions(+), 44 deletions(-) create mode 100644 mdl/backend/modelsdk/widget_navlist_item_write_test.go create mode 100644 mdl/executor/cmd_pages_describe_navlist_action_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index ed596d6119..37d5bdd3f1 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -846,3 +846,4 @@ {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#944: describe printed `clear $L;` and `set $L = $M;` for a Change list Clear/Set (Replace) action; `clear` did not parse, and `set` on a list variable executed to a Change variable action that passes mxcli check but mx check refuses (CE7247 \"Variable 'A' does not have a primitive type\")", "cause": "the grammar had only add/remove for Change list, and the builder routed every `set $X = …` to ChangeVariableAction regardless of the target's type", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (clearListStatement), `mdl/executor/cmd_microflows_builder_graph.go` (MfSetStmt → addReplaceListAction when isListVariable)", "insight": "A describe → exec round trip that compares only MDL text passes when the action TYPE changes but prints the same — the Set revert check stayed green until the test also read the stored $Type/Type from the unit. Assert the stored shape, not just the re-described text", "refs": ["ako/mxcli#944"]} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#950 item 1: a page button described as `Action: delete close page` executes to a Forms$DeleteClientAction with ClosePage=false — a describe → exec round trip silently stops the button closing its page; check, exec and mx check are all clean", "cause": "buildClientActionV3Base's `delete` case did not copy action.ClosePage, although the visitor sets it and the writer (clientActionToGen) writes it; save/cancel copied it", "file": "`mdl/executor/cmd_pages_builder_v3.go` (buildClientActionV3Base, case \"delete\")", "insight": "The audit of the other cases found no other dropped visitor flag, but two hard-coded ones describe cannot express: complete task always writes ClosePage/Commit true and show page/create object write NumberOfPagesToClose2 \"\" — every Studio Pro instance in TestApp/PedApp has those values, so they are latent, not live. A text round trip could not have caught this: describe printed the flag correctly and the second describe of the exec'd page printed `delete`, which looks like a user edit — read the stored ClosePage", "refs": ["ako/mxcli#950"]} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#950 item 2: describe of a flow ending `return [%CurrentUser%];` prints `return $[%CurrentUser%];`, which does not parse; `return if … then … else …` likewise became `return $if …` (two TestApp WorkflowCommons microflows)", "cause": "formatActivity's EndEvent branch added `$` to any return value without one of + ' \" ( ) — a character blacklist standing in for 'is a bare variable name'", "file": "`mdl/executor/cmd_microflows_format_action.go` (isBareReturnVariable)", "insight": "Restore a stripped sigil only for the positive shape it was stripped from (a bare name, optionally /path); a blacklist of characters lets every new expression form through. The other `$`-adding sites in describe prefix variable-NAME fields, not expressions, and are safe", "refs": ["ako/mxcli#950"]} +{"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#950 item 3: describe of a navigation list prints item actions as `show_page 'Mod.Page'` (does not parse — TestApp Rules.Entity_Menu, 3 syntax errors); with that fixed, exec refuses the description with `item inside navigationlist requires a name` because Studio Pro leaves items unnamed", "cause": "extractNavigationListItemAction had a private copy of the page-action rendering in the legacy form instead of the shared renderClientActionMDL; buildNavigationListItemV3 required a name Studio Pro never stores; and once exec accepted it, the writer wrote `Name: \"\"` and no ConditionalVisibilitySettings where Studio Pro stores no Name key and a null slot (6 of 6 items in TestApp), so GetPut still rewrote the snippet", "file": "`mdl/executor/cmd_pages_describe_parse.go` (extractNavigationListItemAction), `mdl/executor/cmd_pages_builder_v3_widgets.go` (buildNavigationListItemV3), `mdl/executor/cmd_pages_describe_output.go` (item header), `mdl/backend/modelsdk/widget_write.go` (navListItemToGen, Forms$NavigationListItem NullFields)", "insight": "Fixing the reported parse error only exposed the next law: the issue said the empty item name 'parses fine', which was true and irrelevant — exec refused it, and after that the writer rewrote it. An unnamed item with no Name key passes mx check at 11.14.0, contrary to the old ledger note that the key is mandatory (that applies to a NAMED item's key, not its absence). Run the whole describe → check → exec → describe chain on the Studio Pro-authored document before declaring a round-trip bug fixed", "refs": ["ako/mxcli#950"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index db87001692..bd848ee2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`delete close page` closes the page again after a describe → exec** (ako/mxcli#950) — a page button's `Action: delete close page` was stored with "Close page" off, so executing the description of a page whose delete button closes it changed what the button does, with `check`, `exec` and `mx check` all clean. Existing pages written by `exec` from such a script need the script run again. - **`describe` of a flow no longer puts `$` in front of a returned expression** (ako/mxcli#950) — `return [%CurrentUser%];` was described as `return $[%CurrentUser%];` and `return if … then … else …` as `return $if …`, neither of which parses. Only a bare variable name stored without its `$` gets one. +- **A navigation list's items describe as MDL that runs back** (ako/mxcli#950) — an item's page action was printed as the legacy `show_page 'Mod.Page'`, which does not parse; it is now `show page Mod.Page`, like a button's, for every action kind. An item may be unnamed (`item (Action: …) { … }`), as Studio Pro leaves it: `exec` used to refuse it with "item inside navigationlist requires a name". An unnamed item is written as Studio Pro writes it — no `Name` key, a null visibility slot — so executing the description of such a list writes nothing. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". - **A required caption with no text in the default language is foreseen** (ako/mxcli#944) — making de_DE the default left a stock app's `Administration.Account_Overview` `tabPage2` (en_US only) empty in de_DE, and mxbuild refused it with CE4899 "Empty caption. [German, Germany]" while `check -p`, `lint` and `exec` said nothing. Measured on 11.14, the tab page caption is the one caption kind the build requires (page titles, buttons, labels, group boxes, column headers, menu items, enumeration captions and messages build without it; page templates and building blocks are not checked). Now: **lint rule QUAL006** (error) lists every tab page caption without the default language; **`alter settings language (DefaultLanguageCode: …)`** prints how many there are and where, with the `alter page … { set (Caption: …) on … }` that fixes one; **`check -p` reports MDL-I18N01** for a script that changes the default — stored captions, and captions the script wrote before the change. And a page created **after** the change in the same script is now written in the new default: the authoring language was resolved once per session, so it was still written in the old one and failed the build too. diff --git a/mdl/backend/modelsdk/widget_navlist_item_write_test.go b/mdl/backend/modelsdk/widget_navlist_item_write_test.go new file mode 100644 index 0000000000..7e378bd3e4 --- /dev/null +++ b/mdl/backend/modelsdk/widget_navlist_item_write_test.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/modelsdk/codec" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#950: Studio Pro stores a navigation-list item with no Name key and +// a null ConditionalVisibilitySettings (six of six in ako/TestApp at 11.14.0). +// Writing `Name: ""` and no visibility slot made a describe → exec of +// Rules.Entity_Menu rewrite the snippet. A named item keeps its Name. +// +// The bare encoder here leaves an empty Name out either way, so the Name half +// is proven on the stored unit by the TestApp round trip +// (TestTestAppRoundTrip/snippet_Rules.Entity_Menu fails with "Name: added" +// without the guard); this test pins the visibility slot and the named case. +func TestNavListItemToGen_StudioProShape(t *testing.T) { + for _, c := range []struct { + name string + hasName bool + }{{"", false}, {"i1", true}} { + el, err := navListItemToGen(&pages.NavigationListItem{ + BaseElement: model.BaseElement{ID: "item-1"}, + Name: c.name, + }) + if err != nil { + t.Fatal(err) + } + out, err := (&codec.Encoder{}).Encode(el) + if err != nil { + t.Fatal(err) + } + var doc bson.M + if err := bson.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + if _, ok := doc["Name"]; ok != c.hasName { + t.Errorf("item named %q: Name key present = %v, want %v", c.name, ok, c.hasName) + } + cvs, ok := doc["ConditionalVisibilitySettings"] + if !ok || cvs != nil { + t.Errorf("item named %q: ConditionalVisibilitySettings = %v (present %v), want null", c.name, cvs, ok) + } + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 35178c3f8a..33855d0c0c 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -116,6 +116,11 @@ func init() { }) } codec.RegisterListMarker("Forms$LayoutGrid", 2) + // A navigation-list item carries a null ConditionalVisibilitySettings: six of + // six in ako/TestApp at 11.14.0 (ako/mxcli#950). + codec.RegisterTypeDefaults("Forms$NavigationListItem", codec.TypeDefaults{ + NullFields: []string{"ConditionalVisibilitySettings"}, + }) codec.RegisterListMarker("Forms$LayoutGridRow", 2) codec.RegisterListMarker("Forms$LayoutGridColumn", 2) // ActionButton: null Icon/visibility/accessibility slots; marker 2 as a widget. @@ -937,7 +942,14 @@ func navListItemToGen(item *pages.NavigationListItem) (element.Element, error) { // more than one item). The gen NavigationListItem type has no typed Name // setter, so write it as a raw property (like the legacy writer's Name key). // (ledger finding #24) - addStr(&g.Base, "Name", item.Name) + // + // An unnamed item is how Studio Pro stores every one it creates: no Name key + // at all (all six in ako/TestApp at 11.14.0), and `mx check` accepts that. + // Writing `Name: ""` for it turned a describe → exec of such a list into a + // rewrite (ako/mxcli#950). + if item.Name != "" { + addStr(&g.Base, "Name", item.Name) + } g.SetAppearance(newAppearance("", "", "", nil)) act, err := clientActionToGen(item.Action) if err != nil { diff --git a/mdl/executor/cmd_pages_builder_v3_widgets.go b/mdl/executor/cmd_pages_builder_v3_widgets.go index f8d10ce590..21fe6404e5 100644 --- a/mdl/executor/cmd_pages_builder_v3_widgets.go +++ b/mdl/executor/cmd_pages_builder_v3_widgets.go @@ -1152,10 +1152,10 @@ func (pb *pageBuilder) buildNavigationListV3(w *ast.WidgetV3) (*pages.Navigation // buildNavigationListItemV3 creates a NavigationListItem from V3 syntax. func (pb *pageBuilder) buildNavigationListItemV3(w *ast.WidgetV3) (*pages.NavigationListItem, error) { - if w.Name == "" { - return nil, mdlerrors.NewValidation("item inside navigationlist requires a name") - } - + // An item may be unnamed: Studio Pro stores Name "" on the items it + // creates (all three of TestApp's Rules.Entity_Menu), and describe prints + // them as `item (…)`. Refusing that made the describe of every such + // navigation list fail exec (ako/mxcli#950). item := &pages.NavigationListItem{ BaseElement: model.BaseElement{ ID: model.ID(types.GenerateID()), @@ -1164,8 +1164,10 @@ func (pb *pageBuilder) buildNavigationListItemV3(w *ast.WidgetV3) (*pages.Naviga Name: w.Name, } - if err := pb.registerWidgetName(w.Name, item.ID); err != nil { - return nil, err + if w.Name != "" { + if err := pb.registerWidgetName(w.Name, item.ID); err != nil { + return nil, err + } } // Set caption from Caption property diff --git a/mdl/executor/cmd_pages_describe_navlist_action_test.go b/mdl/executor/cmd_pages_describe_navlist_action_test.go new file mode 100644 index 0000000000..d9d19ebf44 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_navlist_action_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#950 item 3: a navigation-list item's page action was described in +// the legacy `show_page 'Mod.Page'` form, which does not parse — TestApp's +// Rules.Entity_Menu gave three syntax errors. It now goes through the shared +// client-action renderer the buttons use, so every stored shape (FormSettings, +// PageSettings) and every other action kind reads back. +func TestNavigationListItemAction_DescribesAsParseableMDL(t *testing.T) { + // The target pages take no parameters (TestApp's overview pages). + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return nil, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return nil, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb)) + cases := []struct { + name string + action map[string]any + want string + }{ + {"FormSettings", map[string]any{"$Type": "Forms$FormAction", + "FormSettings": map[string]any{"$Type": "Forms$FormSettings", "Form": "Rules.BusinessRule_Overview"}}, + "show page Rules.BusinessRule_Overview"}, + {"PageSettings", map[string]any{"$Type": "Forms$FormAction", + "PageSettings": map[string]any{"$Type": "Forms$FormSettings", "Form": "Rules.RuleAction_Overview"}}, + "show page Rules.RuleAction_Overview"}, + {"microflow", map[string]any{"$Type": "Forms$MicroflowAction", + "MicroflowSettings": map[string]any{"$Type": "Forms$MicroflowSettings", "Microflow": "Rules.ACT_Open"}}, + "call microflow Rules.ACT_Open"}, + {"sign out", map[string]any{"$Type": "Forms$SignOutClientAction"}, "sign out"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := extractNavigationListItemAction(ctx, map[string]any{"Action": c.action}) + if got != c.want { + t.Fatalf("described %q, want %q", got, c.want) + } + prog, errs := visitor.Build("create snippet M.S {\n navigationlist nl {\n item i1 (Action: " + got + + ") {\n dynamictext t (Content: 'x')\n }\n }\n}") + if len(errs) > 0 { + t.Fatalf("%q does not parse: %v", got, errs[0]) + } + nl := prog.Statements[0].(*ast.CreateSnippetStmtV3).Widgets[0] + if _, ok := nl.Children[0].Properties["Action"].(*ast.ActionV3); !ok { + t.Errorf("item action is %T, want *ast.ActionV3", nl.Children[0].Properties["Action"]) + } + }) + } +} diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 6494f3236d..12a3673635 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -920,7 +920,10 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { case "Forms$NavigationList", "Pages$NavigationList": fmt.Fprintf(ctx.Output, "%snavigationlist %s {\n", prefix, mdlIdent(w.Name)) for _, child := range w.Children { - itemHeader := fmt.Sprintf("item %s", mdlIdent(child.Name)) + itemHeader := "item" // Studio Pro leaves an item unnamed + if child.Name != "" { + itemHeader += " " + mdlIdent(child.Name) + } props := []string{} if child.Action != "" { props = append(props, actionProp("Action", child.Action)) diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index ebf24fd8c3..4e8e408bb7 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/visitor" - "github.com/mendixlabs/mxcli/model" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -863,41 +862,13 @@ func parseNavigationListItems(ctx *ExecContext, w map[string]any) []rawWidget { return result } -// extractNavigationListItemAction extracts action from a NavigationListItem. -// NavigationListItem uses Forms$FormAction with FormSettings.Form for page references, -// which differs from ActionButton's action format. +// extractNavigationListItemAction renders a NavigationListItem's action. It is +// the same client action a button stores (a page action is a Forms$FormAction +// whose page sits in FormSettings or PageSettings), so it goes through the +// shared renderer. A private copy here printed the legacy `show_page 'M.P'`, +// which does not parse (ako/mxcli#950). func extractNavigationListItemAction(ctx *ExecContext, w map[string]any) string { - action, ok := w["Action"].(map[string]any) - if !ok { - return "" - } - typeName, _ := action["$Type"].(string) - switch typeName { - case "Forms$FormAction", "Pages$FormAction": - // Extract page reference from FormSettings (Studio Pro format) - if formSettings, ok := action["FormSettings"].(map[string]any); ok { - if formName, ok := formSettings["Form"].(string); ok && formName != "" { - return "show_page '" + formName + "'" - } - } - // Fall back to PageSettings.Form (string name) - if pageSettings, ok := action["PageSettings"].(map[string]any); ok { - if pageName, ok := pageSettings["Form"].(string); ok && pageName != "" { - return "show_page '" + pageName + "'" - } - } - // Fall back to Page field (binary ID from mxcli serialization) - if pageID := extractBinaryID(action["Page"]); pageID != "" { - pageName := getPageQualifiedName(ctx, model.ID(pageID)) - if pageName != "" { - return "show_page '" + pageName + "'" - } - } - return "show_page" - default: - // Delegate to the standard action extractor - return extractButtonAction(ctx, w) - } + return extractButtonAction(ctx, w) } // parseDataViewChildren extracts child widgets from a DataView. diff --git a/mdl/roundtrip/testapp_allowlist_test.go b/mdl/roundtrip/testapp_allowlist_test.go index 42eb678af2..3ea9defca0 100644 --- a/mdl/roundtrip/testapp_allowlist_test.go +++ b/mdl/roundtrip/testapp_allowlist_test.go @@ -33,6 +33,10 @@ package roundtrip // stopped failing exec with it — their list view is typed Administration.Account // again, so a nested snippet call finds its $Account — and now break only // GetPut, on the snippet call's null visibility key (C3). +// ako/mxcli#950 struck three: Rules.Entity_Menu (navigation-list items described +// as the legacy `show_page 'M.P'`, and unnamed items refused by exec) and two +// WorkflowCommons microflows whose `return if … then … else …` was described +// with a `$` in front. var testAppKnownFailures = map[string]knownFailure{ "association ViewAssociations.persistent_order": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "association WorkflowCommons.ConflictedWorkflowDefinitionView_WorkflowDefinition": {laws: []law{lawExec}, issue: "#721", why: "association: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, @@ -187,7 +191,6 @@ var testAppKnownFailures = map[string]knownFailure{ "scheduled event WorkflowCommons.SE_WorkflowAuditTrailRecord_CleanUp": {laws: []law{lawGetPut}, issue: "#721", why: "scheduled event: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet Administration.ReadMe": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet Atlas_Core.FeedbackWidget": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, - "snippet Rules.Entity_Menu": {laws: []law{lawParse}, issue: "#721", why: "snippet: breaks parse on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Readme": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks getput on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, "snippet WorkflowCommons.Snip_Assignee_Search": {laws: []law{lawGetPut}, issue: "#721", why: "snippet: breaks exec on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes; executes since a widget bound to a snippet parameter is described as $Param.Attr — getput not yet triaged"}, "snippet WorkflowCommons.Snip_AuditTrailViewer_Toolbar": {laws: []law{lawGetPut, lawPutGet}, issue: "#721", why: "snippet: breaks getput and putget on TestApp, measured when it joined the harness (#743); not yet triaged into #721's classes"}, From 8c64d16d448b4a7efe51067f39a51193237718f3 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:28:02 +0000 Subject: [PATCH 14/18] test: round-trip harness covers the three #950 describe outputs describe -> check -> exec -> describe on PedApp for a delete-close-page button (stored ClosePage compared, plain delete as control), a flow returning [%CurrentUser%], and a navigation list with an unnamed item. Co-Authored-By: Claude Opus 5.5 --- mdl/roundtrip/describe_reparse_950_test.go | 151 +++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 mdl/roundtrip/describe_reparse_950_test.go diff --git a/mdl/roundtrip/describe_reparse_950_test.go b/mdl/roundtrip/describe_reparse_950_test.go new file mode 100644 index 0000000000..38aefd4fc8 --- /dev/null +++ b/mdl/roundtrip/describe_reparse_950_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package roundtrip + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ako/mxcli#950: three describe outputs that did not survive describe → check → +// exec → describe. Each case creates the subject, then executes its own +// description, and compares what is stored — not only the text — before and +// after: +// +// - `delete close page` was stored as ClosePage=false, so the button stopped +// closing its page. Plain `delete` is the control: it must stay false. +// - `return [%CurrentUser%];` was described as `return $[%CurrentUser%];`, +// which does not parse. +// - a navigation-list item's page action was described as the legacy +// `show_page 'M.P'`, which does not parse. (TestApp's Studio Pro-authored +// Rules.Entity_Menu, whose items are also unnamed, is covered by the +// whole-fixture round trip.) +const describe950 = `create persistent entity MyFirstModule.Thing950 (Name: String(100)); +create or modify page MyFirstModule.Thing950_Edit (Title: 'Edit', Layout: Atlas_Core.PopupLayout, Params: { $Thing: MyFirstModule.Thing950 }) { + dataview dv (DataSource: $Thing) { + actionbutton bDelClose (Caption: 'Delete and close', Action: delete close page) + actionbutton bDel (Caption: 'Delete', Action: delete) + } +}; +create or modify page MyFirstModule.Thing950_Overview (Title: 'Things', Layout: Atlas_Core.Atlas_Default) { + dynamictext t (Content: 'Things') +}; +create or modify page MyFirstModule.Menu950 (Title: 'Menu', Layout: Atlas_Core.Atlas_Default) { + navigationlist nav { + item (Action: show page MyFirstModule.Thing950_Overview) { + dynamictext t1 (Content: 'Things') + } + item i2 (Action: sign out) { + dynamictext t2 (Content: 'Sign out') + } + } +}; +create or modify microflow MyFirstModule.CurrentUser950 () +returns System.User +begin + return [%CurrentUser%]; +end; +` + +func TestDescribeReExecutes_950(t *testing.T) { + h := newHarness(t) + defer h.close() + if err := h.exec(describe950); err != nil { + t.Fatalf("create: %v\n%s", err, h.out.String()) + } + wantClose := map[string]bool{"bDelClose": true, "bDel": false} + if got := deleteClosePage(t, h.pageUnit(t, "Thing950_Edit")); !mapsEqual(got, wantClose) { + t.Fatalf("as created, delete actions store ClosePage %v, want %v", got, wantClose) + } + + for _, c := range []struct{ target, want string }{ + {"page MyFirstModule.Thing950_Edit", "Action: delete close page"}, + {"page MyFirstModule.Menu950", "item (Action: show page MyFirstModule.Thing950_Overview)"}, + {"microflow MyFirstModule.CurrentUser950", "return [%CurrentUser%];"}, + } { + t.Run(c.target, func(t *testing.T) { + first := h.mustDescribeMdl0(t, c.target) + if !strings.Contains(first, c.want) { + t.Fatalf("describe has no %q:\n%s", c.want, first) + } + if errs := h.checkReferences(first); len(errs) > 0 { + t.Fatalf("check of the description: %v\n%s", errs[0], first) + } + before := h.snapshot() + if err := h.exec(first); err != nil { + t.Fatalf("exec the description: %v\n%s", err, first) + } + if changed := before.diff(h.snapshot()); len(changed) > 0 { + t.Errorf("executing the description wrote:\n %s", strings.Join(changed, "\n ")) + } + if again := h.mustDescribeMdl0(t, c.target); again != first { + t.Errorf("describe after exec differs:\n--- before ---\n%s\n--- after ---\n%s", first, again) + } + }) + } + if got := deleteClosePage(t, h.pageUnit(t, "Thing950_Edit")); !mapsEqual(got, wantClose) { + t.Errorf("after describe → exec, delete actions store ClosePage %v, want %v", got, wantClose) + } +} + +// deleteClosePage maps each action button's name to the ClosePage its +// Forms$DeleteClientAction stores. +func deleteClosePage(t *testing.T, unit []byte) map[string]bool { + t.Helper() + var doc bson.D + if err := bson.Unmarshal(unit, &doc); err != nil { + t.Fatal(err) + } + out := map[string]bool{} + var walk func(any) + walk = func(v any) { + switch x := v.(type) { + case bson.D: + name := "" + for _, e := range x { + if e.Key == "Name" { + name, _ = e.Value.(string) + } + } + for _, e := range x { + if a, ok := e.Value.(bson.D); ok && e.Key == "Action" { + typ, closePage := "", false + for _, f := range a { + switch f.Key { + case "$Type": + typ, _ = f.Value.(string) + case "ClosePage": + closePage, _ = f.Value.(bool) + } + } + if typ == "Forms$DeleteClientAction" { + out[name] = closePage + } + } + walk(e.Value) + } + case bson.A: + for _, e := range x { + walk(e) + } + } + } + walk(doc) + return out +} + +func mapsEqual(a, b map[string]bool) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if w, ok := b[k]; !ok || w != v { + return false + } + } + return true +} From 190be49e533b5f1f3171b11258f3bfc48360ec26 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:28:57 +0000 Subject: [PATCH 15/18] fix(check): a void Java/JavaScript action call declares no variable; MDL063 covers nanoflows Two calls to a void action carrying the same output name (Studio Pro names a JavaScript action's after the action, $RefreshEntity) are accepted by mxbuild 11.13.0, but check reported MDL063 (microflows) or 'already declared in this scope' (nanoflows), and describe called the model invalid. Measured: a void call's output name is inert - a later declare of the name builds clean, a use is CE0109. The return type is resolved from the script or, with -p, the project; an unresolvable action still counts. describe keeps printing the stored `$X =`: Studio Pro stores the name with UseReturnVariable=true, and the bare form would write an empty name. MDL063 now runs for nanoflows too (flow-wide, as measured in mxbuild): a duplicate output across if/else branches passed check and was CE0111. The check-time body validator no longer reports duplicate names for flows - it scoped them per branch and counted void calls; rules keep it. Part of #953 (item 1). Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + .../write-microflows/reference/pitfalls.md | 12 +- CHANGELOG.md | 2 + mdl/executor/bugfix_test.go | 36 ++- mdl/executor/cmd_microflows_builder.go | 8 + .../cmd_microflows_builder_validate.go | 21 +- .../cmd_microflows_duplicate_output_test.go | 90 +------ mdl/executor/cmd_microflows_show.go | 16 +- mdl/executor/validate_microflow.go | 12 + mdl/executor/validate_microflow_ce_gaps.go | 17 +- mdl/executor/validate_nanoflow.go | 42 ++- mdl/executor/validate_program.go | 18 +- mdl/executor/validate_void_code_calls.go | 127 +++++++++ mdl/executor/validate_void_code_calls_test.go | 249 ++++++++++++++++++ 14 files changed, 545 insertions(+), 106 deletions(-) create mode 100644 mdl/executor/validate_void_code_calls.go create mode 100644 mdl/executor/validate_void_code_calls_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index b9b64c0ccd..413dd84d95 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -844,3 +844,4 @@ {"date": "2026-10-03", "area": "mdl/executor/drop", "symptom": "`drop microflow M.F;` in one `mxcli exec` run and `create microflow M.F …` in the next leaves M.F with no module-role grants (CE0106 on the pages calling it); the drop printed only \"Dropped microflow: M.F\"", "cause": "the grants carry only through the session cache (rememberDroppedMicroflow / consumeDroppedMicroflow), which a later process does not have; nothing told the user the carry was session-scoped. `drop page` never remembers its AllowedRoles at all", "fix": "writeDroppedGrantsNote (mdl/executor/drop_grants_note.go), called from execDropMicroflow / execDropNanoflow / execDropPage, prints the removed roles, whether a create carries them (same script or session for flows; never for a page), and the `grant` that restores them; flowRefusal's rebuild advice says drop + create in the same script", "insight": "A carry that lives in a session cache is invisible at the statement that creates it; the place to say so is the drop, which is the last moment the roles are known. Snippets have no access roles, so they need nothing", "issue": "ako/mxcli#944", "file": "mdl/executor/drop_grants_note.go; cmd_microflows_drop.go; cmd_nanoflows_drop.go; cmd_pages_builder.go; flow_verdict.go", "test": "mdl/executor/drop_grants_note_test.go; flow_verdict_test.go (TestFlowRefusalNamesTheFlowAndTheReason)"} {"date": "2026-10-03", "area": "mdl/executor/settings", "symptom": "after `alter settings language (DefaultLanguageCode: 'de_DE')`, `docker check` fails with CE4899 \"Empty caption. [German, Germany]\" at Tab page 'tabPage2' (Administration.Account_Overview, en_US only) while `check -p --references`, `lint` and exec are silent; a page created AFTER the switch in the same script fails the same way", "cause": "nothing compared required captions with DefaultLanguageCode (QUAL005 compares languages with each other, and `mxcli lint` does not even run it); and describeDefaultLanguage cached the authoring language once per session, so the switch did not reach later creates", "fix": "translations.MissingRequiredCaptions (measured set: Forms$TabPage.Caption in pages, snippets, layouts; templates and building blocks skipped) feeds lint QUAL006, the note printed by alterSettings (defaultLanguageChanged, which also drops the cached authoring language) and check -p MDL-I18N01 (CheckDefaultLanguageCaptions simulates which documents the script writes before/after the switch)", "insight": "Measure which caption kinds the build requires before flagging: of eleven kinds written en_US-only, only the tab page caption failed; flagging the rest would have made an error rule wrong ten times out of eleven. The first lint run also flagged 22 page-template tab pages mxbuild never reported, caught only by comparing lint's count with docker check's (1 vs 1 after the fix, 3 vs 3 on the e2e script)", "issue": "ako/mxcli#944", "file": "mdl/translations/required.go; mdl/linter/rules/required_captions.go; mdl/executor/default_language_captions.go; mdl/executor/cmd_settings.go (defaultLanguageChanged)", "test": "mdl/translations/required_test.go; mdl/linter/rules/required_captions_test.go; mdl/executor/default_language_captions_test.go"} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#944: describe printed `clear $L;` and `set $L = $M;` for a Change list Clear/Set (Replace) action; `clear` did not parse, and `set` on a list variable executed to a Change variable action that passes mxcli check but mx check refuses (CE7247 \"Variable 'A' does not have a primitive type\")", "cause": "the grammar had only add/remove for Change list, and the builder routed every `set $X = …` to ChangeVariableAction regardless of the target's type", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (clearListStatement), `mdl/executor/cmd_microflows_builder_graph.go` (MfSetStmt → addReplaceListAction when isListVariable)", "insight": "A describe → exec round trip that compares only MDL text passes when the action TYPE changes but prints the same — the Set revert check stayed green until the test also read the stored $Type/Type from the unit. Assert the stored shape, not just the re-described text", "refs": ["ako/mxcli#944"]} +{"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#953 item 1: two calls to a VOID Java/JavaScript action with the same output name (Studio Pro names a JS one after the action, `$RefreshEntity`) failed `check` — MDL063 in a microflow, \"duplicate variable name … already declared in this scope (CE0111)\" in a nanoflow — while mxbuild 11.13.0 builds them clean; describe's header also called the model invalid. Alongside it, the opposite: a real duplicate output in a nanoflow's if/else branches passed check and was CE0111 in mxbuild", "cause": "every named call output counted as a declaration regardless of the action's return type; MDL063 ran for microflows only, and the nanoflow path relied on the check-time body validator (validateFlowBody), which scoped names per branch", "file": "`mdl/executor/validate_void_code_calls.go` (voidCodeActions: script declarations + lazily opened project), `validate_microflow_ce_gaps.go` (checkDuplicateVariableNames skips void calls, walks error handlers), `validate_nanoflow.go` (MDL063 for nanoflows), `cmd_microflows_builder_validate.go` (duplicatesOwnedElsewhere), `cmd_microflows_show.go` (duplicateOutputVariableWarnings)", "insight": "Measure what the name IS before deciding whether to print it: a void call's output name is inert in mxbuild (a later `declare` of the same name is clean, a use is CE0109 Undefined variable), but Studio Pro stores it with UseReturnVariable=true, and the bare `call …` form writes an empty name — so describe keeps `$X =` for the round trip and only the declaration count changes. The Java action reader returns a NIL ReturnType for Void (codeActionReturnTypeFromGen) while the JavaScript reader returns a VoidType; a mock built from the type name passed while the real project did not — run the end-to-end check on a real project. Two validators owning one rule disagreed in both directions; give the rule one owner (MDL063) and switch the other off for it.", "refs": ["ako/mxcli#953"]} diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index 65540475ae..b4d13fe39a 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -207,8 +207,9 @@ return $Found; **Error**: CE0111 - "Duplicate variable name 'X'." (MDL063) -A microflow's variable names are unique **flow-wide**. Branches and loop bodies -do not open a scope, and parameters and loop iterators share the same namespace. +A microflow's (and a nanoflow's) variable names are unique **flow-wide**. +Branches and loop bodies do not open a scope, and parameters and loop iterators +share the same namespace. The trap is that every activity with an output **creates** its variable — there is no form in which a call, a retrieve, an aggregate or an import mapping writes into one that already exists. @@ -232,6 +233,13 @@ declare $Session string = ''; set $Session = 'anonymous'; -- valid, any number of times ``` +A call to a Java or JavaScript action that returns **Void** creates nothing +either, whatever output name it carries — Studio Pro keeps one on such calls +(a JavaScript action's is named after the action, e.g. `$RefreshEntity`), and +two of them in one flow build clean. `describe` keeps printing the stored name +so a round trip does not change the model. The name is not a variable: using +`$RefreshEntity` afterwards is CE0109 "Undefined variable". + ### 10. Calling a Rule or Microflow Inside an Expression **Error**: CE0117 - "Error(s) in expression." (MDL066) diff --git a/CHANGELOG.md b/CHANGELOG.md index f78ba22a38..4a2c4c931f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A call to a void Java or JavaScript action declares no variable** (ako/mxcli#953) — two calls carrying the same output name (Studio Pro names a JavaScript action's after the action, `$RefreshEntity`) no longer fail `check` with MDL063 or "already declared in this scope", and `describe` no longer calls the model invalid; mxbuild 11.13.0 builds them clean. The action's return type comes from the script or, with `-p`, the project; an action neither knows still counts. `describe` keeps printing the stored `$X =`, so a round trip leaves the model as it was. MDL063 now also covers nanoflows, whose names are as flat as a microflow's: a duplicate output across if/else branches passed `check` and was CE0111. + - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". - **A required caption with no text in the default language is foreseen** (ako/mxcli#944) — making de_DE the default left a stock app's `Administration.Account_Overview` `tabPage2` (en_US only) empty in de_DE, and mxbuild refused it with CE4899 "Empty caption. [German, Germany]" while `check -p`, `lint` and `exec` said nothing. Measured on 11.14, the tab page caption is the one caption kind the build requires (page titles, buttons, labels, group boxes, column headers, menu items, enumeration captions and messages build without it; page templates and building blocks are not checked). Now: **lint rule QUAL006** (error) lists every tab page caption without the default language; **`alter settings language (DefaultLanguageCode: …)`** prints how many there are and where, with the `alter page … { set (Caption: …) on … }` that fixes one; **`check -p` reports MDL-I18N01** for a script that changes the default — stored captions, and captions the script wrote before the change. And a page created **after** the change in the same script is now written in the new default: the authoring language was resolved once per session, so it was still written in the old one and failed the build too. diff --git a/mdl/executor/bugfix_test.go b/mdl/executor/bugfix_test.go index a925224180..a6552e4f08 100644 --- a/mdl/executor/bugfix_test.go +++ b/mdl/executor/bugfix_test.go @@ -27,7 +27,7 @@ begin return $Count; end;` - errors := validateMicroflowFromMDL(t, input) + errors := duplicateNameErrorsFromMDL(t, input) found := false for _, e := range errors { @@ -50,7 +50,7 @@ begin declare $X String = 'hello'; end;` - errors := validateMicroflowFromMDL(t, input) + errors := duplicateNameErrorsFromMDL(t, input) found := false for _, e := range errors { @@ -72,7 +72,7 @@ begin retrieve $Items from Test.SomeEntity; end;` - errors := validateMicroflowFromMDL(t, input) + errors := duplicateNameErrorsFromMDL(t, input) for _, e := range errors { if strings.Contains(e, "duplicate") { @@ -90,7 +90,7 @@ begin $NewTodo = create Test.Todo(); end;` - errors := validateMicroflowFromMDL(t, input) + errors := duplicateNameErrorsFromMDL(t, input) found := false for _, e := range errors { @@ -116,7 +116,7 @@ begin $Summary = call microflow Test.Inner(Tag = 'description'); $Summary = call microflow Test.Inner(Tag = 'summary'); end;` - errors := validateMicroflowFromMDL(t, input) + errors := duplicateNameErrorsFromMDL(t, input) if !hasDupError(errors, "Summary") { t.Errorf("Expected duplicate variable error for $Summary, got: %v", errors) } @@ -130,13 +130,37 @@ begin $Summary = call microflow Test.Inner(Tag = 'summary'); end if; end;` - errors := validateMicroflowFromMDL(t, input) + errors := duplicateNameErrorsFromMDL(t, input) if !hasDupError(errors, "Summary") { t.Errorf("Expected duplicate variable error for $Summary (fallback in if), got: %v", errors) } }) } +// duplicateNameErrorsFromMDL returns the MDL063 messages for the script's +// microflow, each prefixed "duplicate:". Duplicate names are MDL063's alone +// since #953: the check-time body validator scoped them per branch and counted +// void action calls, so it was wrong in both directions and no longer reports +// them for a flow. +func duplicateNameErrorsFromMDL(t *testing.T, input string) []string { + t.Helper() + prog, errs := visitor.Build(input) + if len(errs) > 0 { + t.Fatalf("Parse error: %v", errs[0]) + } + stmt, ok := prog.Statements[0].(*ast.CreateMicroflowStmt) + if !ok { + t.Fatalf("Expected CreateMicroflowStmt, got %T", prog.Statements[0]) + } + var out []string + for _, v := range ValidateMicroflow(stmt) { + if v.RuleID == "MDL063" { + out = append(out, "duplicate: "+v.Message) + } + } + return out +} + func hasDupError(errors []string, varName string) bool { for _, e := range errors { if strings.Contains(e, "duplicate") && strings.Contains(e, varName) { diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index 3d2aac3a3a..dc96d1f1cd 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -16,6 +16,14 @@ import ( // flowBuilder helps construct the flow graph from AST statements. type flowBuilder struct { + // duplicateNamesOwnedElsewhere stops the check-time body validation + // (validateFlowBody) from reporting a variable name created twice. For a + // microflow or nanoflow that is MDL063's job, which knows the namespace is + // flow-wide and that a void action call declares nothing (#953); this + // validator scoped names per branch and counted every call output, so it + // was wrong both ways. Rules keep it — MDL063 does not run on them. + duplicateNamesOwnedElsewhere bool + objects []microflows.MicroflowObject flows []*microflows.SequenceFlow annotationFlows []*microflows.AnnotationFlow diff --git a/mdl/executor/cmd_microflows_builder_validate.go b/mdl/executor/cmd_microflows_builder_validate.go index 3768066005..185d09f232 100644 --- a/mdl/executor/cmd_microflows_builder_validate.go +++ b/mdl/executor/cmd_microflows_builder_validate.go @@ -12,17 +12,19 @@ import ( // ValidateMicroflowBody validates the microflow body for semantic errors without building objects. // This is used by the check command to validate scripts without executing them. func ValidateMicroflowBody(s *ast.CreateMicroflowStmt) []string { - return validateFlowBody(s.Parameters, s.Body) + return validateFlowBody(s.Parameters, s.Body, true) } // ValidateNanoflowBody validates the nanoflow body for semantic errors without building objects. // This is used by the check command to validate scripts without executing them. func ValidateNanoflowBody(s *ast.CreateNanoflowStmt) []string { - return validateFlowBody(s.Parameters, s.Body) + return validateFlowBody(s.Parameters, s.Body, true) } // validateFlowBody validates parameters and body statements for semantic errors. -func validateFlowBody(params []ast.MicroflowParam, body []ast.MicroflowStatement) []string { +// duplicatesOwnedElsewhere leaves duplicate variable names to MDL063 — see +// flowBuilder.duplicateNamesOwnedElsewhere. +func validateFlowBody(params []ast.MicroflowParam, body []ast.MicroflowStatement, duplicatesOwnedElsewhere bool) []string { varTypes := make(map[string]string) declaredVars := make(map[string]string) @@ -50,9 +52,10 @@ func validateFlowBody(params []ast.MicroflowParam, body []ast.MicroflowStatement } fb := &flowBuilder{ - varTypes: varTypes, - declaredVars: declaredVars, - errors: []string{}, + varTypes: varTypes, + declaredVars: declaredVars, + errors: []string{}, + duplicateNamesOwnedElsewhere: duplicatesOwnedElsewhere, } fb.validateStatements(body) @@ -80,7 +83,7 @@ func (fb *flowBuilder) validateStatement(stmt ast.MicroflowStatement) { switch s := stmt.(type) { case *ast.DeclareStmt: // Check for duplicate variable declaration - if fb.isVariableDeclared(s.Variable) { + if !fb.duplicateNamesOwnedElsewhere && fb.isVariableDeclared(s.Variable) { fb.addError("duplicate variable name '$%s' — variable is already declared (CE0111)", s.Variable) } // Register the variable as declared @@ -376,7 +379,7 @@ func (fb *flowBuilder) validateStatement(stmt ast.MicroflowStatement) { } func (fb *flowBuilder) validateOutputVariable(varName, statement string) { - if varName == "" { + if varName == "" || fb.duplicateNamesOwnedElsewhere { return } if fb.isVariableDeclared(varName) { @@ -389,5 +392,5 @@ func (fb *flowBuilder) validateOutputVariable(varName, statement string) { // counterpart of ValidateMicroflowBody. What a rule may not *contain* is a // separate question, answered by validateRule. func ValidateRuleBody(s *ast.CreateRuleStmt) []string { - return validateFlowBody(s.Parameters, s.Body) + return validateFlowBody(s.Parameters, s.Body, false) } diff --git a/mdl/executor/cmd_microflows_duplicate_output_test.go b/mdl/executor/cmd_microflows_duplicate_output_test.go index f671b8326a..9ee63e835f 100644 --- a/mdl/executor/cmd_microflows_duplicate_output_test.go +++ b/mdl/executor/cmd_microflows_duplicate_output_test.go @@ -12,7 +12,11 @@ import ( "github.com/mendixlabs/mxcli/sdk/microflows" ) -func TestValidateMicroflowBodyRejectsDuplicateImplicitOutputs(t *testing.T) { +// Duplicate variable names in a flow are MDL063's (ValidateMicroflow), which +// treats the namespace as flow-wide and skips void action calls. The check-time +// body validator used to report them too, scoped per branch — which mxbuild +// does not do (CE0111 across if/else branches) — so it no longer does (#953). +func TestValidateMicroflowRejectsDuplicateImplicitOutputs(t *testing.T) { entityRef := ast.QualifiedName{Module: "Synthetic", Name: "Item"} stmt := &ast.CreateMicroflowStmt{ Body: []ast.MicroflowStatement{ @@ -28,16 +32,13 @@ func TestValidateMicroflowBodyRejectsDuplicateImplicitOutputs(t *testing.T) { }, } - errs := ValidateMicroflowBody(stmt) - if len(errs) == 0 { - t.Fatalf("expected duplicate output variable validation error") - } - if !strings.Contains(errs[0], "duplicate variable name '$Item'") { - t.Fatalf("validation error = %#v, want duplicate $Item", errs) + got := mdl063(ValidateMicroflow(stmt)) + if len(got) != 1 || !strings.Contains(got[0].Message, "'$Item' is created twice") { + t.Fatalf("MDL063 = %v, want one duplicate $Item", got) } } -func TestValidateMicroflowBodyRejectsDuplicateCallOutputs(t *testing.T) { +func TestValidateMicroflowRejectsDuplicateCallOutputs(t *testing.T) { stmt := &ast.CreateMicroflowStmt{ Body: []ast.MicroflowStatement{ &ast.CallMicroflowStmt{ @@ -51,74 +52,9 @@ func TestValidateMicroflowBodyRejectsDuplicateCallOutputs(t *testing.T) { }, } - errs := ValidateMicroflowBody(stmt) - if len(errs) == 0 { - t.Fatalf("expected duplicate call output validation error") - } - if !strings.Contains(errs[0], "duplicate variable name '$Result'") { - t.Fatalf("validation error = %#v, want duplicate $Result", errs) - } -} - -func TestValidateMicroflowBodyAllowsDuplicateOutputsInExclusiveBranches(t *testing.T) { - entityRef := ast.QualifiedName{Module: "Synthetic", Name: "Item"} - stmt := &ast.CreateMicroflowStmt{ - Body: []ast.MicroflowStatement{ - &ast.IfStmt{ - Condition: &ast.VariableExpr{Name: "UsePrimaryPath"}, - ThenBody: []ast.MicroflowStatement{ - &ast.CreateObjectStmt{Variable: "Result", EntityType: entityRef}, - &ast.ReturnStmt{}, - }, - ElseBody: []ast.MicroflowStatement{ - &ast.RetrieveStmt{Variable: "Result", Source: entityRef, Limit: "1"}, - &ast.ReturnStmt{}, - }, - }, - }, - } - - errs := ValidateMicroflowBody(stmt) - for _, err := range errs { - if strings.Contains(err, "duplicate variable name '$Result'") { - t.Fatalf("exclusive branches must not share duplicate-output scope: %#v", errs) - } - } -} - -func TestValidateMicroflowBodyAllowsDuplicateOutputsInEnumCases(t *testing.T) { - entityRef := ast.QualifiedName{Module: "Synthetic", Name: "Item"} - stmt := &ast.CreateMicroflowStmt{ - Body: []ast.MicroflowStatement{ - &ast.EnumSplitStmt{ - Variable: "Route", - Cases: []ast.EnumSplitCase{ - { - Value: "First", - Body: []ast.MicroflowStatement{ - &ast.CallJavaActionStmt{OutputVariable: "GeneratedID", ActionName: ast.QualifiedName{Module: "Synthetic", Name: "Generate"}}, - &ast.CreateObjectStmt{Variable: "Result", EntityType: entityRef}, - &ast.ReturnStmt{}, - }, - }, - { - Value: "Second", - Body: []ast.MicroflowStatement{ - &ast.CallJavaActionStmt{OutputVariable: "GeneratedID", ActionName: ast.QualifiedName{Module: "Synthetic", Name: "Generate"}}, - &ast.CreateObjectStmt{Variable: "Result", EntityType: entityRef}, - &ast.ReturnStmt{}, - }, - }, - }, - }, - }, - } - - errs := strings.Join(ValidateMicroflowBody(stmt), "\n") - for _, name := range []string{"GeneratedID", "Result"} { - if strings.Contains(errs, "duplicate variable name '$"+name+"'") { - t.Fatalf("enum cases must not share duplicate-output scope: %s", errs) - } + got := mdl063(ValidateMicroflow(stmt)) + if len(got) != 1 || !strings.Contains(got[0].Message, "'$Result' is created twice") { + t.Fatalf("MDL063 = %v, want one duplicate $Result", got) } } @@ -320,5 +256,5 @@ func TestFormatMicroflowActivitiesHighComplexityCompletes(t *testing.T) { oc := µflows.MicroflowObjectCollection{Objects: objs, Flows: flows} // Just needs to complete; path enumeration would be 2^120. - _ = duplicateOutputVariableWarnings(oc) + _ = duplicateOutputVariableWarnings(oc, func(any) bool { return false }) } diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index cd23a08cb9..5ba682a54c 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/microflowgraph" "github.com/mendixlabs/mxcli/mdl/types" @@ -878,7 +879,10 @@ func formatMicroflowActivities( // high-complexity flows — 20 sequential if/end-if diamonds already took ~10s // (issue #710). Microflow control flow is a DAG (loops are nested inside // LoopedActivity nodes, not back-edges), so reachability is exact and cheap. -func duplicateOutputVariableWarnings(oc *microflows.MicroflowObjectCollection) []string { +// +// isVoidCall, when it says so, marks an action whose output name declares no +// variable — a call to a void Java/JavaScript action. +func duplicateOutputVariableWarnings(oc *microflows.MicroflowObjectCollection, isVoidCall func(action any) bool) []string { warningPositions := make(map[string]model.Point) record := func(name string, pos model.Point) { if _, ok := warningPositions[name]; !ok { @@ -934,7 +938,7 @@ func duplicateOutputVariableWarnings(oc *microflows.MicroflowObjectCollection) [ for _, obj := range collection.Objects { switch o := obj.(type) { case *microflows.ActionActivity: - if name := actionOutputVariableName(o.Action); name != "" { + if name := actionOutputVariableName(o.Action); name != "" && !isVoidCall(o.Action) { assignments[name] = append(assignments[name], assignment{id: o.GetID(), pos: o.GetPosition()}) } case *microflows.LoopedActivity: @@ -1747,7 +1751,13 @@ func microflowBodyWarnings( declaredCrossed map[model.ID]bool, ) []string { var out []string - out = append(out, duplicateOutputVariableWarnings(mf.ObjectCollection)...) + // A call to a void Java/JavaScript action declares nothing, so its output + // name is no duplicate however often it recurs (#953). + var voids *voidCodeActions + if ctx != nil && ctx.Backend != nil { + voids = newVoidCodeActions(nil, func() backend.FullBackend { return ctx.Backend }) + } + out = append(out, duplicateOutputVariableWarnings(mf.ObjectCollection, voids.actionIsVoidCall)...) out = append(out, irreducibleGraphWarnings(mf.ObjectCollection, declaredCrossed)...) out = append(out, droppedMergeWarnings(ctx, mf.ObjectCollection, labels)...) return out diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 9b2873c276..3e670d59cc 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -16,11 +16,20 @@ import ( // ValidateMicroflow checks a microflow for common issues that don't require a project connection. // Returns a list of structured violations with rule IDs. func ValidateMicroflow(stmt *ast.CreateMicroflowStmt) []linter.Violation { + return validateMicroflowWith(stmt, nil) +} + +// validateMicroflowWith is ValidateMicroflow with a resolver for the return +// types of the Java/JavaScript actions the body calls, so a call to a void +// action is not counted as declaring its output name (MDL063, #953). A nil +// resolver knows no action, and every named call output counts. +func validateMicroflowWith(stmt *ast.CreateMicroflowStmt, voids *voidCodeActions) []linter.Violation { v := µflowValidator{ mfName: stmt.Name.String(), docType: "microflow", returnType: stmt.ReturnType, varKinds: map[string]exprcheck.TypeKind{}, + voids: voids, } // Seed the variable→kind scope with the microflow's parameters so numeric // assignment checks can resolve operands like $count. @@ -89,6 +98,9 @@ type microflowValidator struct { // excluded marks an @excluded document. mxbuild does not check one, so the // #893 rules stand down for it — see skipCEGapRules. excluded bool + // voids resolves which Java/JavaScript action calls target a void action; + // such a call declares no variable (MDL063, #953). May be nil. + voids *voidCodeActions } func (v *microflowValidator) addViolation(ruleID string, severity linter.Severity, message, suggestion string) { diff --git a/mdl/executor/validate_microflow_ce_gaps.go b/mdl/executor/validate_microflow_ce_gaps.go index 8218a96620..d9c31018af 100644 --- a/mdl/executor/validate_microflow_ce_gaps.go +++ b/mdl/executor/validate_microflow_ce_gaps.go @@ -252,10 +252,10 @@ func (v *microflowValidator) checkDuplicateVariableNames(params []ast.MicroflowP return // MDL052 } v.addViolation("MDL063", linter.SeverityError, - fmt.Sprintf("'$%s' is created twice in this microflow — first by %s, then by %s. "+ - "A microflow's variable names are unique flow-wide (branches and loop bodies do "+ + fmt.Sprintf("'$%s' is created twice in this %s — first by %s, then by %s. "+ + "A %s's variable names are unique flow-wide (branches and loop bodies do "+ "not open a scope), so mxbuild rejects this with CE0111 \"Duplicate variable name\".", - p.name, prev.label, p.label), + p.name, v.docType, prev.label, p.label, v.docType), fmt.Sprintf("Rename one of them, or — if you meant to reuse the first — drop the "+ "redundant definition: %s already creates '$%s', and an activity always creates "+ "its own output variable rather than writing into an existing one", @@ -264,14 +264,17 @@ func (v *microflowValidator) checkDuplicateVariableNames(params []ast.MicroflowP for _, p := range params { if p.Name != "" { - report(producedVar{name: p.Name, label: "the microflow parameter"}) + report(producedVar{name: p.Name, label: "the " + v.docType + " parameter"}) } } var walk func(stmts []ast.MicroflowStatement) walk = func(stmts []ast.MicroflowStatement) { for _, s := range stmts { - if v.buildsAsAProducer(s) { + // A call to a void Java/JavaScript action keeps an output name in + // the model and declares nothing: two of them build clean, and so + // does a later declare of the same name (#953, mxbuild 11.13.0). + if v.buildsAsAProducer(s) && !v.voids.callIsVoid(s) { for _, p := range statementProducedVars(s) { report(p) } @@ -295,6 +298,10 @@ func (v *microflowValidator) checkDuplicateVariableNames(params []ast.MicroflowP } walk(st.ElseBody) } + // A custom error handler's activities share the flow's namespace. + if eh := stmtErrorHandling(s); eh != nil { + walk(eh.Body) + } } } walk(body) diff --git a/mdl/executor/validate_nanoflow.go b/mdl/executor/validate_nanoflow.go index a3599b31d7..73f62f2e36 100644 --- a/mdl/executor/validate_nanoflow.go +++ b/mdl/executor/validate_nanoflow.go @@ -12,8 +12,9 @@ import ( "github.com/mendixlabs/mxcli/mdl/linter" ) -// ValidateNanoflow runs the MDL0xx rules that hold for a nanoflow body — today -// only MDL044, an expression calling a name that is not a Mendix function. +// ValidateNanoflow runs the MDL0xx rules that hold for a nanoflow body: MDL044, +// an expression calling a name that is not a Mendix function, and MDL063, a +// variable name created twice. // // MDL044 was wired to CREATE MICROFLOW alone (#828), so `currentDeviceType()` // in a nanoflow passed check and exec and failed the build with CE0117 @@ -21,14 +22,32 @@ import ( // wholesale: several rules are microflow-specific and would be false positives // here — MDL057 refuses `synchronize`, which only a nanoflow may contain. func ValidateNanoflow(stmt *ast.CreateNanoflowStmt) []linter.Violation { + return validateNanoflowWith(stmt, nil) +} + +// validateNanoflowWith is ValidateNanoflow with a resolver for the return types +// of the Java/JavaScript actions the body calls (see validateMicroflowWith). +func validateNanoflowWith(stmt *ast.CreateNanoflowStmt, voids *voidCodeActions) []linter.Violation { v := µflowValidator{ mfName: stmt.Name.String(), docType: "nanoflow", returnType: stmt.ReturnType, varKinds: map[string]exprcheck.TypeKind{}, + params: stmt.Parameters, + excluded: stmt.Excluded, + voids: voids, } v.walkExprFunctions(stmt.Body) v.walkAnnotations(stmt.Body) + // MDL063 holds for a nanoflow too: its variable names are as flat as a + // microflow's. Measured on mxbuild 11.13.0, each is CE0111 in a nanoflow — + // a non-void call in each of two if/else branches, a parameter and a + // declare, a declare inside a loop body and another after it. The + // exec-side validator treated branches as scopes and passed the first + // (ako/mxcli#953). The kinds MDL063 reads to spare the String + // contains/find rewrite come from the parameters and the declares. + v.seedPrimitiveKinds(stmt.Parameters, stmt.Body) + v.checkDuplicateVariableNames(v.params, stmt.Body) // The nanoflow restrictions exec's build refuses (validateNanoflow): an // action a nanoflow cannot hold, an error-handling clause its activity // rejects (CE6035), a Binary return. They ran only inside exec, so `check` @@ -133,3 +152,22 @@ func validateNanoflowRules(stmt *ast.CreateNanoflowStmt) error { return mdlerrors.NewValidationf("nanoflow '%s' has validation errors:\n - %s", stmt.Name.String(), strings.Join(msgs, "\n - ")) } + +// seedPrimitiveKinds records the primitive kind of every parameter and declare, +// which a microflow's walkBody records as it goes. The nanoflow walk does not +// run walkBody, and MDL063 reads these kinds to tell a String contains/find — +// which the builder rewrites into a Change Variable — from a list operation. +func (v *microflowValidator) seedPrimitiveKinds(params []ast.MicroflowParam, body []ast.MicroflowStatement) { + for _, p := range params { + if k, ok := astKindToExprKind(p.Type.Kind); ok { + v.varKinds[p.Name] = k + } + } + forEachMicroflowStatement(body, func(s ast.MicroflowStatement) { + if d, ok := s.(*ast.DeclareStmt); ok { + if k, ok := astKindToExprKind(d.Type.Kind); ok { + v.varKinds[d.Variable] = k + } + } + }) +} diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index b927b5f33b..98462e98e4 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -4,6 +4,7 @@ package executor import ( "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" "github.com/mendixlabs/mxcli/mdl/linter" ) @@ -26,6 +27,19 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // Statement-level checks that need no project connection. var violations []linter.Violation securityEnabled := programEnablesSecurity(prog) + // Which Java/JavaScript action calls target a void action — such a call + // declares no variable (MDL063, #953). The script answers for the actions + // it creates; the project, opened only if a call needs it, for the rest. + var voidsProject backend.FullBackend + voids := newVoidCodeActions(prog, func() backend.FullBackend { + voidsProject = openProjectForValidation(projectPath) + return voidsProject + }) + defer func() { + if voidsProject != nil { + _ = voidsProject.Disconnect() + } + }() for _, stmt := range prog.Statements { // Check enumeration values for reserved words if enumStmt, ok := stmt.(*ast.CreateEnumerationStmt); ok { @@ -85,7 +99,7 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { } // Check microflow body for common issues if mfStmt, ok := stmt.(*ast.CreateMicroflowStmt); ok { - violations = append(violations, ValidateMicroflow(mfStmt)...) + violations = append(violations, validateMicroflowWith(mfStmt, voids)...) violations = append(violations, ValidateFlowParameterAnnotations("microflow '"+mfStmt.Name.String()+"'", mfStmt.Parameters)...) } @@ -93,7 +107,7 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // ValidateMicroflow but share the parameter grammar. if nfStmt, ok := stmt.(*ast.CreateNanoflowStmt); ok { // MDL044 over the body (mendixlabs/mxcli#1033). - violations = append(violations, ValidateNanoflow(nfStmt)...) + violations = append(violations, validateNanoflowWith(nfStmt, voids)...) violations = append(violations, ValidateFlowParameterAnnotations("nanoflow '"+nfStmt.Name.String()+"'", nfStmt.Parameters)...) } diff --git a/mdl/executor/validate_void_code_calls.go b/mdl/executor/validate_void_code_calls.go new file mode 100644 index 0000000000..8214d01c27 --- /dev/null +++ b/mdl/executor/validate_void_code_calls.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// voidCodeActions answers one question for the duplicate-variable checks: does +// a Java or JavaScript action call target an action that returns nothing? +// +// A call to a VOID action declares no variable, whatever output name it +// carries (ako/mxcli#953). Studio Pro keeps an output name on such a call — it +// names a JavaScript action's after the action (`$RefreshEntity`) and stores +// UseReturnVariable=true on some — so a model holding two of them is ordinary. +// Measured on mxbuild 11.13.0: +// +// two void calls with the same output name 0 errors +// void call `$V = …` then `declare $V String` 0 errors +// void call `$V = …` then a log message using `$V` CE0109 "Undefined variable 'V'" +// void JS call `$W = …` then a non-void JS call `$W` 0 errors +// +// The name is inert: it neither collides with a later variable nor defines one. +// +// An action the resolver cannot find (no project, a runtime-provided System +// action) is NOT treated as void: the author wrote `$X =`, which normally asks +// for a value, and guessing void would silence a real CE0111. +type voidCodeActions struct { + // script holds the actions the script itself creates, keyed by + // codeActionKey, valued true when the action returns Void. + script map[string]bool + // open returns the project to read stored actions from, or nil. + open func() backend.FullBackend + opened bool + b backend.FullBackend + cache map[string]bool +} + +func codeActionKey(javaScript bool, qn string) string { + if javaScript { + return "js:" + qn + } + return "java:" + qn +} + +// newVoidCodeActions collects the script's own action declarations; open, +// which may be nil, supplies the project for the rest. +func newVoidCodeActions(prog *ast.Program, open func() backend.FullBackend) *voidCodeActions { + r := &voidCodeActions{script: map[string]bool{}, open: open, cache: map[string]bool{}} + if prog == nil { + return r + } + for _, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.CreateJavaActionStmt: + r.script[codeActionKey(false, s.Name.String())] = s.ReturnType.Kind == ast.TypeVoid + case *ast.CreateJavaScriptActionStmt: + r.script[codeActionKey(true, s.Name.String())] = s.ReturnType.Kind == ast.TypeVoid + } + } + return r +} + +func (r *voidCodeActions) project() backend.FullBackend { + if !r.opened { + r.opened = true + if r.open != nil { + r.b = r.open() + } + } + return r.b +} + +// isVoid reports whether the named action is known to return Void. +func (r *voidCodeActions) isVoid(javaScript bool, qn string) bool { + if r == nil || qn == "" { + return false + } + key := codeActionKey(javaScript, qn) + if v, ok := r.script[key]; ok { + return v + } + if v, ok := r.cache[key]; ok { + return v + } + void := false + if b := r.project(); b != nil { + if javaScript { + if a, err := b.ReadJavaScriptActionByName(qn); err == nil && a != nil && a.ReturnType != nil { + void = a.ReturnType.TypeString() == "Void" + } + } else { + // The Java action reader returns a nil ReturnType for Void + // (codeActionReturnTypeFromGen); the JavaScript one a VoidType. + if a, err := b.ReadJavaActionByName(qn); err == nil && a != nil { + void = a.ReturnType == nil || a.ReturnType.TypeString() == "Void" + } + } + } + r.cache[key] = void + return void +} + +// callIsVoid reports whether a statement is a call to a void Java or +// JavaScript action — one whose output name declares nothing. +func (r *voidCodeActions) callIsVoid(s ast.MicroflowStatement) bool { + switch st := s.(type) { + case *ast.CallJavaActionStmt: + return r.isVoid(false, st.ActionName.String()) + case *ast.CallJavaScriptActionStmt: + return r.isVoid(true, st.ActionName.String()) + } + return false +} + +// actionIsVoidCall is callIsVoid for a stored action, used by describe. +func (r *voidCodeActions) actionIsVoidCall(action any) bool { + switch a := action.(type) { + case *microflows.JavaActionCallAction: + return r.isVoid(false, a.JavaAction) + case *microflows.JavaScriptActionCallAction: + return r.isVoid(true, a.JavaScriptAction) + } + return false +} diff --git a/mdl/executor/validate_void_code_calls_test.go b/mdl/executor/validate_void_code_calls_test.go new file mode 100644 index 0000000000..7613832e9a --- /dev/null +++ b/mdl/executor/validate_void_code_calls_test.go @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#953 item 1: a call to a VOID Java/JavaScript action keeps an +// output name in the model and declares no variable, so two of them are not a +// CE0111 — and a nanoflow's variable names are as flat as a microflow's, so a +// real duplicate across if/else branches is. Measured on mxbuild 11.13.0; see +// validate_void_code_calls.go. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/javaactions" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + + +func javaCall(out, action string) *ast.CallJavaActionStmt { + return &ast.CallJavaActionStmt{OutputVariable: out, ActionName: qn("M", action)} +} + +func jsCall(out, action string) *ast.CallJavaScriptActionStmt { + return &ast.CallJavaScriptActionStmt{OutputVariable: out, ActionName: qn("M", action)} +} + +func mdl063(vs []linter.Violation) []linter.Violation { + var out []linter.Violation + for _, v := range vs { + if v.RuleID == "MDL063" { + out = append(out, v) + } + } + return out +} + +// codeActionDecls declares a Java and a JavaScript action of each return kind. +func codeActionDecls() []ast.Statement { + return []ast.Statement{ + &ast.CreateJavaActionStmt{Name: qn("M", "JaVoid"), ReturnType: ast.DataType{Kind: ast.TypeVoid}}, + &ast.CreateJavaActionStmt{Name: qn("M", "JaBool"), ReturnType: ast.DataType{Kind: ast.TypeBoolean}}, + &ast.CreateJavaScriptActionStmt{Name: qn("M", "JsVoid"), ReturnType: ast.DataType{Kind: ast.TypeVoid}}, + &ast.CreateJavaScriptActionStmt{Name: qn("M", "JsBool"), ReturnType: ast.DataType{Kind: ast.TypeBoolean}}, + } +} + +func programWith(flow ast.Statement) *ast.Program { + return &ast.Program{Statements: append(codeActionDecls(), flow)} +} + +func TestMDL063_VoidCallOutputsAreNotDeclarations(t *testing.T) { + cases := []struct { + name string + flow ast.Statement + want int // MDL063 violations + }{ + {"microflow: two void java calls, same name", &ast.CreateMicroflowStmt{Name: qn("M", "Mf"), + Body: []ast.MicroflowStatement{javaCall("R", "JaVoid"), javaCall("R", "JaVoid")}}, 0}, + {"microflow control: two Boolean java calls, same name", &ast.CreateMicroflowStmt{Name: qn("M", "Mf"), + Body: []ast.MicroflowStatement{javaCall("R", "JaBool"), javaCall("R", "JaBool")}}, 1}, + {"microflow: void call then a declare of the same name", &ast.CreateMicroflowStmt{Name: qn("M", "Mf"), + Body: []ast.MicroflowStatement{javaCall("V", "JaVoid"), + &ast.DeclareStmt{Variable: "V", Type: ast.DataType{Kind: ast.TypeString}, + InitialValue: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}}}}, 0}, + {"nanoflow: two void JS calls, same name", &ast.CreateNanoflowStmt{Name: qn("M", "Nf"), + Body: []ast.MicroflowStatement{jsCall("RefreshEntity", "JsVoid"), jsCall("RefreshEntity", "JsVoid")}}, 0}, + {"nanoflow control: two Boolean JS calls, same name", &ast.CreateNanoflowStmt{Name: qn("M", "Nf"), + Body: []ast.MicroflowStatement{jsCall("R", "JsBool"), jsCall("R", "JsBool")}}, 1}, + {"nanoflow: void JS call then a non-void one, same name", &ast.CreateNanoflowStmt{Name: qn("M", "Nf"), + Body: []ast.MicroflowStatement{jsCall("W", "JsVoid"), jsCall("W", "JsBool")}}, 0}, + // An action nobody can resolve keeps counting: guessing void would + // silence a real CE0111. + {"microflow: unresolvable action still counts", &ast.CreateMicroflowStmt{Name: qn("M", "Mf"), + Body: []ast.MicroflowStatement{javaCall("R", "Elsewhere"), javaCall("R", "Elsewhere")}}, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mdl063(ValidateProgram(programWith(tc.flow), "")) + if len(got) != tc.want { + t.Fatalf("MDL063 count = %d, want %d: %v", len(got), tc.want, got) + } + }) + } +} + +// The false negative found alongside: a nanoflow's branches do not open a scope +// either. mxbuild 11.13.0 reports CE0111 for each of these in a nanoflow. +func TestMDL063_NanoflowNamesAreFlowWide(t *testing.T) { + str := ast.DataType{Kind: ast.TypeString} + lit := &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"} + cond := &ast.VariableExpr{Name: "C"} + cases := []struct { + name string + params []ast.MicroflowParam + body []ast.MicroflowStatement + want int + }{ + {"non-void call in each if/else branch", nil, []ast.MicroflowStatement{&ast.IfStmt{Condition: cond, + ThenBody: []ast.MicroflowStatement{jsCall("R", "JsBool")}, + ElseBody: []ast.MicroflowStatement{jsCall("R", "JsBool")}}}, 1}, + {"control: different names per branch", nil, []ast.MicroflowStatement{&ast.IfStmt{Condition: cond, + ThenBody: []ast.MicroflowStatement{jsCall("R1", "JsBool")}, + ElseBody: []ast.MicroflowStatement{jsCall("R2", "JsBool")}}}, 0}, + {"void call in each branch", nil, []ast.MicroflowStatement{&ast.IfStmt{Condition: cond, + ThenBody: []ast.MicroflowStatement{jsCall("R", "JsVoid")}, + ElseBody: []ast.MicroflowStatement{jsCall("R", "JsVoid")}}}, 0}, + {"declare in each branch", nil, []ast.MicroflowStatement{&ast.IfStmt{Condition: cond, + ThenBody: []ast.MicroflowStatement{&ast.DeclareStmt{Variable: "E", Type: str, InitialValue: lit}}, + ElseBody: []ast.MicroflowStatement{&ast.DeclareStmt{Variable: "E", Type: str, InitialValue: lit}}}}, 1}, + {"parameter and declare", []ast.MicroflowParam{{Name: "P", Type: str}}, + []ast.MicroflowStatement{&ast.DeclareStmt{Variable: "P", Type: str, InitialValue: lit}}, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + nf := &ast.CreateNanoflowStmt{Name: qn("M", "Nf"), Parameters: tc.params, Body: tc.body} + got := mdl063(ValidateProgram(programWith(nf), "")) + if len(got) != tc.want { + t.Fatalf("MDL063 count = %d, want %d: %v", len(got), tc.want, got) + } + if tc.want > 0 && !strings.Contains(got[0].Message, "in this nanoflow") { + t.Errorf("message names the wrong document kind: %s", got[0].Message) + } + }) + } +} + +// The builder rewrites a String contains/find into a Change Variable, so it +// creates nothing; MDL063 must stay silent on it in a nanoflow as it does in a +// microflow (the kinds it reads were never recorded for a nanoflow). +func TestMDL063_NanoflowStringContainsIsNotAProducer(t *testing.T) { + str := ast.DataType{Kind: ast.TypeString} + nf := &ast.CreateNanoflowStmt{Name: qn("M", "Nf"), + Parameters: []ast.MicroflowParam{{Name: "Hay", Type: str}}, + Body: []ast.MicroflowStatement{ + &ast.DeclareStmt{Variable: "Found", Type: ast.DataType{Kind: ast.TypeBoolean}, + InitialValue: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: false}}, + &ast.ListOperationStmt{OutputVariable: "Found", Operation: ast.ListOpContains, InputVariable: "Hay"}, + }} + if got := mdl063(ValidateNanoflow(nf)); len(got) != 0 { + t.Fatalf("MDL063 on a String contains: %v", got) + } +} + +// Stored actions are read from the project: Studio Pro's NanoflowCommons +// RefreshEntity is void, and its calls carry `$RefreshEntity`. +func TestVoidCodeActions_ReadsStoredReturnType(t *testing.T) { + b := &mock.MockBackend{ + ReadJavaScriptActionByNameFunc: func(name string) (*types.JavaScriptAction, error) { + switch name { + case "NanoflowCommons.RefreshEntity": + return &types.JavaScriptAction{ReturnType: &types.VoidType{}}, nil + case "M.IsStrict": + return &types.JavaScriptAction{ReturnType: &types.BooleanType{}}, nil + } + return nil, nil + }, + ReadJavaActionByNameFunc: func(name string) (*javaactions.JavaAction, error) { + switch name { + case "M.JaVoid": + // What the modelsdk reader returns for a stored Void. + return &javaactions.JavaAction{ReturnType: nil}, nil + case "M.JaVoidTyped": + return &javaactions.JavaAction{ReturnType: &javaactions.VoidType{}}, nil + case "M.JaBool": + return &javaactions.JavaAction{ReturnType: &javaactions.BooleanType{}}, nil + } + return nil, nil + }, + } + r := newVoidCodeActions(nil, func() backend.FullBackend { return b }) + if !r.isVoid(true, "NanoflowCommons.RefreshEntity") { + t.Error("stored void JS action not recognised") + } + if r.isVoid(true, "M.IsStrict") { + t.Error("control: a Boolean JS action read as void") + } + if !r.isVoid(false, "M.JaVoid") { + t.Error("stored void Java action not recognised") + } + if !r.isVoid(false, "M.JaVoidTyped") { + t.Error("a Java action with a VoidType return not recognised") + } + if r.isVoid(false, "M.JaBool") { + t.Error("control: a Boolean Java action read as void") + } + if r.isVoid(false, "M.Missing") { + t.Error("an action the project does not have read as void") + } +} + +// The check-time body validator no longer reports duplicate names for a flow +// — MDL063 owns that, flow-wide and void-aware. It kept counting a void JS +// call's output ("already declared in this scope") and scoped names per branch. +func TestValidateFlowBody_LeavesFlowDuplicatesToMDL063(t *testing.T) { + body := []ast.MicroflowStatement{jsCall("RefreshEntity", "JsVoid"), jsCall("RefreshEntity", "JsVoid")} + if errs := ValidateNanoflowBody(&ast.CreateNanoflowStmt{Name: qn("M", "Nf"), Body: body}); len(errs) != 0 { + t.Fatalf("nanoflow body validator still reports duplicates: %v", errs) + } + if errs := ValidateMicroflowBody(&ast.CreateMicroflowStmt{Name: qn("M", "Mf"), + Body: []ast.MicroflowStatement{javaCall("R", "JaVoid"), javaCall("R", "JaVoid")}}); len(errs) != 0 { + t.Fatalf("microflow body validator still reports duplicates: %v", errs) + } + // A rule has no MDL063, so its body validator keeps the check. + str := ast.DataType{Kind: ast.TypeString} + lit := &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"} + rule := &ast.CreateRuleStmt{Name: qn("M", "Rule"), Body: []ast.MicroflowStatement{ + &ast.DeclareStmt{Variable: "X", Type: str, InitialValue: lit}, + &ast.DeclareStmt{Variable: "X", Type: str, InitialValue: lit}, + }} + if errs := ValidateRuleBody(rule); len(errs) == 0 { + t.Fatal("control: a rule's duplicate declare is no longer reported") + } +} + +// describe's "duplicate output variable … model is invalid" header is false +// for void calls: mxbuild builds two of them clean. +func TestDescribeDuplicateWarning_SkipsVoidCalls(t *testing.T) { + call := func(id string, x int) *microflows.ActionActivity { + a := µflows.ActionActivity{Action: µflows.JavaScriptActionCallAction{ + JavaScriptAction: "NanoflowCommons.RefreshEntity", OutputVariableName: "RefreshEntity", UseReturnVariable: true}} + a.ID = model.ID(id) + a.Position = model.Point{X: x, Y: 100} + return a + } + start := µflows.StartEvent{} + start.ID = "start" + end := µflows.EndEvent{} + end.ID = "end" + oc := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{start, call("a", 100), call("b", 200), end}, + Flows: []*microflows.SequenceFlow{ + {OriginID: "start", DestinationID: "a"}, + {OriginID: "a", DestinationID: "b"}, + {OriginID: "b", DestinationID: "end"}, + }, + } + if w := duplicateOutputVariableWarnings(oc, func(any) bool { return true }); len(w) != 0 { + t.Fatalf("void calls warned as duplicates: %v", w) + } + if w := duplicateOutputVariableWarnings(oc, func(any) bool { return false }); len(w) != 1 { + t.Fatalf("control: non-void duplicate not warned: %v", w) + } +} From 4d53f68364ec2a18b14b49224faa206ddcb736e0 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:28:57 +0000 Subject: [PATCH 16/18] fix(check): unknown call parameters are no longer hidden behind other errors check --references accepted a Java action argument naming no parameter (CE1613 in mxbuild) whenever the script also had a semantic error: the reference tier was skipped, and a flow's body errors were returned instead of its reference errors. Both are now reported. call microflow / call nanoflow arguments are checked against the callee's parameters too (CE1613, measured on 11.13.0), and an action or flow known to take no parameters reports any named argument. Part of #953 (item 7). Co-Authored-By: Claude Opus 5.5 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + CHANGELOG.md | 1 + cmd/mxcli/check_void_calls_test.go | 85 +++++++++++ cmd/mxcli/cmd_check.go | 16 ++- mdl/executor/validate.go | 133 ++++++++++++++---- .../validate_flow_call_params_test.go | 99 +++++++++++++ 6 files changed, 300 insertions(+), 35 deletions(-) create mode 100644 cmd/mxcli/check_void_calls_test.go create mode 100644 mdl/executor/validate_flow_call_params_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 413dd84d95..694a7b5c02 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -845,3 +845,4 @@ {"date": "2026-10-03", "area": "mdl/executor/settings", "symptom": "after `alter settings language (DefaultLanguageCode: 'de_DE')`, `docker check` fails with CE4899 \"Empty caption. [German, Germany]\" at Tab page 'tabPage2' (Administration.Account_Overview, en_US only) while `check -p --references`, `lint` and exec are silent; a page created AFTER the switch in the same script fails the same way", "cause": "nothing compared required captions with DefaultLanguageCode (QUAL005 compares languages with each other, and `mxcli lint` does not even run it); and describeDefaultLanguage cached the authoring language once per session, so the switch did not reach later creates", "fix": "translations.MissingRequiredCaptions (measured set: Forms$TabPage.Caption in pages, snippets, layouts; templates and building blocks skipped) feeds lint QUAL006, the note printed by alterSettings (defaultLanguageChanged, which also drops the cached authoring language) and check -p MDL-I18N01 (CheckDefaultLanguageCaptions simulates which documents the script writes before/after the switch)", "insight": "Measure which caption kinds the build requires before flagging: of eleven kinds written en_US-only, only the tab page caption failed; flagging the rest would have made an error rule wrong ten times out of eleven. The first lint run also flagged 22 page-template tab pages mxbuild never reported, caught only by comparing lint's count with docker check's (1 vs 1 after the fix, 3 vs 3 on the e2e script)", "issue": "ako/mxcli#944", "file": "mdl/translations/required.go; mdl/linter/rules/required_captions.go; mdl/executor/default_language_captions.go; mdl/executor/cmd_settings.go (defaultLanguageChanged)", "test": "mdl/translations/required_test.go; mdl/linter/rules/required_captions_test.go; mdl/executor/default_language_captions_test.go"} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#944: describe printed `clear $L;` and `set $L = $M;` for a Change list Clear/Set (Replace) action; `clear` did not parse, and `set` on a list variable executed to a Change variable action that passes mxcli check but mx check refuses (CE7247 \"Variable 'A' does not have a primitive type\")", "cause": "the grammar had only add/remove for Change list, and the builder routed every `set $X = …` to ChangeVariableAction regardless of the target's type", "file": "`mdl/grammar/domains/MDLMicroflow.g4` (clearListStatement), `mdl/executor/cmd_microflows_builder_graph.go` (MfSetStmt → addReplaceListAction when isListVariable)", "insight": "A describe → exec round trip that compares only MDL text passes when the action TYPE changes but prints the same — the Set revert check stayed green until the test also read the stored $Type/Type from the unit. Assert the stored shape, not just the re-described text", "refs": ["ako/mxcli#944"]} {"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#953 item 1: two calls to a VOID Java/JavaScript action with the same output name (Studio Pro names a JS one after the action, `$RefreshEntity`) failed `check` — MDL063 in a microflow, \"duplicate variable name … already declared in this scope (CE0111)\" in a nanoflow — while mxbuild 11.13.0 builds them clean; describe's header also called the model invalid. Alongside it, the opposite: a real duplicate output in a nanoflow's if/else branches passed check and was CE0111 in mxbuild", "cause": "every named call output counted as a declaration regardless of the action's return type; MDL063 ran for microflows only, and the nanoflow path relied on the check-time body validator (validateFlowBody), which scoped names per branch", "file": "`mdl/executor/validate_void_code_calls.go` (voidCodeActions: script declarations + lazily opened project), `validate_microflow_ce_gaps.go` (checkDuplicateVariableNames skips void calls, walks error handlers), `validate_nanoflow.go` (MDL063 for nanoflows), `cmd_microflows_builder_validate.go` (duplicatesOwnedElsewhere), `cmd_microflows_show.go` (duplicateOutputVariableWarnings)", "insight": "Measure what the name IS before deciding whether to print it: a void call's output name is inert in mxbuild (a later `declare` of the same name is clean, a use is CE0109 Undefined variable), but Studio Pro stores it with UseReturnVariable=true, and the bare `call …` form writes an empty name — so describe keeps `$X =` for the round trip and only the declaration count changes. The Java action reader returns a NIL ReturnType for Void (codeActionReturnTypeFromGen) while the JavaScript reader returns a VoidType; a mock built from the type name passed while the real project did not — run the end-to-end check on a real project. Two validators owning one rule disagreed in both directions; give the rule one owner (MDL063) and switch the other off for it.", "refs": ["ako/mxcli#953"]} +{"area": "mdl/executor", "date": "2026-10-03", "symptom": "ako/mxcli#953 item 7: `check --references` accepted `call java action M.ValidateEmail(email = …)` (the parameter is EmailAddress), which mxbuild rejects with CE1613; `call microflow` / `call nanoflow` with an argument naming no parameter of the callee passed even in isolation", "cause": "the Java/JavaScript parameter check existed, but `check` returned before the reference tier whenever a semantic rule reported an error (the repro also had a true MDL063), and validateWithContext returned a flow's body-validation errors INSTEAD of its reference errors; flow calls had no parameter-name check at all", "file": "`cmd/mxcli/cmd_check.go` (reference tier runs after semantic errors), `mdl/executor/validate.go` (flowCallRef + flowValidationError; validateCodeActionParams treats a known-empty parameter list as none)", "insight": "A check that 'accepts' an input may never have looked at it: before writing a new rule, run the existing one in isolation — here it already fired, and the bug was the early return two tiers up. mxbuild's CE1613 is itself a gate (it hides the other errors in the same run), so measure one fault per mx check.", "refs": ["ako/mxcli#953"]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2c4c931f..d1e0a73d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **A call to a void Java or JavaScript action declares no variable** (ako/mxcli#953) — two calls carrying the same output name (Studio Pro names a JavaScript action's after the action, `$RefreshEntity`) no longer fail `check` with MDL063 or "already declared in this scope", and `describe` no longer calls the model invalid; mxbuild 11.13.0 builds them clean. The action's return type comes from the script or, with `-p`, the project; an action neither knows still counts. `describe` keeps printing the stored `$X =`, so a round trip leaves the model as it was. MDL063 now also covers nanoflows, whose names are as flat as a microflow's: a duplicate output across if/else branches passed `check` and was CE0111. +- **`check --references` reports a call to a parameter the callee does not have** (ako/mxcli#953) — the reference tier no longer stops behind an earlier semantic error, which hid a Java action argument mxbuild rejects with CE1613; a flow's body errors and reference errors are reported together; and `call microflow` / `call nanoflow` arguments are checked against the callee's parameters as Java and JavaScript action calls already were. - **`create or modify` of a flow matches `commit … with events`, a legacy `split type` spelling and an empty `else` against what is stored** (ako/mxcli#942). Describe prints a stored commit as a bare `commit`, the `when … then` split form, and no empty `else`. The statement diff compared the spelling, so these never matched their own activity. An unchanged re-run reported "Unchanged … (spliced: 1 replaced)". A change inside a loop body next to such a statement was not refused under `mdl 1`, and the loop was rebuilt with new element IDs. `without events` is still a change. **The loop-body refusal also holds when another statement changes next to the loop:** before, only a loop-body change on its own was refused. **`describe` no longer warns that the merge closing an `if` at the end of a loop body "joins no decision"** and would be deleted. The check counted a loop body's flows from the loop's own collection, which holds none. - **Less noise from `check` and `test`** (ako/mxcli#943) — **MDL-WORKFLOW10** no longer warns when the task is claimed in a called microflow: a callee the script creates is read (nested calls too), and with `-p` a stored one; a call that passes the task to a microflow neither can find counts as a possible claim. A callee that does not claim the task it is passed still warns. **`mxcli test`** no longer prints MDL-DEPR001 / MDL-V1-SLASH warnings about the endpoint-registration script it generates itself: that script is `mdl 1`. **`check -p`** drops **MDL067** for a commit already stored the way the script writes it, as `exec` already did. **MDL-WIDGET15** skips a dynamictext with its own `class:` or `style:` (a laid-out label/value pair is not fused text), names the two widgets, and every widget-rule diagnostic now carries its page or snippet as its location instead of "(no module)". diff --git a/cmd/mxcli/check_void_calls_test.go b/cmd/mxcli/check_void_calls_test.go new file mode 100644 index 0000000000..59d3a1e9c5 --- /dev/null +++ b/cmd/mxcli/check_void_calls_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ako/mxcli#953 against a real project (PedApp's FeedbackModule, a Marketplace +// module Studio Pro authored): +// +// - item 1: a call to a stored VOID JavaScript action declares no variable, +// so two calls carrying the same output name are not MDL063 (mxbuild +// 11.13.0 builds them clean). Control: the same pair on a Boolean action is. +// - item 7: `check --references` stopped at the first tier with an error, so +// a semantic error hid a call naming a parameter the Java action does not +// have (CE1613). The reference tier now runs and reports it too. +func TestCheckReferences_VoidCallsAndHiddenParameterErrors(t *testing.T) { + src := filepath.Join("..", "..", "testdata", "pedapp") + if _, err := os.Stat(filepath.Join(src, "PedApp.mpr")); err != nil { + t.Skipf("PedApp fixture not found: %v", err) + } + dir := t.TempDir() + if err := copyTree(src, dir); err != nil { + t.Fatal(err) + } + mpr := filepath.Join(dir, "PedApp.mpr") + + _ = checkCmd.InheritedFlags() + + _ = rootCmd.PersistentFlags().Set("project", mpr) + _ = checkCmd.Flags().Set("references", "true") + defer func() { + _ = rootCmd.PersistentFlags().Set("project", "") + rootCmd.PersistentFlags().Lookup("project").Changed = false + _ = checkCmd.Flags().Set("references", "false") + checkCmd.Flags().Lookup("references").Changed = false + }() + + check := func(script string) (int, string) { + file := writeScript(t, t.TempDir(), "s.mdl", "mdl 1;\n"+script) + var code int + out := captureStd(t, func() { code = runCheckFiles(checkCmd, []string{file}) }) + return code, out + } + + code, out := check(`create nanoflow MyFirstModule.NF_RevokeTwice () +begin + $ReturnValueName = call javascript action FeedbackModule.JS_RevokeUploadedFileFromMemory(fileBlobURL = 'a'); + $ReturnValueName = call javascript action FeedbackModule.JS_RevokeUploadedFileFromMemory(fileBlobURL = 'b'); +end; +`) + if code != 0 || strings.Contains(out, "MDL063") || strings.Contains(out, "already declared") { + t.Errorf("two calls to a void action reported as a duplicate (exit %d):\n%s", code, out) + } + + code, out = check(`create nanoflow MyFirstModule.NF_StrictTwice () +begin + $IsStrict = call javascript action FeedbackModule.JS_isStrictMode(); + $IsStrict = call javascript action FeedbackModule.JS_isStrictMode(); +end; +`) + if code == 0 || !strings.Contains(out, "MDL063") { + t.Errorf("control: two calls to a Boolean action not reported (exit %d):\n%s", code, out) + } + + code, out = check(`create microflow MyFirstModule.MF_SaveLog ($Email: String) +begin + if $Email != empty then + $Valid = call java action FeedbackModule.ValidateEmail(email = $Email); + else + $Valid = call java action FeedbackModule.ValidateEmail(email = 'x@y.z'); + end if; +end; +`) + if code == 0 || !strings.Contains(out, "MDL063") { + t.Errorf("the real duplicate across branches is not reported (exit %d):\n%s", code, out) + } + if !strings.Contains(out, `has no parameter "email"`) { + t.Errorf("the unknown parameter (CE1613) is hidden behind the semantic error:\n%s", out) + } +} diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 1bc225dd67..3abd576245 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -296,11 +296,14 @@ func runCheckFile(cmd *cobra.Command, filePath string) int { report(violations) } - if len(violations) > 0 { - summary := linter.Summarize(violations) - if summary.Errors > 0 { - return finish(1) - } + // A semantic error fails the run, but does not hide the reference tier: + // an unresolved name is a separate error mxbuild reports alongside it, and + // stopping here made `check --references` look as if it had accepted a + // call to a parameter the action does not have (CE1613, ako/mxcli#953). + // The catalog-backed tier after it still waits for a clean script. + semanticErrors := len(violations) > 0 && linter.Summarize(violations).Errors > 0 + if semanticErrors && !checkRefs { + return finish(1) } // If reference checking requested @@ -374,6 +377,9 @@ func runCheckFile(cmd *cobra.Command, filePath string) int { if !isStructured { fmt.Printf("✓ All references valid\n") } + if semanticErrors { + return finish(1) + } // The catalog-backed tier: the checks whose answers only exist once a // project is connected. It runs after the reference check because a diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index f2ca49a78e..3e61f03bb6 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -678,18 +678,18 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext } } // Validate microflow body for semantic errors (e.g., undeclared variables) - if validationErrors := ValidateMicroflowBody(s); len(validationErrors) > 0 { - return mdlerrors.NewValidationf("microflow '%s' has validation errors:\n - %s", - s.Name.String(), strings.Join(validationErrors, "\n - ")) - } + // Reported together with the reference errors below rather than + // instead of them: a body error used to hide a call's unknown + // parameter, which mxbuild reports as well (CE1613, #953). + validationErrors := ValidateMicroflowBody(s) // Validate references inside microflow body (pages, microflows, java actions, entities) - if refErrors := validateFlowBodyReferences(ctx, s.Body, sc); len(refErrors) > 0 { - if s.Excluded { - sc.warnExcluded("microflow", s.Name.String(), refErrors) - } else { - return mdlerrors.NewValidationf("microflow '%s' has reference errors:\n - %s", - s.Name.String(), strings.Join(refErrors, "\n - ")) - } + refErrors := validateFlowBodyReferences(ctx, s.Body, sc) + if len(refErrors) > 0 && s.Excluded { + sc.warnExcluded("microflow", s.Name.String(), refErrors) + refErrors = nil + } + if len(validationErrors) > 0 || len(refErrors) > 0 { + return flowValidationError("microflow", s.Name.String(), validationErrors, refErrors) } case *ast.CreateRuleStmt: if s.Name.Module != "" && !sc.modules[s.Name.Module] { @@ -721,18 +721,18 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext } } // Validate nanoflow body for semantic errors (e.g., undeclared variables) - if validationErrors := ValidateNanoflowBody(s); len(validationErrors) > 0 { - return mdlerrors.NewValidationf("nanoflow '%s' has validation errors:\n - %s", - s.Name.String(), strings.Join(validationErrors, "\n - ")) - } + // Reported together with the reference errors below rather than + // instead of them: a body error used to hide a call's unknown + // parameter, which mxbuild reports as well (CE1613, #953). + validationErrors := ValidateNanoflowBody(s) // Validate references inside nanoflow body (an excluded nanoflow's are warnings) - if refErrors := validateFlowBodyReferences(ctx, s.Body, sc); len(refErrors) > 0 { - if s.Excluded { - sc.warnExcluded("nanoflow", s.Name.String(), refErrors) - } else { - return mdlerrors.NewValidationf("nanoflow '%s' has reference errors:\n - %s", - s.Name.String(), strings.Join(refErrors, "\n - ")) - } + refErrors := validateFlowBodyReferences(ctx, s.Body, sc) + if len(refErrors) > 0 && s.Excluded { + sc.warnExcluded("nanoflow", s.Name.String(), refErrors) + refErrors = nil + } + if len(validationErrors) > 0 || len(refErrors) > 0 { + return flowValidationError("nanoflow", s.Name.String(), validationErrors, refErrors) } case *ast.CreatePageStmtV3: if s.Name.Module != "" && !sc.modules[s.Name.Module] { @@ -1243,6 +1243,33 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, } } + // An argument naming no parameter of the called flow is CE1613 "The + // selected parameter 'M.F.X' no longer exists" (mxbuild 11.13.0, both + // call microflow and call nanoflow), and exec writes it as given. Java and + // JavaScript action calls were checked; flow calls were not (#953). A flow + // that does not resolve is the not-found check's to report. + if len(refs.flowCalls) > 0 { + var stored map[string]*flowSignature + for _, call := range refs.flowCalls { + if len(call.argNames) == 0 { + continue + } + key := strings.ToLower(call.name) + sig, ok := sc.flowParams[key] + if !ok { + if stored == nil { + stored = buildFlowSignatures(ctx) + } + sig, ok = stored[key] + } + if !ok || sig == nil { + continue + } + errors = append(errors, validateCodeActionParams(call.kind, + codeActionCallRef{name: call.name, argNames: call.argNames}, sig.paramNames())...) + } + } + if len(refs.javaActions) > 0 { known := buildJavaActionQualifiedNames(ctx) for _, ref := range refs.javaActions { @@ -1269,7 +1296,7 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, continue } if ja, err := ctx.Backend.ReadJavaActionByName(ref.name); err == nil && ja != nil { - var declared []string + declared := []string{} // read: an action with no parameters is known to have none flowParams := map[string]bool{} for _, p := range ja.Parameters { declared = append(declared, p.Name) @@ -1298,7 +1325,7 @@ func validateFlowBodyReferences(ctx *ExecContext, body []ast.MicroflowStatement, continue } if jsa, err := ctx.Backend.ReadJavaScriptActionByName(ref.name); err == nil && jsa != nil { - var declared []string + declared := []string{} // read: an action with no parameters is known to have none for _, p := range jsa.Parameters { declared = append(declared, p.Name) } @@ -1484,9 +1511,12 @@ func qualifiedNameModule(qn string) string { // flowRefCollector collects qualified name references from flow body statements. type flowRefCollector struct { - pages []string - microflows []string - nanoflows []string + pages []string + microflows []string + nanoflows []string + // flowCalls are the microflow and nanoflow calls with the parameter names + // written on them, for the CE1613 parameter-name check (#953). + flowCalls []flowCallRef javaActions []codeActionCallRef javaScriptActions []codeActionCallRef entities []entityRef @@ -1494,6 +1524,14 @@ type flowRefCollector struct { queues []string } +// flowCallRef is a `call microflow` / `call nanoflow` and the parameter names +// its arguments name. +type flowCallRef struct { + kind string // "microflow" or "nanoflow" + name string + argNames []string +} + // codeActionCallRef is a Java / JavaScript action call: the action's qualified // name plus the parameter names the author wrote, so both the action's existence // and its parameter names can be validated (a wrong/mis-cased name writes a @@ -1525,8 +1563,12 @@ func callArgNames(args []ast.CallArgument) []string { // that fails the build with CE1613). When the mismatch is only a casing // difference, the message suggests the correct spelling. `declared` empty means // the backend could not report parameters — skip (degrade gracefully). +// +// declared nil means the parameters are unknown, and nothing is reported; an +// empty, non-nil list is an action or flow known to take none, where any named +// argument is the CE1613. func validateCodeActionParams(kind string, ref codeActionCallRef, declared []string) []string { - if len(declared) == 0 { + if declared == nil { return nil } declaredSet := make(map[string]bool, len(declared)) @@ -1548,8 +1590,18 @@ func validateCodeActionParams(kind string, ref codeActionCallRef, declared []str } sorted := append([]string(nil), declared...) sort.Strings(sorted) - msg += fmt.Sprintf(" (declared parameters: %s). Mendix build fails CE1613 \"The selected %s parameter … no longer exists\".", - strings.Join(sorted, ", "), kind) + list := strings.Join(sorted, ", ") + if list == "" { + list = "none" + } + // mxbuild names the code action kind in the message, not the flow kind: + // "The selected parameter 'M.F.X' no longer exists" for a flow call. + selected := kind + " parameter" + if kind == "microflow" || kind == "nanoflow" { + selected = "parameter" + } + msg += fmt.Sprintf(" (declared parameters: %s). Mendix build fails CE1613 \"The selected %s … no longer exists\".", + list, selected) errs = append(errs, msg) } return errs @@ -1595,11 +1647,17 @@ func (c *flowRefCollector) collectFromStatements(stmts []ast.MicroflowStatement) case *ast.CallMicroflowStmt: if s.MicroflowName.Module != "" { c.microflows = append(c.microflows, s.MicroflowName.String()) + c.flowCalls = append(c.flowCalls, flowCallRef{ + kind: "microflow", name: s.MicroflowName.String(), argNames: callArgNames(s.Arguments), + }) } c.addQueue(s.Queue) case *ast.CallNanoflowStmt: if s.NanoflowName.Module != "" { c.nanoflows = append(c.nanoflows, s.NanoflowName.String()) + c.flowCalls = append(c.flowCalls, flowCallRef{ + kind: "nanoflow", name: s.NanoflowName.String(), argNames: callArgNames(s.Arguments), + }) } case *ast.CallJavaActionStmt: if s.ActionName.Module != "" { @@ -1913,3 +1971,18 @@ func validateViewEntityAttributeSet(ctx *ExecContext, s *ast.AlterEntityStmt, sc } return viewEntityAttributeSetRefusal(qn, "drop", s.AttributeName) } + +// flowValidationError joins a flow's body validation errors and its reference +// errors into one error, keeping each list's own heading. +func flowValidationError(kind, name string, validationErrors, refErrors []string) error { + var parts []string + if len(validationErrors) > 0 { + parts = append(parts, fmt.Sprintf("%s '%s' has validation errors:\n - %s", + kind, name, strings.Join(validationErrors, "\n - "))) + } + if len(refErrors) > 0 { + parts = append(parts, fmt.Sprintf("%s '%s' has reference errors:\n - %s", + kind, name, strings.Join(refErrors, "\n - "))) + } + return mdlerrors.NewValidationf("%s", strings.Join(parts, "\n ")) +} diff --git a/mdl/executor/validate_flow_call_params_test.go b/mdl/executor/validate_flow_call_params_test.go new file mode 100644 index 0000000000..f785d68335 --- /dev/null +++ b/mdl/executor/validate_flow_call_params_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +// ako/mxcli#953 item 7: an argument naming no parameter of the called flow is +// CE1613 "The selected parameter 'M.F.X' no longer exists" in mxbuild 11.13.0, +// for call microflow and call nanoflow alike. Java and JavaScript action calls +// were checked under --references; flow calls were not. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func flowCallScript(call ast.MicroflowStatement, targetParams []ast.MicroflowParam, nano bool) (*ast.Program, []ast.MicroflowStatement) { + body := []ast.MicroflowStatement{call} + var target, caller ast.Statement + if nano { + target = &ast.CreateNanoflowStmt{Name: qn("M", "Target"), Parameters: targetParams} + caller = &ast.CreateNanoflowStmt{Name: qn("M", "Caller"), Body: body} + } else { + target = &ast.CreateMicroflowStmt{Name: qn("M", "Target"), Parameters: targetParams} + caller = &ast.CreateMicroflowStmt{Name: qn("M", "Caller"), Body: body} + } + return &ast.Program{Statements: []ast.Statement{target, caller}}, body +} + +func TestValidate_FlowCallParameterNames(t *testing.T) { + str := ast.DataType{Kind: ast.TypeString} + arg := func(name string) []ast.CallArgument { + return []ast.CallArgument{{Name: name, Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}}} + } + one := []ast.MicroflowParam{{Name: "Input", Type: str}} + cases := []struct { + name string + call ast.MicroflowStatement + params []ast.MicroflowParam + nano bool + wantErr string + }{ + {"call microflow, unknown name", &ast.CallMicroflowStmt{MicroflowName: qn("M", "Target"), Arguments: arg("Bogus")}, + one, false, `microflow M.Target has no parameter "Bogus" (declared parameters: Input)`}, + {"control: call microflow, declared name", &ast.CallMicroflowStmt{MicroflowName: qn("M", "Target"), Arguments: arg("Input")}, + one, false, ""}, + {"call nanoflow, unknown name", &ast.CallNanoflowStmt{NanoflowName: qn("M", "Target"), Arguments: arg("Bogus")}, + one, true, `nanoflow M.Target has no parameter "Bogus"`}, + {"control: call nanoflow, declared name", &ast.CallNanoflowStmt{NanoflowName: qn("M", "Target"), Arguments: arg("Input")}, + one, true, ""}, + {"call microflow taking no parameters, with an argument", &ast.CallMicroflowStmt{MicroflowName: qn("M", "Target"), Arguments: arg("Bogus")}, + nil, false, `(declared parameters: none)`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, _ := newMockCtx(t) + prog, body := flowCallScript(tc.call, tc.params, tc.nano) + sc := newScriptContext() + sc.collectDefinitions(prog) + errs := validateFlowBodyReferences(ctx, body, sc) + if tc.wantErr == "" { + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + return + } + if len(errs) != 1 || !strings.Contains(errs[0], tc.wantErr) || !strings.Contains(errs[0], "CE1613") { + t.Fatalf("errors = %v, want one containing %q", errs, tc.wantErr) + } + }) + } +} + +// A flow's body error used to be returned INSTEAD of its reference errors, so a +// call to a parameter the action does not have (CE1613) stayed hidden behind +// it until the body was fixed — the shape #953 reported. +func TestValidateWithContext_BodyErrorDoesNotHideReferenceErrors(t *testing.T) { + ctx, _ := newMockCtx(t) + mf := &ast.CreateMicroflowStmt{Name: qn("M", "Caller"), Body: []ast.MicroflowStatement{ + &ast.MfSetStmt{Target: "Undeclared", Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}}, + &ast.CallJavaActionStmt{ActionName: qn("M", "Ja"), Arguments: []ast.CallArgument{ + {Name: "email", Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "a@b.c"}}}}, + }} + prog := &ast.Program{Statements: []ast.Statement{ + &ast.CreateModuleStmt{Name: "M"}, + &ast.CreateJavaActionStmt{Name: qn("M", "Ja"), Parameters: []ast.JavaActionParam{{Name: "EmailAddress"}}}, + mf, + }} + sc := newScriptContext() + sc.collectDefinitions(prog) + err := validateWithContext(ctx, mf, sc) + if err == nil { + t.Fatal("no error for a body error plus an unknown parameter") + } + for _, want := range []string{"is not declared", `has no parameter "email"`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error lacks %q:\n%v", want, err) + } + } +} From 119622bf35de1a10ca63d771e2c0a5cc1e5ee0ad Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 16:01:29 +0000 Subject: [PATCH 17/18] style: gofmt validate_void_code_calls_test.go Co-Authored-By: Claude Opus 5.5 --- mdl/executor/validate_void_code_calls_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/mdl/executor/validate_void_code_calls_test.go b/mdl/executor/validate_void_code_calls_test.go index 7613832e9a..37a51268e9 100644 --- a/mdl/executor/validate_void_code_calls_test.go +++ b/mdl/executor/validate_void_code_calls_test.go @@ -21,7 +21,6 @@ import ( "github.com/mendixlabs/mxcli/sdk/microflows" ) - func javaCall(out, action string) *ast.CallJavaActionStmt { return &ast.CallJavaActionStmt{OutputVariable: out, ActionName: qn("M", action)} } From bc4cab5a11adeb11f967009e8cf5eff13abeab69 Mon Sep 17 00:00:00 2001 From: Ako Date: Sat, 3 Oct 2026 15:31:51 +0000 Subject: [PATCH 18/18] test(report): --modules scoping keeps rule failures listed A rule that could not run has no module; ScopeToModules dropped it, so a --modules report hid the failure that #952 lists separately. Co-Authored-By: Claude Opus 5.5 --- mdl/linter/report_scope_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/mdl/linter/report_scope_test.go b/mdl/linter/report_scope_test.go index a5c6513149..c13a703d9c 100644 --- a/mdl/linter/report_scope_test.go +++ b/mdl/linter/report_scope_test.go @@ -59,3 +59,22 @@ func TestScopeToModules(t *testing.T) { } } } + +// A rule that could not run has no module, but it is about the tooling, not a +// module the selection excludes: `report --modules` must still list it, and +// BuildReport still keeps it out of the score (ako/mxcli#952 with #953). +func TestScopeToModulesKeepsRuleFailures(t *testing.T) { + vs := []Violation{ + {RuleID: "QUAL004", Severity: SeverityInfo, RuleFailure: true}, + {RuleID: "CONV008", Severity: SeverityInfo, Location: Location{Module: ""}}, // control: dropped + {RuleID: "SEC001", Severity: SeverityWarning, Location: Location{Module: "MyFirstModule"}}, + } + got := ScopeToModules(vs, []string{"MyFirstModule"}) + if len(got) != 2 || !got[0].RuleFailure || got[1].Location.Module != "MyFirstModule" { + t.Fatalf("want the rule failure and the MyFirstModule finding, got %+v", got) + } + r := BuildReport("App", "d", got) + if len(r.RuleFailures) != 1 || len(r.Violations) != 1 { + t.Fatalf("rule failures %d, violations %d; want 1 and 1", len(r.RuleFailures), len(r.Violations)) + } +}