Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
01eeb7e
feat(lint): expose the catalog and settings data guideline rules need…
ako Oct 31, 2026
b00bc28
feat(lint): permission rows name the access rule they come from (#1437)
ako Oct 31, 2026
2675472
feat(lint): --baseline reports only findings not in a recorded baseli…
ako Oct 31, 2026
98ad178
Merge remote-tracking branch 'origin/main' into feature/1453-starlark…
ako Nov 1, 2026
ffa8e68
Merge remote-tracking branch 'origin/main' into feature/1328-lint-bas…
ako Nov 1, 2026
1755c0a
Merge pull request #1843 from ako/feature/1453-starlark-catalog-data
ako Nov 1, 2026
19e0546
Merge remote-tracking branch 'origin/main' into feature/1437-permissi…
ako Nov 1, 2026
2b451ef
Merge remote-tracking branch 'origin/main' into feature/1328-lint-bas…
ako Nov 1, 2026
00d28d3
Merge pull request #1844 from ako/feature/1437-permission-access-rule-id
ako Nov 1, 2026
ad8db6b
Merge remote-tracking branch 'origin/main' into feature/1328-lint-bas…
ako Nov 1, 2026
789b879
Merge pull request #1845 from ako/feature/1328-lint-baseline
ako Nov 1, 2026
0804473
fix(mcp): treat "Error executing tool handler" as a failed tool call
ako Nov 1, 2026
91d26f7
feat(mcp): DROP through ped_delete_document when Studio Pro has it
ako Nov 1, 2026
fbc93fc
fix(widgets): read editorConfig visibility rules in linear time (#1848)
ako Nov 1, 2026
f9c6fd4
fix(widgets): name colliding widgets apart and skip an unchanged widg…
ako Nov 1, 2026
0097515
Merge remote-tracking branch 'origin/main' into feature/mcp-native-de…
ako Nov 1, 2026
0368830
fix(describe): decode only the documents a describe prints (#1850)
ako Nov 1, 2026
9a8e3c6
Merge pull request #1849 from ako/fix/1848-widget-defs
ako Nov 1, 2026
b4cc171
Merge remote-tracking branch 'origin/main' into fix/1850-describe-lookup
ako Nov 1, 2026
2b1ad0b
Merge pull request #1852 from ako/fix/1850-describe-lookup
ako Nov 1, 2026
7324ca8
Merge remote-tracking branch 'origin/main' into feature/mcp-native-de…
ako Nov 1, 2026
d01bf50
Merge pull request #1851 from ako/feature/mcp-native-delete
ako Nov 1, 2026
93f9478
fix(mcp): --mcp-trace lists the PED calls (#1833)
claude Nov 1, 2026
bf4e753
fix(mcp): dial loopback before host.docker.internal (#1833)
claude Nov 1, 2026
dc935b3
Merge pull request #1872 from ako/claude/cool-keller-jtch0r
ako Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/commands/mendix/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ mxcli lint -p app.mpr --list-rules

# Exclude modules
mxcli lint -p app.mpr --exclude System --exclude Administration

# Only findings not in a recorded baseline (record it once with --update-baseline)
mxcli lint -p app.mpr --baseline lint-baseline.json --update-baseline
mxcli lint -p app.mpr --baseline lint-baseline.json
```

## Built-in Go Rules
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "cmd/mxcli", "date": "2026-11-01", "symptom": "`mxcli exec probe.mdl --mcp <url> --mcp-trace` (and `-c`, the REPL, `mcp capabilities`) printed `▸ connect local …` and `▸ CreateConstantStmt` but no PED tool call, so a reporter concluded the write went to the .mpr on disk instead of Studio Pro (mendixlabs/mxcli#1684). A fake Studio Pro received ped_get_schema, ped_create_document and six ped_check_errors in the same run", "cause": "Tracing has two halves: the executor prints statements (Executor.SetTracer, wired in newLoggedExecutorTo) and the MCP client prints calls from Backend.tracer, set only by mcpbackend.WithTracer. newBackendFactory and the mcp capabilities command built the backend with mcpbackend.New alone; WithTracer had no caller outside tests, although executor.go's SetTracer comment says the backend must get it too", "file": "cmd/mxcli/engine.go (newBackendFactory); cmd/mxcli/mcp.go", "fix": "Build the MCP backend with .WithTracer(mcpTracer()) in both places", "insight": "A diagnostic flag that prints half its output is worse than none: the missing half reads as a negative result. When a report's evidence is a trace or a log line that is absent, check that the thing producing that line is wired before believing the absence; a logging fake server on the other end settled it in one run. The test drives the CLI's own executor builder against a fake PED and compares the trace with what the fake received", "refs": "ako/mxcli#1833, mendixlabs/mxcli#1684", "test": "cmd/mxcli/mcp_trace_test.go"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/backend/mcp", "date": "2026-11-01", "symptom": "Over `--mcp` on Studio Pro 11.16, every DROP of a standalone document (constant, enumeration, microflow, nanoflow, page, workflow, business-event service) demanded `--mcp-concord`, though 11.16 ships `ped_delete_document`. Separately, a PED tool whose handler throws answers `Error executing tool handler: …` with `isError:false`, which any caller trusting the flag reads as success", "cause": "`concordDeleteDocument` was the only DROP path, written when PED had no delete tool. `Client.CallTool` passed `isError` through verbatim, and only the create/update callers (`pedOpError`'s `SUCCESS` prefix) noticed failures reported in the text", "file": "`mdl/backend/mcp/delete.go` (new `deleteDocument`: native when `HasTool(\"ped_delete_document\")`, Concord fallback), `client.go` (`HasTool`, `handlerFailed` sets `IsError`), the seven `Delete*` call sites, `capabilities.yaml`/`.go` report text", "insight": "**Probe tool presence, never the version**: `serverInfo.version` was 1.0.0 on 11.12.0 and 11.16.0 reports itself, but the delete tool is the fact that matters. **On 11.16.0 the native delete fails for every caller outside Studio Pro's own agent session** (`Session 'mcp-server' not found`, measured from a devcontainer through four clientInfo names), so the success reply has never been seen. The code therefore settles an unrecognised reply by `ped_find_document` instead of guessing the text, and keeps Concord as a fallback. **Revert check**: stubbing `handlerFailed` fails `TestCallTool_HandlerErrorIsAnError`, and forcing the Concord path fails three `TestDeleteDocument_*`. Live runs are in mx-test-projects/runs/native-delete/"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/backend", "date": "2026-11-01", "symptom": "On Windows with Docker Desktop, `mxcli mcp capabilities -p App.mpr --mcp http://localhost:7782/mcp` failed with `dial tcp <LAN IP>:7782: i/o timeout` while Studio Pro listened on 127.0.0.1; `--mcp-dial 127.0.0.1:7782` connected. The reporter blamed localhost resolving to the LAN address (mendixlabs/mxcli#1684)", "cause": "mdl/backend/mcp/client.go rewrote a localhost/127.0.0.1 endpoint to host.docker.internal:<port> whenever net.LookupHost(host.docker.internal) succeeded, as a 'running in a devcontainer' test. Docker Desktop for Windows writes host.docker.internal into the host's own hosts file (the LAN IP), so the rewrite also fired on the host, for 127.0.0.1 URLs too", "file": "mdl/backend/mcp/client.go (dialPlan, dialFirst, newHTTPClient)", "fix": "Dial a plan instead of one address: an explicit --mcp-dial alone, otherwise the URL host first and host.docker.internal:<port> as the fallback when it resolves; every failure is reported when none connects", "insight": "'A name resolves' is evidence about DNS, not about where the process runs; the same Docker name is injected into containers and into Windows hosts. Prefer trying the likely address and falling back over guessing the environment: a refused loopback connection in a container costs nothing. The address in the error (a LAN IP for a localhost URL) pointed at the rewrite, not at localhost resolution. Test it by forcing the cached 'resolves' answer true and connecting to a loopback fake", "refs": "ako/mxcli#1833, mendixlabs/mxcli#1684", "test": "mdl/backend/mcp/client_dial_test.go"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-11-01", "symptom": "A first `refresh catalog full source` on FactoryManagement (3,718 documents) took 33 s and 145 s of CPU; a CPU profile put 26% in describeSnippet -> ListSnippets and 13% in describeNanoflow -> ListMicroflows, both decoding custom-widget and flow trees.", "cause": "describe found its document, and the snippets, pages, layouts and flows it names, by listing every document of the type, and a listing decodes each one. The source build describes every document, so the work grew with the square of the project. The nanoflow path still decoded every nanoflow after the direct fix: describe rebuilds the flow through layoutCheckBackend, a memo that embeds the FullBackend interface and so hid ListNanoflowHeaders from the type assertion.", "file": "mdl/executor/document_lookup.go, mdl/executor/cmd_microflows_derived_layout_backend.go", "insight": "Measure with mprread.UnitsDecoded() against a fixture you then grow: what a describe decodes must not change when documents it does not print are added. When an optional backend capability is detected by type assertion, every wrapper that embeds the backend interface must forward it, or the fast path silently falls back (mendixlabs/mxcli#1624 was the same trap for microflows); a temporary stack dump in decodeUnits for listings over 5 units finds the caller in one run.", "issue": "ako/mxcli#1850"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-11-01", "symptom": "refresh catalog after one model edit spent about 3 s of 5 s on widget definitions (Windows, FactoryManagement): `Widget definitions: 0 new, 2 refreshed` on every run, and the widget docs regenerated each time.", "cause": "Three causes stacked. KeyboardShortcut.mpk and itvisors.KeyboardShortcut.mpk both derived KEYBOARDSHORTCUT and rewrote one .def.json in turn, so the refresh always found drift and regenerated docs (which re-ran the whole refresh). The editorConfig visibility extractor ran end-anchored regexps (`…$`) over everything before each hide call, quadratic in the file: 0.5 s for Maps' 400 KB editorConfig.js. And nothing recorded that widgets/ was unchanged, so every refresh re-read every .mpk.", "file": "mdl/executor/widget_defs.go, mdl/executor/editorconfig_extract.go", "insight": "Profile before caching: the issue blamed unzipping, but parsing all 54 .mpk took 45 ms; one 400 KB file in the extractor took 535 ms. Go's regexp has no backward search, so a `$`-anchored pattern on a growing prefix scans the whole prefix every time; use a suffix scan. A name derived from an ID must be assigned over the whole set, and every consumer (defs, docs, catalog) must look up by ID, not re-derive the file name.", "issue": "ako/mxcli#1848"}
121 changes: 29 additions & 92 deletions .claude/skills/mendix/write-lint-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ description: "Write custom Starlark lint rules in .claude/lint-rules/ that run b
| lines | section |
|---|---|
| 25-67 | Rule File Structure |
| 68-161 | Available Query Functions |
| 162-606 | Object Properties |
| 607-620 | Helper Functions |
| 621-707 | Common Patterns |
| 708-716 | Validation |
| 68-163 | Available Query Functions |
| 164-543 | Object Properties |
| 544-557 | Helper Functions |
| 558-644 | Common Patterns |
| 645-653 | Validation |

<!-- /skill-index -->
# Writing Custom Starlark Lint Rules
Expand Down Expand Up @@ -74,6 +74,7 @@ Without this, a rule that reads a full-only table under a fast build gets
| `pages()` | list of page | All non-system pages |
| `enumerations()` | list of enumeration | All non-system enumerations |
| `constants()` | list of constant | All non-system constants |
| `constant_values()` | list of constant_value | The constant values the run configurations set (Settings → Configurations), in constant then configuration order. A configuration that does not override a constant has no entry: it runs on the constant's `default_value` |
| `widgets()` | list of widget | All non-system page and snippet widgets (full catalog — auto-detected) |
| `snippets()` | list of snippet | All non-system snippets |
| `scheduled_events()` | list of scheduled_event | All non-system scheduled events (requires MPR reader) |
Expand All @@ -95,6 +96,7 @@ Without this, a rule that reads a full-only table under a fast build gets
| `module_roles()` | list of module_role | All module roles (deduplicated from role mappings) |
| `role_mappings()` | list of role_mapping | User role to module role assignments |
| `project_security()` | project_security or None | Project-level security settings (requires MPR reader) |
| `project_settings()` | project_settings or None | Project-wide settings a guideline rule checks: default language, OQL version, Java version, hash algorithm, time zone, rounding, decimal scale, optimistic locking, foreign keys, web client (requires MPR reader; `None` without one) |
| `languages()` | list of language | The languages **enabled** in the project settings, in settings order (requires MPR reader; `[]` without one). Use it to scope per-language checks: `strings()` has a row for every stored translation, including languages the project never enabled |
| `xpath_expressions()` | list of xpath_expression | All XPath constraint expressions in the catalog (access rules, retrieve actions, widgets) (full catalog — auto-detected) |
| `modules()` | list of module | The user's modules (not System, not Marketplace), with their domain model's documentation |
Expand Down Expand Up @@ -228,6 +230,8 @@ def check():
| `complexity` | int | McCabe cyclomatic complexity |
| `document_noun` | string | `"microflow"`, `"nanoflow"` or `"rule"` — for mid-sentence use in a message |
| `document_noun_title` | string | `"Microflow"`, `"Nanoflow"` or `"Rule"` — for `document_type=` and a message that opens with it |
| `excluded` | bool | "Exclude from project": the flow is not built or run. Most guideline rules should skip it |
| `apply_entity_access` | bool | The flow's "Apply entity access" setting. Always `false` for a nanoflow, which has no such setting (entity access always applies there) |

### page
| Property | Type | Example |
Expand All @@ -252,6 +256,15 @@ def check():
| `folder` | string | `"enumerations"` — folder path within module |
| `description` | string | Documentation text |
| `value_count` | int | Number of enum values |
| `values` | list of enumeration_value | The values in ordinal order; `[]` for an enumeration without any |

### enumeration_value
| Property | Type | Example |
|----------|------|---------|
| `name` | string | `"Slow"` |
| `qualified_name` | string | `"Sales.ApiTimeout.Slow"` — the name `refs_from()` and an expression's `getCaption(Sales.ApiTimeout.Slow)` use |
| `caption` | string | Caption in the project's default language: `"400"` |
| `ordinal` | int | Position in the enumeration, from 0 |

### constant
| Property | Type | Example |
Expand All @@ -265,6 +278,15 @@ def check():
| `default_value` | string | `"https://example.com"` |
| `exposed_to_client` | bool | `true` if constant is exposed to client |

### constant_value
Returned by `constant_values()`, one per constant a run configuration overrides.

| Property | Type | Example |
|----------|------|---------|
| `constant` | string | `"MyModule.AppBaseUrl"` — the constant's qualified name |
| `configuration` | string | `"Default"` — the run configuration's name |
| `value` | string | `"https://test.example.com"` |

**widget** — the struct returned by `widgets()` — identity, references, tree
position (`parent_widget_id`, `depth`), appearance (`class_name`, `style`) and
primary action (`action_type`, `has_confirmation`) — is documented in
Expand Down Expand Up @@ -515,94 +537,9 @@ def count_not(node):
| `timeout` | int | Configured timeout in milliseconds; `0` when none is set |
| `module_name` | string | `"Sales"` |

### permission

Returned by `permissions()` (all types) or `permissions_for()` (entity-specific).
The `permission` struct `permissions()` and `permissions_for()` return is in [catalog-tables.md](catalog-tables.md#permission).

| Property | Type | Example |
|----------|------|---------|
| `module_role_name` | string | `"Sales.Admin"` — qualified, so it compares directly against `user_role.module_roles` |
| `element_type` | string | `"ENTITY"`, `"MICROFLOW"`, `"NANOFLOW"`, `"PAGE"`, `"ODATA_SERVICE"` (from `permissions()` only) |
| `element_name` | string | `"Sales.Customer"` |
| `module_name` | string | `"Sales"` |
| `entity_name` | string | `"Sales.Customer"` (from `permissions_for()` only) |
| `access_type` | string | `"CREATE"`, `"READ"`, `"WRITE"`, `"DELETE"` (entity), `"EXECUTE"` (microflow, nanoflow), `"VIEW"` (page), `"ACCESS"` (OData service), `"MEMBER_READ"`, `"MEMBER_WRITE"` |
| `member_name` | string | Attribute name (for MEMBER_READ/MEMBER_WRITE) |
| `xpath_constraint` | string | XPath constraint or empty |
| `is_constrained` | bool | True if XPath constraint is set |
| `default_member_access_rights` | string | The rule's "default rights for new members": `"None"`, `"ReadOnly"` or `"ReadWrite"`. Empty for non-entity permissions |

### user_role
| Property | Type | Example |
|----------|------|---------|
| `name` | string | `"Administrator"` |
| `description` | string | The role's documentation in Project Security; `""` when none |
| `is_anonymous` | bool | True if this is the anonymous/guest role |
| `module_roles` | list of string | `["Sales.Admin", "HR.Viewer"]` |
| `check_security` | bool | Studio Pro's per-role "Check security" flag |
| `manage_all_roles` | bool | The role may hand out every user role |
| `manage_users_without_roles` | bool | The role may manage users that have no role |
| `manageable_roles` | list of string | The user roles this role may hand out, when not all |

### module_role
| Property | Type | Example |
|----------|------|---------|
| `name` | string | `"Sales.Admin"` — qualified module role name |
| `module_name` | string | `"Sales"` |
| `description` | string | Module role description |

### role_mapping
| Property | Type | Example |
|----------|------|---------|
| `user_role_name` | string | `"Administrator"` |
| `module_role_name` | string | `"Sales.Admin"` |
| `module_name` | string | `"Sales"` |

### reference
| Property | Type | Example |
|----------|------|---------|
| `source_type` | string | The document the edge comes FROM, upper-case: `"MICROFLOW"`, `"NANOFLOW"`, `"RULE"`, `"PAGE"`, `"SNIPPET"`, `"ENTITY"`, `"ASSOCIATION"`, `"WORKFLOW"`, `"NAVIGATION"`, `"SCHEDULED_EVENT"`, `"PUBLISHED_REST_OPERATION"`, `"PUBLISHED_REST_SERVICE"`, `"PUBLISHED_ODATA_SERVICE"`, `"CONSUMED_ODATA_SERVICE"`, `"BUSINESS_EVENT_SERVICE"`, `"PROJECT_SETTINGS"`, `"IMPORT_MAPPING"`, `"EXPORT_MAPPING"`, `"MESSAGE_DEFINITION_COLLECTION"`, `"MESSAGE_DEFINITION"` |
| `source_id` | string | Source UUID |
| `source_name` | string | `"Sales.ACT_Customer_Create"` |
| `target_type` | string | What it points AT, upper-case: `"ENTITY"`, `"ASSOCIATION"`, `"MICROFLOW"`, `"NANOFLOW"`, `"RULE"`, `"PAGE"`, `"LAYOUT"`, `"WORKFLOW"`, `"WIDGET"`, `"JAVA_ACTION"`, `"JAVASCRIPT_ACTION"`, `"REST_OPERATION"`, `"REGULAR_EXPRESSION"`, `"ATTRIBUTE"`, `"ENUMERATION"`, `"ENUMERATION_VALUE"`. `LAYOUT`, `WIDGET`, `ATTRIBUTE`, `ENUMERATION` and `ENUMERATION_VALUE` are only ever targets; `SCHEDULED_EVENT`, `PROJECT_SETTINGS` and the service types only ever sources |
| `target_id` | string | Target UUID |
| `target_name` | string | `"Sales.Customer"`; three-part for an attribute or an enumeration value: `"Sales.Order.Total"`, `"Sales.OrderStatus.Open"` |
| `ref_kind` | string | How it references: `"call"`, `"create"`, `"retrieve"`, `"change"`, `"delete"`, `"commit"` (a commit action, or a create/change that commits — beside its `"create"`/`"change"` edge; a commit of a variable whose entity the flow cannot tell has no edge), `"show_page"`, `"datasource"`, `"action"`, `"layout"`, `"parameter"`, `"return"`, `"generalize"`, `"associate"`, `"home_page"`, `"login_page"`, `"menu_item"`, `"calculate"`, `"schedule"`, `"validate"`, `"settings"`, `"widget"`, `"sync"`, `"publish"` (a published REST operation, OData resource or action, or a service's authentication microflow), `"event"` (an entity event or a business event handler), `"member"` (binds/reads/writes an attribute or navigates an association), `"xpath"` (an XPath constraint names it), `"type"` (typed as an enumeration), `"value"` (an expression names an enumeration value), `"mapping"` (an import/export mapping maps the entity, or a message definition exposes it), `"split"` (a decision branches on an object being the entity) — lower-case, unlike the types above. Attribute names used only through a variable in a free-text expression (`$Order/Total`) have no edge |
| `module_name` | string | Source module |

### project_security

Returned by `project_security()`. Returns `none` if no MPR reader is available.

| Property | Type | Description |
|----------|------|-------------|
| `security_level` | string | `"CheckNothing"` (Off), `"CheckFormsAndMicroflows"` (Prototype), `"CheckEverything"` (Production) |
| `enable_demo_users` | bool | Whether demo users are enabled |
| `enable_guest_access` | bool | Whether anonymous/guest access is enabled |
| `check_security` | bool | Whether security checking is active |
| `strict_mode` | bool | Strict security mode |
| `anonymous_user_role` | string | Name of the project's guest user role, the role anonymous users get. Read `enable_guest_access` too: the role name can stay set while guest access is off |
| `admin_user_name` | string | Name of the administrator account: `"MxAdmin"`. Its password is deliberately not exposed |
| `admin_user_role` | string | The administrator account's user role: `"Administrator"` |
| `password_policy` | struct | Nested password policy settings |

#### password_policy (nested in project_security)
| Property | Type | Description |
|----------|------|-------------|
| `min_length` | int | Minimum password length |
| `require_digit` | bool | Must contain a digit |
| `require_mixed_case` | bool | Must contain upper and lower case |
| `require_symbol` | bool | Must contain a symbol |

### language

Returned by `languages()`, one per language enabled in the project settings. A per-language check skips `strings()` rows whose `language` is not among these codes.

| Property | Type | Description |
|----------|------|-------------|
| `code` | string | Language code: `"en_US"`, `"nl_NL"` |
| `is_default` | bool | Whether this is the project's default language |
| `check_completeness` | bool | Whether Studio Pro checks this language's translations for completeness |
The structs `user_roles()`, `module_roles()`, `role_mappings()`, `project_security()`, `languages()` and `project_settings()` return — `user_role`, `module_role`, `role_mapping`, `project_security`, `language`, `project_settings` — are in [security-and-settings.md](security-and-settings.md).

## Helper Functions

Expand Down
Loading
Loading