From d54f0f3b034555216061603f18b52ffa50317bc2 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 24 Sep 2026 07:23:42 +0000 Subject: [PATCH 1/7] feat(lint): expose the access rule's default member access rights An entity access rule carries a 'default rights for new members' setting (None / ReadOnly / ReadWrite) that decides what access a newly added attribute or association inherits. A rule enforcing a safe default - so that adding an attribute can never silently grant write access - cannot be written today, because the value is neither stored in the catalog nor exposed to Starlark. The builder already reads it: entityAccessFromMemberRights derives read/write from rule.DefaultMemberAccessRights and then discards the value. This stores it instead. Modelled on XPathConstraint, which is likewise a property of the rule rather than of the access type and is already repeated across the rows one rule produces. Non-entity permissions (microflow, page, OData) write NULL. --- .../skills/mendix/write-lint-rules/SKILL.md | 1 + mdl/catalog/builder_permissions.go | 36 +++++++++++-------- mdl/catalog/tables.go | 1 + mdl/linter/context.go | 18 +++++++--- mdl/linter/starlark.go | 32 +++++++++-------- 5 files changed, 54 insertions(+), 34 deletions(-) diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 0b32d4b40..0108984f0 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -395,6 +395,7 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) | `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 | diff --git a/mdl/catalog/builder_permissions.go b/mdl/catalog/builder_permissions.go index e2d53beb6..6ac3dd7d4 100644 --- a/mdl/catalog/builder_permissions.go +++ b/mdl/catalog/builder_permissions.go @@ -61,8 +61,8 @@ func (b *Builder) buildPermissions() error { } stmt, err := b.tx.Prepare(` - INSERT INTO permissions (ModuleRoleName, ElementType, ElementName, MemberName, AccessType, XPathConstraint, ModuleName, ProjectId, SnapshotId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO permissions (ModuleRoleName, ElementType, ElementName, MemberName, AccessType, XPathConstraint, DefaultMemberAccessRights, ModuleName, ProjectId, SnapshotId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -112,27 +112,33 @@ func (b *Builder) buildEntityPermissions(stmt *sql.Stmt, projectID, snapshotID s // and MemberAccesses, not by AllowRead/AllowWrite flags. hasRead, hasWrite := entityAccessFromMemberRights(rule) + // The rule's "default rights for new members" setting. Stored + // alongside every row this rule produces, the same way + // XPathConstraint is: it is a property of the rule, not of the + // individual access type. + defaultRights := string(rule.DefaultMemberAccessRights) + for _, roleName := range roleNames { // Entity-level permissions if rule.AllowCreate { - stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeCreate, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeCreate, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } if hasRead { - stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeRead, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeRead, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } if hasWrite { - stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeWrite, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeWrite, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } if rule.AllowDelete { - stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeDelete, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, nil, AccessTypeDelete, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } // Member-level permissions - count += b.emitMemberPermissions(stmt, rule, ent, roleName, entityQN, xpath, moduleName, projectID, snapshotID) + count += b.emitMemberPermissions(stmt, rule, ent, roleName, entityQN, xpath, defaultRights, moduleName, projectID, snapshotID) } } } @@ -172,7 +178,7 @@ func entityAccessFromMemberRights(rule *domainmodel.AccessRule) (hasRead, hasWri // When MemberAccesses is non-empty, use explicit per-member rights. // When MemberAccesses is empty, expand DefaultMemberAccessRights to all attributes. func (b *Builder) emitMemberPermissions(stmt *sql.Stmt, rule *domainmodel.AccessRule, ent *domainmodel.Entity, - roleName, entityQN, xpath, moduleName, projectID, snapshotID string) int { + roleName, entityQN, xpath, defaultRights, moduleName, projectID, snapshotID string) int { count := 0 if len(rule.MemberAccesses) > 0 { @@ -187,11 +193,11 @@ func (b *Builder) emitMemberPermissions(stmt *sql.Stmt, rule *domainmodel.Access } if ma.AccessRights == domainmodel.MemberAccessRightsReadOnly || ma.AccessRights == domainmodel.MemberAccessRightsReadWrite { - stmt.Exec(roleName, PermissionElementEntity, entityQN, memberName, AccessTypeMemberRead, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, memberName, AccessTypeMemberRead, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } if ma.AccessRights == domainmodel.MemberAccessRightsReadWrite { - stmt.Exec(roleName, PermissionElementEntity, entityQN, memberName, AccessTypeMemberWrite, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, memberName, AccessTypeMemberWrite, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } } @@ -199,11 +205,11 @@ func (b *Builder) emitMemberPermissions(stmt *sql.Stmt, rule *domainmodel.Access // Expand default to all attributes for _, attr := range ent.Attributes { if rule.DefaultMemberAccessRights == domainmodel.MemberAccessRightsReadOnly || rule.DefaultMemberAccessRights == domainmodel.MemberAccessRightsReadWrite { - stmt.Exec(roleName, PermissionElementEntity, entityQN, attr.Name, AccessTypeMemberRead, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, attr.Name, AccessTypeMemberRead, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } if rule.DefaultMemberAccessRights == domainmodel.MemberAccessRightsReadWrite { - stmt.Exec(roleName, PermissionElementEntity, entityQN, attr.Name, AccessTypeMemberWrite, xpath, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementEntity, entityQN, attr.Name, AccessTypeMemberWrite, xpath, defaultRights, moduleName, projectID, snapshotID) count++ } } @@ -233,7 +239,7 @@ func (b *Builder) buildMicroflowPermissions(stmt *sql.Stmt, projectID, snapshotI for _, roleID := range mf.AllowedModuleRoles { // AllowedModuleRoles are BY_NAME strings stored as model.ID roleName := string(roleID) - stmt.Exec(roleName, PermissionElementMicroflow, mfQN, nil, AccessTypeExecute, nil, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementMicroflow, mfQN, nil, AccessTypeExecute, nil, nil, moduleName, projectID, snapshotID) count++ } } @@ -262,7 +268,7 @@ func (b *Builder) buildPagePermissions(stmt *sql.Stmt, projectID, snapshotID str for _, roleID := range pg.AllowedRoles { // AllowedRoles are BY_NAME strings stored as model.ID roleName := string(roleID) - stmt.Exec(roleName, PermissionElementPage, pgQN, nil, AccessTypeView, nil, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementPage, pgQN, nil, AccessTypeView, nil, nil, moduleName, projectID, snapshotID) count++ } } @@ -289,7 +295,7 @@ func (b *Builder) buildODataServicePermissions(stmt *sql.Stmt, projectID, snapsh svcQN := moduleName + "." + svc.Name for _, roleName := range svc.AllowedModuleRoles { - stmt.Exec(roleName, PermissionElementODataService, svcQN, nil, AccessTypeAccess, nil, moduleName, projectID, snapshotID) + stmt.Exec(roleName, PermissionElementODataService, svcQN, nil, AccessTypeAccess, nil, nil, moduleName, projectID, snapshotID) count++ } } diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index 84aca3aee..a5d247d60 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -1026,6 +1026,7 @@ func (c *Catalog) createTables() error { MemberName TEXT, AccessType TEXT NOT NULL, XPathConstraint TEXT, + DefaultMemberAccessRights TEXT, ModuleName TEXT, ProjectId TEXT, SnapshotId TEXT diff --git a/mdl/linter/context.go b/mdl/linter/context.go index 0ae76e048..e327f8690 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -312,13 +312,17 @@ type Permission struct { MemberName string // populated for MEMBER_READ/MEMBER_WRITE, empty for entity-level XPathConstraint string // empty means unconstrained IsConstrained bool // convenience: XPathConstraint != "" + // DefaultMemberAccessRights is the rule's "default rights for new members" + // setting: None, ReadOnly or ReadWrite. Empty when not applicable. + DefaultMemberAccessRights string } // PermissionsFor returns an iterator over all permissions for a given entity. func (ctx *LintContext) PermissionsFor(entityQualifiedName string) iter.Seq[Permission] { return func(yield func(Permission) bool) { rows, err := ctx.db.Query(` - SELECT ModuleRoleName, ElementName, MemberName, AccessType, XPathConstraint, ModuleName + SELECT ModuleRoleName, ElementName, MemberName, AccessType, XPathConstraint, + COALESCE(DefaultMemberAccessRights, ''), ModuleName FROM permissions WHERE ElementType = 'ENTITY' AND ElementName = ? ORDER BY ModuleRoleName, AccessType @@ -332,7 +336,8 @@ func (ctx *LintContext) PermissionsFor(entityQualifiedName string) iter.Seq[Perm for rows.Next() { var p Permission var memberName, xpathConstraint, moduleName sql.NullString - err := rows.Scan(&p.ModuleRoleName, &p.EntityName, &memberName, &p.AccessType, &xpathConstraint, &moduleName) + err := rows.Scan(&p.ModuleRoleName, &p.EntityName, &memberName, &p.AccessType, &xpathConstraint, + &p.DefaultMemberAccessRights, &moduleName) if err != nil { ctx.recordQueryError("PermissionsFor (row scan)", err) continue @@ -359,6 +364,9 @@ type AllPermission struct { XPathConstraint string IsConstrained bool ModuleName string + // DefaultMemberAccessRights is the rule's "default rights for new members" + // setting: None, ReadOnly or ReadWrite. Empty for non-entity permissions. + DefaultMemberAccessRights string } // Permissions returns an iterator over all permissions in the catalog. @@ -370,7 +378,8 @@ func (ctx *LintContext) Permissions() iter.Seq[AllPermission] { rows, err := ctx.db.Query(` SELECT ModuleRoleName, ElementType, ElementName, COALESCE(MemberName, ''), AccessType, - COALESCE(XPathConstraint, ''), COALESCE(ModuleName, '') + COALESCE(XPathConstraint, ''), COALESCE(DefaultMemberAccessRights, ''), + COALESCE(ModuleName, '') FROM permissions ORDER BY ElementType, ElementName, ModuleRoleName, AccessType `) @@ -383,7 +392,8 @@ func (ctx *LintContext) Permissions() iter.Seq[AllPermission] { for rows.Next() { var p AllPermission if err := rows.Scan(&p.ModuleRoleName, &p.ElementType, &p.ElementName, - &p.MemberName, &p.AccessType, &p.XPathConstraint, &p.ModuleName); err != nil { + &p.MemberName, &p.AccessType, &p.XPathConstraint, + &p.DefaultMemberAccessRights, &p.ModuleName); err != nil { continue } p.IsConstrained = p.XPathConstraint != "" diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index 40e2d4815..e3e7cf7f9 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -1008,27 +1008,29 @@ func referenceToStarlark(r Reference) starlark.Value { // allPermissionToStarlark converts an AllPermission to a Starlark struct. func allPermissionToStarlark(p AllPermission) starlark.Value { return starlarkstruct.FromStringDict(starlark.String("permission"), starlark.StringDict{ - "module_role_name": starlark.String(p.ModuleRoleName), - "element_type": starlark.String(p.ElementType), - "element_name": starlark.String(p.ElementName), - "member_name": starlark.String(p.MemberName), - "access_type": starlark.String(p.AccessType), - "xpath_constraint": starlark.String(p.XPathConstraint), - "is_constrained": starlark.Bool(p.IsConstrained), - "module_name": starlark.String(p.ModuleName), + "module_role_name": starlark.String(p.ModuleRoleName), + "element_type": starlark.String(p.ElementType), + "element_name": starlark.String(p.ElementName), + "member_name": starlark.String(p.MemberName), + "access_type": starlark.String(p.AccessType), + "xpath_constraint": starlark.String(p.XPathConstraint), + "is_constrained": starlark.Bool(p.IsConstrained), + "module_name": starlark.String(p.ModuleName), + "default_member_access_rights": starlark.String(p.DefaultMemberAccessRights), }) } // permissionToStarlark converts a Permission to a Starlark struct. func permissionToStarlark(p Permission) starlark.Value { return starlarkstruct.FromStringDict(starlark.String("entity_permission"), starlark.StringDict{ - "module_role_name": starlark.String(p.ModuleRoleName), - "module_name": starlark.String(p.ModuleName), - "entity_name": starlark.String(p.EntityName), - "access_type": starlark.String(p.AccessType), - "member_name": starlark.String(p.MemberName), - "xpath_constraint": starlark.String(p.XPathConstraint), - "is_constrained": starlark.Bool(p.IsConstrained), + "module_role_name": starlark.String(p.ModuleRoleName), + "module_name": starlark.String(p.ModuleName), + "entity_name": starlark.String(p.EntityName), + "access_type": starlark.String(p.AccessType), + "member_name": starlark.String(p.MemberName), + "xpath_constraint": starlark.String(p.XPathConstraint), + "is_constrained": starlark.Bool(p.IsConstrained), + "default_member_access_rights": starlark.String(p.DefaultMemberAccessRights), }) } From d245bb7bf136bf4d16b38b5d6e14ddee5329eadd Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 24 Sep 2026 08:47:42 +0000 Subject: [PATCH 2/7] feat(lint): expose the Call REST service activity's timeout A 'Call REST service' activity has a 'Use a timeout' toggle and a timeout in seconds. Neither reaches a custom lint rule, so the rule 'every outbound REST call must have a timeout' cannot be written: a rule can identify a RestCallAction but learns nothing about how it is configured. Storage names verified against a real MPR with 'mxcli bson dump': "TimeOutExpression": "300" "UseRequestTimeOut": true Note the seconds are stored as an expression string, not an integer, and that UseRequestTimeOut was not read at all - microflowActionFromGen built a RestCallAction without it, so the value was lost on read. Changes: read UseRequestTimeOut in microflow_read_actions.go, carry it on sdk/microflows.RestCallAction, store both it and the existing TimeoutExpression on the activities table, and expose them as use_request_timeout / timeout_expression. Follows the ServiceRef/ActionRef precedent. Read side only. microflow_write.go currently writes UseRequestTimeOut as a hardcoded true, which will silently flip a false back to true on rewrite - a separate bug, left alone here to keep this change scoped to linting. --- .../skills/mendix/write-lint-rules/SKILL.md | 2 ++ .../modelsdk/microflow_read_actions.go | 3 ++ mdl/catalog/builder_microflows.go | 29 +++++++++++++++++-- mdl/catalog/tables.go | 2 ++ mdl/linter/context.go | 14 +++++++-- mdl/linter/starlark.go | 2 ++ sdk/microflows/microflows_actions.go | 6 +++- 7 files changed, 52 insertions(+), 6 deletions(-) diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 3e6bf7d84..fe89d3eb1 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -351,6 +351,8 @@ def count_not(node): | `entity_ref` | string | Referenced entity qualified name | | `service_ref` | string | Called service document (REST / web service / OData client); empty when the activity calls none | | `action_ref` | string | Operation or action within that service; empty when the activity calls none | +| `use_request_timeout` | bool | Call REST service: whether "Use a timeout" is enabled. False for other action types | +| `timeout_expression` | string | Call REST service: the timeout in seconds, stored as an expression, e.g. `"300"` | ### rest_client | Property | Type | Example | diff --git a/mdl/backend/modelsdk/microflow_read_actions.go b/mdl/backend/modelsdk/microflow_read_actions.go index bc7d9193c..70f1aefce 100644 --- a/mdl/backend/modelsdk/microflow_read_actions.go +++ b/mdl/backend/modelsdk/microflow_read_actions.go @@ -331,6 +331,9 @@ func actionFromGen(el element.Element) microflows.MicroflowAction { ErrorHandlingType: microflows.ErrorHandlingType(rawStr(raw, "ErrorHandlingType")), TimeoutExpression: rawStr(raw, "TimeOutExpression"), } + if b, ok := raw.Lookup("UseRequestTimeOut").BooleanOK(); ok { + out.UseRequestTimeOut = b + } out.ID = model.ID(a.ID()) if hc, ok := raw.Lookup("HttpConfiguration").DocumentOK(); ok { out.HttpConfiguration = httpConfigFromRaw(hc) diff --git a/mdl/catalog/builder_microflows.go b/mdl/catalog/builder_microflows.go index d9cf1596d..1bbc97022 100644 --- a/mdl/catalog/builder_microflows.go +++ b/mdl/catalog/builder_microflows.go @@ -58,9 +58,10 @@ func (b *Builder) buildMicroflows() error { if b.fullMode { actStmt, err = b.tx.Prepare(` INSERT INTO activities_data (Id, Name, Caption, ActivityType, Sequence, MicroflowId, MicroflowQualifiedName, - ModuleName, Folder, EntityRef, ActionType, ServiceRef, ActionRef, Description, + ModuleName, Folder, EntityRef, ActionType, ServiceRef, ActionRef, + UseRequestTimeout, TimeoutExpression, Description, ProjectId, SnapshotId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -158,6 +159,8 @@ func (b *Builder) buildMicroflows() error { actionType := "" serviceRef := "" actionRef := "" + useRequestTimeout := 0 + timeoutExpression := "" if act, ok := obj.(*microflows.ActionActivity); ok { if act.Action != nil { @@ -170,6 +173,13 @@ func (b *Builder) buildMicroflows() error { case *microflows.CallExternalAction: serviceRef = a.ConsumedODataService actionRef = a.Name + case *microflows.RestCallAction: + // "Use a timeout" plus the seconds, which Studio Pro + // stores as an expression string (e.g. "300"). + if a.UseRequestTimeOut { + useRequestTimeout = 1 + } + timeoutExpression = a.TimeoutExpression } } } @@ -188,6 +198,8 @@ func (b *Builder) buildMicroflows() error { actionType, serviceRef, actionRef, + useRequestTimeout, + timeoutExpression, "", projectID, snapshotID, ) @@ -250,6 +262,8 @@ func (b *Builder) buildMicroflows() error { actionType := "" serviceRef := "" actionRef := "" + useRequestTimeout := 0 + timeoutExpression := "" if act, ok := obj.(*microflows.ActionActivity); ok { if act.Action != nil { @@ -262,6 +276,13 @@ func (b *Builder) buildMicroflows() error { case *microflows.CallExternalAction: serviceRef = a.ConsumedODataService actionRef = a.Name + case *microflows.RestCallAction: + // "Use a timeout" plus the seconds, which Studio Pro + // stores as an expression string (e.g. "300"). + if a.UseRequestTimeOut { + useRequestTimeout = 1 + } + timeoutExpression = a.TimeoutExpression } } } @@ -280,6 +301,8 @@ func (b *Builder) buildMicroflows() error { actionType, serviceRef, actionRef, + useRequestTimeout, + timeoutExpression, "", projectID, snapshotID, ) @@ -341,7 +364,7 @@ func (b *Builder) buildMicroflows() error { if _, err := actStmt.Exec( string(obj.GetID()), activityName, "Activity", activityType, seq+1, string(rule.ID), qualifiedName, moduleName, moduleName, - "", actionType, "", "", "", + "", actionType, "", "", 0, "", "", projectID, snapshotID, ); err != nil { return err diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index 84aca3aee..61d2abcbc 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -545,6 +545,8 @@ func (c *Catalog) createTables() error { ActionType TEXT, ServiceRef TEXT, ActionRef TEXT, + UseRequestTimeout INTEGER DEFAULT 0, + TimeoutExpression TEXT, Description TEXT, ProjectId TEXT, SnapshotId TEXT diff --git a/mdl/linter/context.go b/mdl/linter/context.go index 0ae76e048..1ffa7b0ac 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -1292,6 +1292,12 @@ type Activity struct { // by the catalog builder and are empty for activities that call neither. ServiceRef string ActionRef string + // UseRequestTimeout mirrors "Use a timeout" on a Call REST service + // activity; TimeoutExpression is the number of seconds, which Studio Pro + // stores as an expression string (e.g. "300"). Both are zero for other + // action types. + UseRequestTimeout bool + TimeoutExpression string } // ActivitiesFor returns an iterator over all activities for a given microflow. @@ -1300,7 +1306,8 @@ func (ctx *LintContext) ActivitiesFor(microflowQualifiedName string) iter.Seq[Ac rows, err := ctx.db.Query(` SELECT Id, Name, Caption, ActivityType, ActionType, MicroflowId, MicroflowQualifiedName, ModuleName, EntityRef, - ServiceRef, ActionRef + ServiceRef, ActionRef, + COALESCE(UseRequestTimeout, 0), COALESCE(TimeoutExpression, '') FROM activities WHERE MicroflowQualifiedName = ? ORDER BY Sequence @@ -1315,9 +1322,11 @@ func (ctx *LintContext) ActivitiesFor(microflowQualifiedName string) iter.Seq[Ac var a Activity var name, caption, actionType, entityRef sql.NullString var serviceRef, actionRef sql.NullString + var useRequestTimeout int err := rows.Scan(&a.ID, &name, &caption, &a.ActivityType, &actionType, &a.MicroflowID, &a.MicroflowQualifiedName, &a.ModuleName, &entityRef, - &serviceRef, &actionRef) + &serviceRef, &actionRef, + &useRequestTimeout, &a.TimeoutExpression) if err != nil { ctx.recordQueryError("ActivitiesFor (row scan)", err) continue @@ -1328,6 +1337,7 @@ func (ctx *LintContext) ActivitiesFor(microflowQualifiedName string) iter.Seq[Ac a.EntityRef = entityRef.String a.ServiceRef = serviceRef.String a.ActionRef = actionRef.String + a.UseRequestTimeout = useRequestTimeout != 0 if !yield(a) { return diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index 40e2d4815..86366f79a 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -1089,6 +1089,8 @@ func activityToStarlark(a Activity) starlark.Value { "entity_ref": starlark.String(a.EntityRef), "service_ref": starlark.String(a.ServiceRef), "action_ref": starlark.String(a.ActionRef), + "use_request_timeout": starlark.Bool(a.UseRequestTimeout), + "timeout_expression": starlark.String(a.TimeoutExpression), }) } diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index d2e77b44d..c338a2dcb 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -846,7 +846,11 @@ type RestCallAction struct { ErrorHandlingType ErrorHandlingType `json:"errorHandlingType,omitempty"` OutputVariable string `json:"outputVariable,omitempty"` UseReturnVariable bool `json:"useReturnVariable"` - TimeoutExpression string `json:"timeoutExpression,omitempty"` + // UseRequestTimeOut is Studio Pro's "Use a timeout" toggle. Stored as + // UseRequestTimeOut; TimeoutExpression (stored TimeOutExpression) holds the + // number of seconds as an expression, e.g. "300". + UseRequestTimeOut bool `json:"useRequestTimeOut,omitempty"` + TimeoutExpression string `json:"timeoutExpression,omitempty"` } func (RestCallAction) isMicroflowAction() {} From 4ace83a3935c637330757d2421139de37711a9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Go=C5=82embiewski?= Date: Thu, 24 Sep 2026 18:45:04 +0200 Subject: [PATCH 3/7] fix(check): report MDL042 for @caption on a while loop (#1187) A while loop builds the same Microflows$LoopedActivity as a for-each loop, which has no Caption property, so @caption on it was dropped by exec. MDL042 was raised only in the LoopStmt case; it now lives in checkCaptionOnLoop and is called for WhileStmt too, pointing to @annotation instead. --- .../fix-issue/findings/mdl-executor.jsonl | 1 + .../reference/control-flow.md | 9 +++--- CHANGELOG.md | 4 +++ mdl/executor/validate_microflow.go | 32 ++++++++++++------- .../validate_microflow_loop_caption_test.go | 29 +++++++++++++++++ 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 1319382a8..0391d7f05 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -686,3 +686,4 @@ {"area": "mdl/executor", "date": "2026-09-23", "symptom": "`CALL MICROFLOW M.F(…) IN QUEUE M.Q` where `F` returns **Boolean**: `mxcli check -p --references` says `Check passed!`, `mx check` says **CE7033** \"A microflow used for background execution must have a Microflow return type of 'Nothing'.\" (at Call microflow activity 'F'). Reported with the CE0142 after-startup sibling, which MDL073 had already closed.", "cause": "The --references pass resolved the call target and the queue name separately, and both resolve. Nothing compared the binding (`in queue`) against the signature of the flow it names. Added MDL088: a project-less pass (ValidateQueuedCallReturnType) for a target the script creates, and validateQueuedMicroflowTargets on the --references path for a stored target, which skips script-defined targets so the fault is not printed twice. Stored void microflows read back as ReturnType \"Void\", not \"\" — both must mean Nothing.", "file": "`mdl/executor/validate_queued_call_return.go` (queuedMicroflowCalls, checkQueuedMicroflowReturnsNothing, validateQueuedMicroflowTargets, ValidateQueuedCallReturnType), wired in `validate_program.go` and `validate.go` (validateFlowBodyReferences); examples `mdl-examples/bug-tests/1064-queued-microflow-must-return-nothing{,.fail}.mdl`", "insight": "Same class as MDL073 (\"the reference resolves\" ≠ \"the reference is usable\"): any binding that names a flow carries a constraint on that flow's signature, and a resolver checks only the name. When one such check lands, sweep for its siblings at other binding sites. The queued CALL JAVA ACTION twin (CE7038) is still unchecked and was deliberately left out of scope. Two things that cost time: (1) `mxcli exec` of a script that CREATEs a queue and then binds a call to it refuses with 'task queue not found' — validateFlowBodyReferences checks queues against the project only, not the script context — so the repro has to create the queue in a separate exec; (2) walk call statements by reflection, not by the flowRefCollector switch, which does not descend into WHILE bodies. Measured on mxbuild 11.12.0 with two projects: Boolean target → CE7033, void target → 0 errors.", "refs": ["mendixlabs/mxcli#1064"], "ce": ["CE7033"], "rules": ["MDL088"]} {"area": "mdl/executor/microflow-layout", "date": "2026-09-23", "symptom": "MPR011 fires on EVERY `while` loop mxcli writes \u2014 'first activity at (50,80) lies outside the loop box' \u2014 single-level loops included. `mx check` passes and the app runs; the flow just renders wrong in Studio Pro. Reported from a real project as 'looks like an mxcli layout issue', with 3 MPR011 warnings still in its final lint run. mxcli's own lint rule was correctly flagging mxcli's own output.", "cause": "One missing term in the WHILE builder. addWhileStatement had `innerStartX := LoopPadding` (50) where addLoopStatement has `LoopPadding + iteratorSpace + ActivityWidth/2` (210). A microflow object's Position is its CENTRE \u2014 the builder says so itself ('Position is the CENTER point (RelativeMiddlePoint in Mendix)') \u2014 so a centre at x=50 with ActivityWidth=120 puts the left edge at -10. The doc comment says the while layout 'matches addLoopStatement but without iterator icon space': dropping the iterator space (100) was right, taking ActivityWidth/2 with it was not, because that term is not iterator space, it is what converts a centre to a left edge. The very next line, `innerStartY := LoopPadding + ActivityHeight/2`, adds the half-height for exactly this reason \u2014 so the omission was accidental, not a choice. Reported (50,80) matches term for term: 50 = LoopPadding, 80 = LoopPadding + ActivityHeight/2.", "file": "`mdl/executor/cmd_microflows_builder_control.go` (addWhileStatement: `innerStartX := LoopPadding + ActivityWidth/2`), tests `mdl/executor/loop_containment_test.go` (TestWhileLoopBox_ContainsDefaultLaidOutChildren, TestWhileLoopFirstChildLeftEdgeIsInsideTheBox)", "insight": "The containment invariant WAS already tested \u2014 loop_containment_test.go exists from #884 and asserts exactly this \u2014 but every fixture in it built a FOREACH loop. There are two loop builders; one was covered and the uncovered one shipped the violation into every project that writes a `while`. An invariant is worth what its COVERAGE is, and a file named for an invariant reads as if it covers the invariant, which is how a second code path goes unexamined for months. When a rule flags the tool's own output, believe the rule first: the reporter hedged with 'looks like an mxcli layout issue' and was exactly right. Cheap tell for this class: a term present on one axis and absent on the other in adjacent lines (`+ ActivityHeight/2` on Y, nothing on X) is almost always an omission rather than a decision. Failing test written first; it reproduced the reported geometry to the pixel, x[-10,...] at 1, 2, 4 and 7 activities. Still uncovered: addManualWhileTrueStatement, the third loop builder.", "refs": ["ako/mxcli#884", "ako/mxcli#645"]} {"date": "2026-09-23", "area": "mdl-executor", "symptom": "upstream #1176: DESCRIBE prints `all` on an import activity that returns ONE object — `$objectResponse = import from mapping M.IMM($s) all;` — which reads as a list import. Reported on v0.23.0 / Studio Pro 11.12.3, after #881 was believed to have settled import ranges", "cause": "#881 made `formatImportMappingRange` always emit a range keyword, because at the time a missing keyword let the range fall back to the variable's cardinality and store First. The later runtime fix (unauthored range written as All explicitly) made bare and `all` build the same activity, but the describe side was never revisited, so `all` kept printing where it was only noise", "file": "`mdl/executor/cmd_microflows_format_action.go` (`formatImportMappingRange`: return \"\" for All against SingleObject); tests `mdl/executor/cmd_microflows_import_range_test.go` (`TestImportRange_ObjectResultDescribesWithoutAll`); example `mdl-examples/bug-tests/1176-import-mapping-object-describes-without-all.mdl`", "insight": "**This was not #881 regressing — it was #881's own workaround outliving its reason.** 'DESCRIBE must never emit nothing' was a guard against the builder's then-broken default; once the builder wrote a missing keyword as All explicitly, the guard became pure noise, and nothing linked the two sites. When a formatter emits something 'because the builder would otherwise infer X', put that reason in a test that asserts the builder equivalence (bare vs keyword build the same activity), so fixing the builder flags the formatter. Proving the omission safe needs that equivalence on a real project, not just the unit test: on 11.12.3 both spellings store byte-identical ResultHandling (ConstantRange{SingleObject:false} + ObjectType), `mx check` 0 errors, and exec'ing the described text reports 'Unchanged microflow'. Wrong turn to skip: a JSON diff of two EMPTY extractions prints 'IDENTICAL' — `bson dump` emits ordered Key/Value lists, not objects; check the extraction is non-empty before trusting a diff", "refs": ["#881", "#1176"]} +{"area": "mdl/executor", "date": "2026-09-24", "symptom": "`@caption 'Are there months left?'` above a `while` passed `mxcli check` with no warning, `exec` created the microflow, and `describe` showed the while with no caption. The same caption on a `loop` was reported as MDL042.", "cause": "MDL042 lived in the `*ast.LoopStmt` case of validate_microflow.go only. `addWhileStatement` builds the same Microflows$LoopedActivity as a for-each loop -- a WhileLoopCondition instead of an iterator -- and LoopedActivity has no Caption property, so the caption had nowhere to go and nothing said so.", "file": "mdl/executor/validate_microflow.go", "insight": "A diagnostic keyed on one AST statement misses every other statement that builds the same model element. When a check exists because the MODEL lacks a property, key it on what the builder writes (here: every LoopedActivity) rather than on the MDL keyword that led there.", "refs": "mendixlabs/mxcli#1187"} diff --git a/.claude/skills/mendix/write-microflows/reference/control-flow.md b/.claude/skills/mendix/write-microflows/reference/control-flow.md index bc39d74ee..3ebf37138 100644 --- a/.claude/skills/mendix/write-microflows/reference/control-flow.md +++ b/.claude/skills/mendix/write-microflows/reference/control-flow.md @@ -206,10 +206,11 @@ begin end loop; ``` -> **`@caption` does nothing on a loop.** Mendix for-loops have no caption -> property, so `@caption` on a `loop` is silently dropped (`mxcli check` flags -> it as **MDL042**). To label a loop, use `@annotation 'text'` — it attaches a -> note, exactly like drawing one onto the loop in Studio Pro. +> **`@caption` does nothing on a loop or a while loop.** Both are the same loop +> activity, which has no caption property, so `@caption` on a `loop` or a `while` +> is dropped (`mxcli check` flags it as **MDL042**). To label either, use +> `@annotation 'text'` — it attaches a note, exactly like drawing one onto the loop +> in Studio Pro. **Note**: - Loop variable (`$Product`) is scoped to the loop body diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8b6e66b..70e3d9aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **`@caption` on a `while` loop was dropped without a word** (mendixlabs/mxcli#1187) — `check` passed, `exec` wrote the loop, and `describe` showed it without the caption, while the same caption on a `loop` was reported as **MDL042**. Both build a `Microflows$LoopedActivity`, which has no Caption property; only the for-each case was checked. MDL042 now covers a `while` too, pointing to `@annotation`, which round-trips through `describe`. + ## [0.24.0] - 2026-09-24 Headline: **An element's storage GUID is the database's identity, and mxcli now treats it as one.** A production report of 28 attributes emptied across 607 rows by a single edit (mendixlabs/mxcli#1119) traced to five write paths that re-minted GUIDs — one of them moving 282 in a single module. They are fixed, and a new guard at the write choke point refuses any write that moves one: a class of data loss that leaves the model valid, `mx check` clean and `DESCRIBE` byte-identical, and surfaces only when the package meets a database that already holds data. Alongside it, `MOVE ENTITY` and `RENAME` stop leaving a project unbuildable, and four more scripts that passed every gate and failed the build are refused. diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 3c5c6880c..cd0b8091e 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -369,6 +369,10 @@ func (v *microflowValidator) walkBody(body []ast.MicroflowStatement) { // never legal. The body was not walked at all before, so nothing inside // a while was checked. v.checkQualifiedCallInExpression("while condition", stmt.Condition) + // A while loop is the same Microflows$LoopedActivity as a for-each loop, + // with a WhileLoopCondition instead of an iterator, so a @caption on it is + // dropped exactly the same way. + v.checkCaptionOnLoop(stmt.Annotations, "a while loop") v.walkBody(stmt.Body) case *ast.CallMicroflowStmt: v.checkAssociationObjectArgs("microflow "+stmt.MicroflowName.String(), stmt.Arguments) @@ -382,17 +386,7 @@ func (v *microflowValidator) walkBody(body []ast.MicroflowStatement) { // mapping are alternatives. Same function exec calls. v.checkWebServiceRequestBody(stmt) case *ast.LoopStmt: - // Check: @caption on a loop is silently dropped — Mendix for-loops - // have no caption (Microflows$LoopedActivity has no Caption - // property; Studio Pro auto-labels them from the iterator). The - // supported way to label a loop is an annotation note. - if stmt.Annotations != nil && stmt.Annotations.Caption != "" { - v.addViolation("MDL042", linter.SeverityWarning, - "@caption on a loop has no effect — Mendix loops have no caption "+ - "(the loop activity has no Caption property, so it is dropped). "+ - "Use @annotation to attach a note to the loop instead.", - "Replace @caption with @annotation to label the loop") - } + v.checkCaptionOnLoop(stmt.Annotations, "a loop") // Check: nested loop anti-pattern. This is a heuristic — a nested loop is // only wasteful when the inner loop is a key LOOKUP (find one matching // item). Intentional aggregation that must visit every element (group × @@ -1568,3 +1562,19 @@ func (v *microflowValidator) checkAnnotationLabels(body []ast.MicroflowStatement } walk(body) } + +// checkCaptionOnLoop raises MDL042 for a @caption on a loop or a while loop. Both +// build a Microflows$LoopedActivity, which has no Caption property -- Studio Pro +// labels a for-each loop from its iterator and a while loop from its condition -- +// so the caption is dropped on write. The supported way to label either is an +// annotation note, which round-trips through DESCRIBE. +func (v *microflowValidator) checkCaptionOnLoop(ann *ast.ActivityAnnotations, what string) { + if ann == nil || ann.Caption == "" { + return + } + v.addViolation("MDL042", linter.SeverityWarning, + "@caption on "+what+" has no effect — Mendix loops have no caption "+ + "(the loop activity has no Caption property, so it is dropped). "+ + "Use @annotation to attach a note to the loop instead.", + "Replace @caption with @annotation to label the loop") +} diff --git a/mdl/executor/validate_microflow_loop_caption_test.go b/mdl/executor/validate_microflow_loop_caption_test.go index d66e27e2a..f402b3e9d 100644 --- a/mdl/executor/validate_microflow_loop_caption_test.go +++ b/mdl/executor/validate_microflow_loop_caption_test.go @@ -52,3 +52,32 @@ func TestValidateMicroflow_PlainLoopNoWarn(t *testing.T) { t.Error("MDL042 must not fire for a plain loop") } } + +func mfWithWhileAnnotations(ann *ast.ActivityAnnotations) *ast.CreateMicroflowStmt { + return &ast.CreateMicroflowStmt{ + Name: ast.QualifiedName{Module: "Sample", Name: "MF"}, + Body: []ast.MicroflowStatement{ + &ast.WhileStmt{ + Condition: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}, + Annotations: ann, + Body: []ast.MicroflowStatement{}, + }, + }, + } +} + +// A while loop builds the same LoopedActivity as a for-each loop, so its @caption +// is dropped the same way and must be reported the same way (mendixlabs/mxcli#1187). +// Before the fix a while loop's caption passed check silently and vanished on exec. +func TestValidateMicroflow_CaptionOnWhileWarns(t *testing.T) { + if !loopHasMDL042(mfWithWhileAnnotations(&ast.ActivityAnnotations{Caption: "Months left?"})) { + t.Error("expected MDL042 warning for @caption on a while loop") + } +} + +// @annotation is the supported label for a while loop too, and must not warn. +func TestValidateMicroflow_AnnotationOnWhileNoWarn(t *testing.T) { + if loopHasMDL042(mfWithWhileAnnotations(&ast.ActivityAnnotations{Notes: []ast.MicroflowAnnotation{{Text: "Count the months"}}})) { + t.Error("MDL042 must not fire for @annotation on a while loop") + } +} From b031d67d57c4eda8e679b9c21707c972e71f5a64 Mon Sep 17 00:00:00 2001 From: Ako Date: Fri, 25 Sep 2026 06:32:04 +0000 Subject: [PATCH 4/7] fix(microflows): drop the dead loop-caption code the MDL042 fix contradicts Review follow-up on #1188. MDL042 now tells the author a loop's @caption is dropped because the activity has no Caption property -- while two files away the builder still ran case *microflows.LoopedActivity: // LOOP / WHILE activities can carry a caption just like splits activity.Caption = ann.Caption and the describer still emitted @caption for one. Nothing read either back: generated/metamodel -- the arbiter -- declares no Caption on Microflows$LoopedActivity, microflow_write.go sets none on the gen object, and the reader therefore cannot populate one. The next contributor reads the builder, concludes the new warning is wrong and deletes MDL042, reopening mendixlabs/mxcli#1187 from the other side. Measured before deleting anything, with the UNMODIFIED branch build against a Mendix 11.6.6 project: `@caption` on a loop and on a while are both absent from `describe microflow` after `exec`, while `@annotation` on either round-trips. So the value died at the gen boundary and the assignment only ever populated an in-memory field. That is also why the code survived: three tests asserted it. They tested the semantic object, never storage, so they passed throughout and would have failed on the correct fix. Inverted rather than deleted, each naming the measurement: TestLoopCaptionPreserved -> TestLoopCaptionNotStorable TestWhileLoopCaptionPreserved -> TestWhileLoopCaptionNotStorable TestEmitObjectAnnotations_LoopCaption -> ..._LoopCaptionNotEmitted The describe test's real value was mdlQuote escaping, which TestMdlQuote_* already covers directly, so retargeting loses nothing. Reinstating the assignment fails the two builder tests -- checked. Also adds the bug-test script the checklist asks for, covering both captions (MDL042 x2) and both @annotation forms (silent), and keeps sdk/microflows LoopedActivity.Caption with a comment saying it cannot be stored. Co-Authored-By: Claude Opus 5 (1M context) --- .../microflow-1187-while-caption.mdl | 77 +++++++++++++++++++ .../cmd_microflows_builder_annotations.go | 13 ++-- ...cmd_microflows_builder_annotations_test.go | 21 +++-- mdl/executor/cmd_microflows_show_helpers.go | 6 +- .../cmd_microflows_show_helpers_test.go | 13 +++- sdk/microflows/microflows.go | 3 + 6 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 mdl-examples/bug-tests/microflow-1187-while-caption.mdl diff --git a/mdl-examples/bug-tests/microflow-1187-while-caption.mdl b/mdl-examples/bug-tests/microflow-1187-while-caption.mdl new file mode 100644 index 000000000..89f18741f --- /dev/null +++ b/mdl-examples/bug-tests/microflow-1187-while-caption.mdl @@ -0,0 +1,77 @@ +-- mendixlabs/mxcli#1187 — @caption on a while loop was dropped with no diagnostic. +-- +-- A `while` builds the same Microflows$LoopedActivity as a for-each `loop`, and +-- generated/metamodel declares no Caption on it, so the caption has nowhere to go. +-- MDL042 was raised only for `loop`, so a `while` passed `check`, was written by +-- `exec`, and came back from `describe` with the caption gone. +-- +-- EXPECTED after the fix — `mxcli check` reports MDL042 twice, once per loop: +-- ⚠ @caption on a while loop has no effect … [MDL042] +-- ⚠ @caption on a loop has no effect … [MDL042] +-- and NOT for the @annotation forms, which are stored and round-trip. +-- +-- In Studio Pro: neither loop shows a caption; both show the attached note. + +create or modify entity MyFirstModule.LoopCaptionProbe ( + Name: string(100) +); +/ + +-- The reporter's repro: @caption on a while. +create or modify microflow MyFirstModule.MF_1187_WhileCaption () +returns integer as $Months +begin + declare $Months integer = 0; + @caption 'Are there months left to count?' + while $Months < 3 + begin + set $Months = $Months + 1; + end while; + return $Months; +end; +/ + +-- The case that was already reported, kept beside it so a regression in either +-- direction shows up in the same run. +create or modify microflow MyFirstModule.MF_1187_LoopCaption () +returns integer as $Count +begin + declare $Count integer = 0; + retrieve $Probes from MyFirstModule.LoopCaptionProbe; + @caption 'Count the probes' + loop $Probe in $Probes + begin + set $Count = $Count + 1; + end loop; + return $Count; +end; +/ + +-- The supported spelling for both: @annotation is stored and comes back from +-- describe, so neither of these may report MDL042. +create or modify microflow MyFirstModule.MF_1187_WhileAnnotation () +returns integer as $Months +begin + declare $Months integer = 0; + @annotation 'Counts up to three months' + while $Months < 3 + begin + set $Months = $Months + 1; + end while; + return $Months; +end; +/ + +create or modify microflow MyFirstModule.MF_1187_LoopAnnotation () +returns integer as $Count +begin + declare $Count integer = 0; + retrieve $Probes from MyFirstModule.LoopCaptionProbe; + @annotation 'Counts every probe' + loop $Probe in $Probes + begin + set $Count = $Count + 1; + end loop; + return $Count; +end; +/ diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 591a203b7..f6aa20b0b 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -117,12 +117,13 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn if ann.Caption != "" { activity.Caption = ann.Caption } - case *microflows.LoopedActivity: - // LOOP / WHILE activities can carry a caption just like - // splits and action activities. - if ann.Caption != "" { - activity.Caption = ann.Caption - } + // No case for *microflows.LoopedActivity. A loop and a while build + // one, and generated/metamodel -- the arbiter -- declares no Caption + // on Microflows$LoopedActivity, so there is nowhere for the value to + // go: the gen writer emits none and the reader can never populate + // one. Assigning it here wrote a field nothing reads and contradicted + // MDL042, which tells the author the caption is dropped. The + // diagnostic is the whole of the support (mendixlabs/mxcli#1187). } break diff --git a/mdl/executor/cmd_microflows_builder_annotations_test.go b/mdl/executor/cmd_microflows_builder_annotations_test.go index c8adc5259..860303ac4 100644 --- a/mdl/executor/cmd_microflows_builder_annotations_test.go +++ b/mdl/executor/cmd_microflows_builder_annotations_test.go @@ -271,7 +271,7 @@ func TestLoopBodyIfAnnotationPromotedToParentFlows(t *testing.T) { // TestLoopCaptionPreserved covers the loop caption case — previously untested // per PR review. The fix for the outer-IF caption contamination bug also applied // the same snapshot/restore pattern to addLoopStatement and addWhileStatement. -func TestLoopCaptionPreserved(t *testing.T) { +func TestLoopCaptionNotStorable(t *testing.T) { innerReturn := &ast.ReturnStmt{Value: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}} loop := &ast.LoopStmt{ LoopVariable: "item", @@ -299,13 +299,18 @@ func TestLoopCaptionPreserved(t *testing.T) { if len(loops) != 1 { t.Fatalf("expected 1 LoopedActivity, got %d", len(loops)) } - if loops[0].Caption != "Process each item" { - t.Errorf("loop caption: got %q, want %q", loops[0].Caption, "Process each item") + // The builder must NOT carry a caption onto a LoopedActivity. generated/metamodel + // declares no Caption on Microflows$LoopedActivity, so the value cannot reach + // storage: measured on 11.6.6, `@caption` on a loop is absent from `describe` + // after `exec`, while `@annotation` round-trips. Setting it here wrote a field + // nothing reads and contradicted MDL042 (mendixlabs/mxcli#1187). + if loops[0].Caption != "" { + t.Errorf("loop caption: got %q, want %q — a loop caption is not storable, MDL042 reports it", loops[0].Caption, "") } } -// TestWhileLoopCaptionPreserved — same coverage for the WHILE shape. -func TestWhileLoopCaptionPreserved(t *testing.T) { +// TestWhileLoopCaptionNotStorable — same coverage for the WHILE shape. +func TestWhileLoopCaptionNotStorable(t *testing.T) { whileStmt := &ast.WhileStmt{ Condition: &ast.BinaryExpr{ Left: &ast.VariableExpr{Name: "n"}, @@ -337,8 +342,10 @@ func TestWhileLoopCaptionPreserved(t *testing.T) { if len(loops) != 1 { t.Fatalf("expected 1 LoopedActivity (WHILE), got %d", len(loops)) } - if loops[0].Caption != "Until n >= 10" { - t.Errorf("while caption: got %q, want %q", loops[0].Caption, "Until n >= 10") + // Same as the for-each case above: a while builds the same LoopedActivity, so + // its caption is equally unstorable and equally reported as MDL042. + if loops[0].Caption != "" { + t.Errorf("while caption: got %q, want %q — a while caption is not storable, MDL042 reports it", loops[0].Caption, "") } } diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 07274f92e..38c18d4f0 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -797,9 +797,9 @@ func emitObjectAnnotations( if split, ok := obj.(*microflows.InheritanceSplit); ok && split.Caption != "" { *lines = append(*lines, indentStr+fmt.Sprintf("@caption %s", mdlQuote(split.Caption))) } - if loop, ok := obj.(*microflows.LoopedActivity); ok && loop.Caption != "" { - *lines = append(*lines, indentStr+fmt.Sprintf("@caption %s", mdlQuote(loop.Caption))) - } + // No @caption for a LoopedActivity: the metamodel declares none on it, so a + // stored loop never carries one and this only ever emitted MDL that check + // would then report as MDL042 (mendixlabs/mxcli#1187). // @annotation (attached Annotation objects) *lines = append(*lines, annotationsByTarget.lines(currentID, pos, objectHeight(obj), indentStr)...) diff --git a/mdl/executor/cmd_microflows_show_helpers_test.go b/mdl/executor/cmd_microflows_show_helpers_test.go index 2cf881784..d2169bca9 100644 --- a/mdl/executor/cmd_microflows_show_helpers_test.go +++ b/mdl/executor/cmd_microflows_show_helpers_test.go @@ -162,7 +162,14 @@ func TestEmitObjectAnnotations_EscapesMultilineText(t *testing.T) { } } -func TestEmitObjectAnnotations_LoopCaption(t *testing.T) { +// A LoopedActivity must emit NO @caption. generated/metamodel declares no Caption +// on Microflows$LoopedActivity, so a stored loop can never carry one — emitting it +// produced MDL that `check` then reports as MDL042 (mendixlabs/mxcli#1187). The +// in-memory field is set here deliberately: even then, nothing may be emitted. +// mdlQuote's escaping is covered directly by TestMdlQuote_* in +// cmd_microflows_annotation_escape_test.go, so nothing is lost by not asserting it +// on a caption that cannot exist. +func TestEmitObjectAnnotations_LoopCaptionNotEmitted(t *testing.T) { obj := µflows.LoopedActivity{ BaseMicroflowObject: microflows.BaseMicroflowObject{ BaseElement: model.BaseElement{ID: mkID("loop")}, @@ -176,8 +183,8 @@ func TestEmitObjectAnnotations_LoopCaption(t *testing.T) { emitObjectAnnotations(obj, &lines, "", nil, nil, nil, nil) got := strings.Join(lines, "\n") - if !strings.Contains(got, "@caption 'Loop owner''s\\ncaption'") { - t.Fatalf("expected escaped loop caption, got:\n%s", got) + if strings.Contains(got, "@caption") { + t.Fatalf("a loop must not describe a @caption (it is not storable), got:\n%s", got) } } diff --git a/sdk/microflows/microflows.go b/sdk/microflows/microflows.go index 5f3497dcb..ddce64bfa 100644 --- a/sdk/microflows/microflows.go +++ b/sdk/microflows/microflows.go @@ -455,6 +455,9 @@ type LoopSource interface { // LoopedActivity represents a loop construct (FOR EACH or WHILE). type LoopedActivity struct { BaseMicroflowObject + // Caption is NOT storable: generated/metamodel declares no Caption on + // Microflows$LoopedActivity, so a value here is dropped at the gen boundary. + // Nothing sets it; `mxcli check` reports MDL042 instead (mendixlabs/mxcli#1187). Caption string `json:"caption,omitempty"` Documentation string `json:"documentation,omitempty"` LoopSource LoopSource `json:"loopSource,omitempty"` From da47c63312c34fb06bd3ebec2579d4dc7804b925 Mon Sep 17 00:00:00 2001 From: Ako Date: Fri, 25 Sep 2026 08:18:14 +0000 Subject: [PATCH 5/7] docs(review): record the dead-code-beside-a-new-diagnostic pattern From the review of mendixlabs/mxcli#1188. The fix added MDL042 for a @caption on a while loop -- telling the author the loop activity has no Caption property -- while the builder two files away still assigned one, under a comment saying loops can carry captions, and the describer still emitted it. Nothing read either back. The part worth recording is why such code survives: it had three tests. All asserted the caption was carried, all against the semantic object and none against storage, so they passed throughout and failed only when the dead code was removed. A reviewer who stops at "the tests pass" concludes the capability is real. The canonical fix therefore carries the measurement step -- exec then describe on a real project with the UNMODIFIED build, so the deletion rests on the stored document rather than on reading the codec -- and the instruction to invert those tests rather than delete them, keeping any half still true. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/mxcli-dev/review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/commands/mxcli-dev/review.md b/.claude/commands/mxcli-dev/review.md index 596868287..1c647739c 100644 --- a/.claude/commands/mxcli-dev/review.md +++ b/.claude/commands/mxcli-dev/review.md @@ -60,6 +60,7 @@ proactively. Add a row after every review that surfaces something new. | 29 | A predicate that names ONE cause of a build error is read as if it named the error (`mem.IsCalculated` for CE6592, which an autonumber also triggers) — the half that is covered works, so every test passes and the gap is invisible until a user hits the other half | Code correctness | When a guard cites a CE number, enumerate what the PLATFORM rejects, not what the current code checks. Put the rule in one named place (`types.WriteRightsForbidden`) rather than a bare boolean at each site, so the second cause has somewhere to go. And fix every pass that can re-derive the value — a reconcile running after every program re-broke a grant the user had corrected by hand | | 30 | Two commands compute the same thing from two copies of the setup (`report` re-implementing `lint`'s rule list and skipping its config), so they disagree about a project — and a SCORE carries no provenance, so neither number looks wrong | Code correctness | Extract the shared setup and route both through it. A value test cannot guard this when the copies live inside cobra `RunE` bodies: use a structural check on the source, with a positive control asserted FIRST so it cannot pass vacuously | | 31 | A test helper that needs a heavyweight object only to satisfy a signature (`NewLintContext(nil, nil)`, which panics) invites a nil-guard added purely to make the test compile — behaviour nothing in production needs, defended forever | Test coverage | Narrow the signature instead: if the helper does not use the parameter, drop it and let the caller apply the part it owns. A test that cannot construct an argument is usually telling you the argument does not belong | +| 32 | A fix adds a diagnostic for a capability the model lacks while leaving in place the code that asserts the capability EXISTS — MDL042 telling the author a loop's `@caption` is dropped, while `cmd_microflows_builder_annotations.go` still ran `case *microflows.LoopedActivity: activity.Caption = ann.Caption` under the comment "LOOP / WHILE activities can carry a caption just like splits", and the describer still emitted one. Nothing read either back. The next reader trusts the code over the warning, deletes the check, and reopens the bug from the other side. The reason it survives is that it usually has TESTS — three here asserted the caption was carried, all of them against the semantic object and none against storage, so they passed throughout and failed only on the correct fix | Code correctness | `generated/metamodel` is the arbiter: a field on the semantic type it does not declare cannot survive a write, so an assignment to it is dead by construction. Grep the writer, the describer and the semantic struct and delete (or re-comment) whatever sets it. MEASURE before deleting — `exec` then `describe` on a real project, with the UNMODIFIED build, so the deletion rests on the stored document rather than on reading the codec. Invert the tests that defended it rather than deleting them, keeping any half still true (escaping coverage belongs on a type that can carry a caption), and check the inverted test fails when the assignment is put back | --- From b70e1084d599dd2e5d041cc2b7ac86f54dd6f7f7 Mon Sep 17 00:00:00 2001 From: Ako Date: Fri, 25 Sep 2026 12:48:18 +0000 Subject: [PATCH 6/7] docs(matrix): audit the feature matrix against what actually ships The matrix claimed two shipped document types were unavailable, which is the failure its own "Keeping this honest" note warns about -- a row claiming a gap that has since been filled sends people to Studio Pro for work mxcli can do. - Microflow rules sat under "Not Yet Implemented" ("no MDL surface at all") while CREATE [OR MODIFY] RULE ships with a skill, rules.mdl, a `microflow.rule` syntax topic, a CATALOG.RULES view and rule REFS edges. - Message definitions likewise: SHOW / DESCRIBE / CREATE OR MODIFY / DROP / ALTER all ship, with tests and examples. Both now have rows in Core Document Types instead. Layouts were recorded as read-only in four places -- the row's CREATE, OR MODIFY, DROP, ALTER, Examples, Tests, Skills and Syntax cells were all N, and three gap lists repeated "Read-only". All eight are Y. OR MODIFY was verified at exec rather than parse: `create or replace` reports Created then Unchanged, and `create or modify` over the stored layout reports Unchanged. Sixteen Examples cells corrected against the doctype-tests listing, including two that cited the wrong file -- Task Queues and Scheduled Events both pointed at 21 (import/export mappings) and the mapping rows pointed at 06 (the REST client). Rule counts are measured, not remembered: 19 built-in (`lint --list-rules` with no project rules dir) and 31 Starlark (`.claude/lint-rules/*.star`). Four different wrong pairs were in circulation across five live docs, one claiming 41 built-in rules. CHANGELOG and the dated eval proposal are left alone: they are records of a past state, not claims about the present. The guard could not have caught any of it. TestCapabilityDocsDoNotClaim... reads only the "Not Yet Implemented" section and checks a hand-maintained list of seven capabilities naming neither rules, message definitions nor layouts. So the list is hoisted to `shippedCapabilities`, extended with all three, and a second test asserts that "Missing Syntax Topics" may not list a capability whose `mxcli syntax` topic resolves -- a flat contradiction, which is what makes it mechanically assertable. It is deliberately not extended to the Skills and Examples gap lists, where an entry can be true at the same time as a syntax topic exists (Regular Expressions has a topic and no skill). Checked by reinstating all three false claims: two tests fail, naming each. Co-Authored-By: Claude Opus 5 (1M context) --- .../syntax/capability_docs_drift_test.go | 66 ++++++++++++++++--- docs-site/src/appendixes/quick-reference.md | 2 +- docs-site/src/migration/validation.md | 2 +- docs-site/src/reference/capabilities.md | 2 +- docs-site/src/tutorial/validation.md | 2 +- docs/01-project/MDL_FEATURE_MATRIX.md | 45 ++++++------- docs/01-project/MDL_QUICK_REFERENCE.md | 2 +- 7 files changed, 83 insertions(+), 38 deletions(-) diff --git a/cmd/mxcli/syntax/capability_docs_drift_test.go b/cmd/mxcli/syntax/capability_docs_drift_test.go index 2c78ffe9d..c1950bd90 100644 --- a/cmd/mxcli/syntax/capability_docs_drift_test.go +++ b/cmd/mxcli/syntax/capability_docs_drift_test.go @@ -50,6 +50,29 @@ func readDoc(t *testing.T, name string) string { // `mxcli syntax` topic is NOT sitting in the docs' "no MDL surface at all" // table. The syntax registry is populated from the code, so it cannot claim a // topic for something that does not exist. +// shippedCapabilities pairs a capability with the syntax topic that PROVES it +// ships. A topic is registered from Go code, so the pairing cannot go stale in +// the direction that matters: delete the feature and the topic goes with it. +// +// The last three were added after an audit found the matrix claiming all three +// were unavailable: "Microflow rules" and "Message definitions" sat under "Not +// Yet Implemented" (i.e. "no MDL surface at all") while both were authorable, +// and Layouts were described as "Read-only, no syntax topic" in three separate +// gap lists. None of it was caught, because this table did not name them — a +// hand-maintained guard only guards what someone remembered to add. +var shippedCapabilities = []struct{ topic, claim string }{ + {"database-connection", "Ext. DB connector"}, + {"queue", "Task queue"}, + {"scheduled-event", "Scheduled events"}, + {"regular-expression", "Regular expressions"}, + {"image-collection", "Image collection"}, + {"navigation.menu-document", "Menus"}, + {"workflow", "Workflows"}, + {"layout", "Layouts"}, + {"microflow.rule", "Microflow rules"}, + {"message-definition", "Message definitions"}, +} + func TestCapabilityDocsDoNotClaimShippedFeaturesAreMissing(t *testing.T) { matrix := readDoc(t, "MDL_FEATURE_MATRIX.md") @@ -66,15 +89,7 @@ func TestCapabilityDocsDoNotClaimShippedFeaturesAreMissing(t *testing.T) { // Each capability that must not appear as unimplemented, keyed by the syntax // topic that proves it ships. A topic is registered from Go code, so this // pairing cannot go stale in the direction that matters. - for _, c := range []struct{ topic, claim string }{ - {"database-connection", "Ext. DB connector"}, - {"queue", "Task queue"}, - {"scheduled-event", "Scheduled events"}, - {"regular-expression", "Regular expressions"}, - {"image-collection", "Image collection"}, - {"navigation.menu-document", "Menus"}, - {"workflow", "Workflows"}, - } { + for _, c := range shippedCapabilities { if ByPath(c.topic) == nil { t.Errorf("no `mxcli syntax %s` topic — either the feature was removed "+ "(then drop this row) or the topic is missing (then add it)", c.topic) @@ -122,3 +137,36 @@ func TestMissingCapabilitiesIsMarkedAsDated(t *testing.T) { } } } + +// TestMissingSyntaxTopicsAreActuallyMissing closes the hole that let Layouts be +// listed as "Read-only, no syntax topic" while `mxcli syntax layout` answered. +// +// This is a direct contradiction rather than a judgement call, which is why it +// can be asserted mechanically: the section states a topic does not exist, and +// the registry says it does. Deliberately narrower than the Skills/Examples gap +// lists, where an entry can be true at the same time as a syntax topic exists — +// Regular Expressions has a topic AND genuinely has no skill. +func TestMissingSyntaxTopicsAreActuallyMissing(t *testing.T) { + matrix := readDoc(t, "MDL_FEATURE_MATRIX.md") + + start := strings.Index(matrix, "### Missing Syntax Topics") + if start < 0 { + t.Fatal(`MDL_FEATURE_MATRIX.md has no "### Missing Syntax Topics" section — ` + + `if it was renamed, update this guard rather than deleting it`) + } + section := matrix[start:] + if end := strings.Index(section, "\n### "); end > 0 { + section = section[:end] + } + + for _, c := range shippedCapabilities { + if ByPath(c.topic) == nil { + continue // covered by the sibling test, which reports it there + } + if strings.Contains(section, c.claim) { + t.Errorf("MDL_FEATURE_MATRIX.md lists %q under \"Missing Syntax Topics\", "+ + "but `mxcli syntax %s` resolves — the doc tells a reader to look for "+ + "syntax that is already published", c.claim, c.topic) + } + } +} diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 24e4914f8..a11276dad 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -541,7 +541,7 @@ Cross-reference commands require `REFRESH CATALOG FULL` to populate reference da | Stdin piping | `echo "CMD" \| mxcli -p app.mpr` | Quiet mode, pipe-friendly | | 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]` | 14 built-in + 27 Starlark rules | +| 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 | | 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 --git a/docs-site/src/migration/validation.md b/docs-site/src/migration/validation.md index 2df058ccf..0c173d2ac 100644 --- a/docs-site/src/migration/validation.md +++ b/docs-site/src/migration/validation.md @@ -11,7 +11,7 @@ mxcli check script.mdl # 2. Reference validation (checks entity/microflow names exist) mxcli check script.mdl -p app.mpr --references -# 3. Lint the full project (41 built-in + 27 Starlark rules) +# 3. Lint the full project (19 built-in + 31 Starlark rules) mxcli lint -p app.mpr # 4. Quality report (scored 0-100 per category) diff --git a/docs-site/src/reference/capabilities.md b/docs-site/src/reference/capabilities.md index 5bf56c230..f8a8bef35 100644 --- a/docs-site/src/reference/capabilities.md +++ b/docs-site/src/reference/capabilities.md @@ -110,7 +110,7 @@ Everything mxcli can do, organized by use case. | Cross-references | `LIST CALLERS/CALLEES OF` | Who calls what | | Impact analysis | `LIST IMPACT OF Module.Entity` | What breaks if I change this | | Transitive callers | `LIST CALLERS OF ... TRANSITIVE` | Full call chain | -| Linting | `mxcli lint -p app.mpr` | 14 built-in + 27 Starlark rules | +| Linting | `mxcli lint -p app.mpr` | 19 built-in + 31 Starlark rules | | Best practices report | `mxcli report -p app.mpr` | Scored report with categories | | Missing translations | QUAL005 linter rule | Detects incomplete translations | | Catalog queries | `SELECT ... FROM CATALOG.tables` | SQL over project metadata | diff --git a/docs-site/src/tutorial/validation.md b/docs-site/src/tutorial/validation.md index 3003709d2..c668447f6 100644 --- a/docs-site/src/tutorial/validation.md +++ b/docs-site/src/tutorial/validation.md @@ -184,7 +184,7 @@ For a broader set of checks across the entire project (not just a single script) mxcli lint -p app.mpr ``` -This runs 14 built-in rules plus 29 Starlark rules covering security, architecture, quality, and naming conventions. See `mxcli lint --list-rules` for the full list. +This runs 19 built-in rules plus 31 Starlark rules covering security, architecture, quality, and naming conventions. See `mxcli lint --list-rules` for the full list. For CI/CD integration, output in SARIF format: diff --git a/docs/01-project/MDL_FEATURE_MATRIX.md b/docs/01-project/MDL_FEATURE_MATRIX.md index e1e3bc5f3..9d2c96df6 100644 --- a/docs/01-project/MDL_FEATURE_MATRIX.md +++ b/docs/01-project/MDL_FEATURE_MATRIX.md @@ -166,10 +166,11 @@ live distinction is **MPR vs MCP**. | **Associations** | Y | Y | Y | N | Y | Y | 01 | Y | N | Y | Y | Y | Y | Y | Y | Y | N | | **Enumerations** | Y | Y | Y | Y | Y | Y | 01 | Y | Y | N | Y | Y | Y | N | Y | Y | Y | | **Microflows** | Y | Y | Y | Y | Y | N | 02 | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | -| **Nanoflows** | Y | Y | Y | Y | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | P | N | N | +| **Nanoflows** | Y | Y | Y | Y | Y | N | 02b | Y | Y | Y | Y | Y | Y | Y | P | N | N | +| **Rules** | Y | Y | Y | Y | Y | N | Y | Y | Y | Y | N | Y | Y | N | N | Y | N | | **Pages** | Y | Y | Y | N | Y | Y | 03 | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | | **Snippets** | Y | Y | Y | N | Y | Y | 03 | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | -| **Layouts** | Y | Y | N | N | N | N | N | N | Y | Y | Y | N | Y | N | Y | N | N | +| **Layouts** | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | N | | **Java Actions** | Y | Y | Y | N | Y | N | 07 | Y | Y | Y | Y | Y | Y | N | Y | Y | N | | **Constants** | Y | Y | Y | Y | Y | N | 09 | Y | N | P | Y | N | Y | N | P | N | N | | **OData Clients** | Y | Y | Y | Y | Y | Y | 10 | Y | Y | P | Y | Y | Y | N | Y | Y | N | @@ -178,24 +179,25 @@ live distinction is **MPR vs MCP**. | **Modules** | Y | Y | Y | N | Y | N | all | Y | Y | Y | Y | Y | Y | N | Y | N | N | | **Navigation** | Y | Y | Y | - | - | Y | 11 | N | Y | Y | Y | Y | Y | N | N | Y | N | | **Business Events** | Y | Y | Y | N | Y | N | 13 | N | Y | N | Y | N | Y | N | Y | Y | N | -| **Project Settings** | Y | Y | - | - | - | Y | N | N | Y | Y | Y | N | Y | N | N | Y | P | -| **Task Queues** | Y | Y | Y | Y | Y | N | 21 | Y | Y | N | N | Y | Y | N | Y | Y | N | -| **Scheduled Events** | Y | Y | Y | Y | Y | N | 21 | Y | Y | Y | N | Y | Y | N | Y | Y | N | +| **Project Settings** | Y | Y | - | - | - | Y | 14 | N | Y | Y | Y | N | Y | N | N | Y | P | +| **Task Queues** | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | Y | Y | N | Y | Y | N | +| **Scheduled Events** | Y | Y | Y | Y | Y | N | Y | Y | Y | Y | N | Y | Y | N | Y | Y | N | | **Database Connections** | Y | Y | Y | Y | Y | N | 05 | Y | Y | N | N | Y | Y | N | Y | Y | N | -| **Regular Expressions** | Y | Y | Y | Y | Y | N | N | Y | Y | Y | N | N | Y | N | Y | Y | N | -| **Validation Rules** | - | Y | Y | - | - | Y | N | Y | N | Y | N | N | Y | N | N | Y | N | -| **Menus** | - | Y | Y | Y | Y | N | N | Y | N | N | N | N | Y | N | N | Y | N | -| **Image Collections** | Y | Y | Y | N | Y | N | N | Y | N | N | N | N | Y | N | Y | Y | N | -| **JavaScript Actions** | Y | Y | Y | N | Y | N | N | Y | Y | Y | N | N | Y | N | Y | N | N | -| **Published REST Services** | Y | Y | Y | Y | Y | N | N | N | Y | N | P | N | Y | N | N | Y | N | +| **Regular Expressions** | Y | Y | Y | Y | Y | N | Y | Y | Y | Y | N | N | Y | N | Y | Y | N | +| **Validation Rules** | - | Y | Y | - | - | Y | Y | Y | N | Y | N | N | Y | N | N | Y | N | +| **Menus** | - | Y | Y | Y | Y | N | 26 | Y | N | N | N | N | Y | N | N | Y | N | +| **Image Collections** | Y | Y | Y | N | Y | N | 19 | Y | N | N | N | N | Y | N | Y | Y | N | +| **JavaScript Actions** | Y | Y | Y | N | Y | N | 07b | Y | Y | Y | N | N | Y | N | Y | N | N | +| **Published REST Services** | Y | Y | Y | Y | Y | N | 22 | N | Y | N | P | N | Y | N | N | Y | N | | **REST Clients** | Y | Y | Y | Y | Y | Y | 06 | Y | Y | P | Y | Y | Y | N | Y | Y | N | -| **Import Mappings** | Y | Y | Y | N | Y | N | 06 | Y | N | N | P | Y | N | N | N | Y | N | -| **Export Mappings** | Y | Y | Y | N | Y | N | 06 | Y | N | N | P | Y | N | N | N | Y | N | +| **Import Mappings** | Y | Y | Y | N | Y | N | 21 | Y | N | N | P | Y | N | N | N | Y | N | +| **Export Mappings** | Y | Y | Y | N | Y | N | 21 | Y | N | N | P | Y | N | N | N | Y | N | | **JSON Structures** | Y | Y | Y | Y | Y | N | 20 | Y | N | N | P | N | N | N | N | N | N | -| **Workflows** | Y | Y | Y | N | Y | Y | N | Y | Y | Y | N | Y | Y | N | N | Y | N | -| **AI Agent documents** | Y | Y | Y | N | Y | N | N | Y | N | N | N | Y | Y | N | Y | Y | N | -| **Pluggable widgets** | Y | Y | Y | - | Y | Y | 03 | Y | N | N | P | Y | Y | N | N | Y | N | -| **Data Transformers** | Y | Y | Y | N | Y | N | N | Y | N | N | N | N | Y | N | Y | Y | N | +| **Message Definitions** | Y | Y | Y | Y | Y | Y | 40 | Y | N | N | N | N | Y | N | N | Y | N | +| **Workflows** | Y | Y | Y | N | Y | Y | 24 | Y | Y | Y | N | Y | Y | N | N | Y | N | +| **AI Agent documents** | Y | Y | Y | N | Y | N | 27 | Y | N | N | N | Y | Y | N | Y | Y | N | +| **Pluggable widgets** | Y | Y | Y | - | Y | Y | 30 | Y | N | N | P | Y | Y | N | N | Y | N | +| **Data Transformers** | Y | Y | Y | N | Y | N | 23 | Y | N | N | N | N | Y | N | Y | Y | N | ## Security Features @@ -214,7 +216,7 @@ live distinction is **MPR vs MCP**. | Feature | SHOW | DESCRIBE | CREATE | OR MODIFY | DROP | ALTER | Examples | Tests | Catalog | REFS | LSP | Skills | Help | Viz | REPL | Syntax | Starlark | |---------|------|----------|--------|-----------|------|-------|----------|-------|---------|------|-----|--------|------|-----|------|--------|----------| -| **Folders** | N | N | P | N | N | N | N | P | N | N | P | Y | Y | - | N | N | N | +| **Folders** | N | N | P | N | N | N | 18 | P | N | N | P | Y | Y | - | N | N | N | | **MOVE** | - | - | - | - | - | - | N | P | N | N | P | Y | Y | - | N | Y | N | ## External SQL & Data @@ -236,7 +238,7 @@ live distinction is **MPR vs MCP**. | **Catalog Query** | `select ... from CATALOG.` | Y | Y | SQL against project metadata | | **Cross-References** | `show callers/callees/references/impact/context of` | Y | Y | Requires `refresh catalog full` | | **Full-Text Search** | `search ''` | Y | Y | Across all strings and source | -| **Linting** | `mxcli lint -p app.mpr` | Y | Y | 14 built-in + 27 Starlark rules | +| **Linting** | `mxcli lint -p app.mpr` | Y | Y | 19 built-in + 31 Starlark rules | | **Report** | `mxcli report -p app.mpr` | Y | Y | Scored best practices report | | **Widget Discovery** | `show widgets [in module] [where ...]` | Y | Y | Experimental | | **Widget Update** | `update widgets set ... where ...` | Y | Y | Bulk pluggable widget updates | @@ -294,7 +296,6 @@ These types are not covered in `help.go` output: ### Missing Skills -- **Layouts** — Read-only, no skill needed - **Constants** — No dedicated skill ### Missing Tests @@ -303,7 +304,6 @@ These types are not covered in `help.go` output: ### Missing Examples -- **Layouts** — Read-only, no example needed - **Folders / MOVE** — No dedicated example file ### Missing REPL Autocomplete @@ -318,7 +318,6 @@ These types are not covered in `help.go` output: - **Constants** — No `mxcli syntax constant` topic - **Nanoflows** — No dedicated syntax topic (covered by microflow topic) -- **Layouts** — Read-only, no syntax topic - **Modules** — No dedicated syntax topic ### Missing Starlark APIs @@ -368,8 +367,6 @@ Document types that exist in Mendix and have **no** MDL surface at all. | Feature | Notes | |---------|-------| -| **Microflow rules** (`Microflows$Rule`) | Reusable decision logic called from a microflow. Not to be confused with `CREATE VALIDATION RULE`, which is an attribute constraint and *is* supported | -| **Message definitions** (`MessageDefinitions$MessageDefinitionCollection`) | Message definition documents | | **XML schemas** | Imported XSD documents | | **Web service publish / consume** | SOAP. `CALL WEB SERVICE` exists in microflows for a stored service; the service documents themselves are not authorable | | **Data importer** | Excel/CSV import documents | diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 67ca255ca..b9606b4dc 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1867,7 +1867,7 @@ Name the widget the way you write it in a page body. The target is stored as the | Execute script | `mxcli exec script.mdl -p app.mpr` | Script file | | 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]` | 15 built-in + 27 Starlark rules | +| 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 | | 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 | From b75d93cfea11a6ecb4b0579c60ebd18cce73dcf6 Mon Sep 17 00:00:00 2001 From: Ako Date: Fri, 25 Sep 2026 12:49:07 +0000 Subject: [PATCH 7/7] docs(review): record the unbumped catalog-schema-version pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of mendixlabs/mxcli#1181. A column added to createTables without bumping CatalogSchemaVersion: the version guard only drops tables when the version CHANGES, and CREATE TABLE IF NOT EXISTS never adds a column, so every user with a cached catalog keeps a table the new SELECT cannot read. Measured: activities_for yielded 302 activities on main and 0 on the branch against the same cache file, with `no such column: UseRequestTimeout` and `mxcli lint` exiting 1. `mxcli report` runs the same rules through the same LintContext and never calls QueryErrors(), so there the same stale cache scores the project silently. The canonical fix carries the reproduction, because the obvious attempt does not reproduce it: the cache has to actually be REUSED. Building it with one `-p` spelling and running the new binary with another invalidates on "MPR path changed" and rebuilds, and a default lint run builds a fast catalog with zero activities — either way the run is green and the bug invisible. Confirm the output says "Loading cached catalog … (from cache)" first. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/mxcli-dev/review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/commands/mxcli-dev/review.md b/.claude/commands/mxcli-dev/review.md index 1c647739c..0d51e8944 100644 --- a/.claude/commands/mxcli-dev/review.md +++ b/.claude/commands/mxcli-dev/review.md @@ -61,6 +61,7 @@ proactively. Add a row after every review that surfaces something new. | 30 | Two commands compute the same thing from two copies of the setup (`report` re-implementing `lint`'s rule list and skipping its config), so they disagree about a project — and a SCORE carries no provenance, so neither number looks wrong | Code correctness | Extract the shared setup and route both through it. A value test cannot guard this when the copies live inside cobra `RunE` bodies: use a structural check on the source, with a positive control asserted FIRST so it cannot pass vacuously | | 31 | A test helper that needs a heavyweight object only to satisfy a signature (`NewLintContext(nil, nil)`, which panics) invites a nil-guard added purely to make the test compile — behaviour nothing in production needs, defended forever | Test coverage | Narrow the signature instead: if the helper does not use the parameter, drop it and let the caller apply the part it owns. A test that cannot construct an argument is usually telling you the argument does not belong | | 32 | A fix adds a diagnostic for a capability the model lacks while leaving in place the code that asserts the capability EXISTS — MDL042 telling the author a loop's `@caption` is dropped, while `cmd_microflows_builder_annotations.go` still ran `case *microflows.LoopedActivity: activity.Caption = ann.Caption` under the comment "LOOP / WHILE activities can carry a caption just like splits", and the describer still emitted one. Nothing read either back. The next reader trusts the code over the warning, deletes the check, and reopens the bug from the other side. The reason it survives is that it usually has TESTS — three here asserted the caption was carried, all of them against the semantic object and none against storage, so they passed throughout and failed only on the correct fix | Code correctness | `generated/metamodel` is the arbiter: a field on the semantic type it does not declare cannot survive a write, so an assignment to it is dead by construction. Grep the writer, the describer and the semantic struct and delete (or re-comment) whatever sets it. MEASURE before deleting — `exec` then `describe` on a real project, with the UNMODIFIED build, so the deletion rests on the stored document rather than on reading the codec. Invert the tests that defended it rather than deleting them, keeping any half still true (escaping coverage belongs on a type that can carry a caption), and check the inverted test fails when the assignment is put back | +| 33 | A column added to `createTables` without bumping `CatalogSchemaVersion` — the version guard only drops tables when it CHANGES, and `CREATE TABLE IF NOT EXISTS` never adds a column, so every user with a cached catalog keeps a table the new SELECT cannot read. Measured on #1181: `activities_for` yielded 302 on `main` and **0** on the branch against the same cache, `no such column: UseRequestTimeout`, `mxcli lint` exit 1. `mxcli report` runs the same rules and never checks `QueryErrors()`, so there it would score silently | Code correctness | Bump the constant in the same commit — its doc comment says so and `62913741` is the precedent (four columns + 11→12 + a builder test). To REPRODUCE, the cache must actually be reused: build it with the old binary and run the new one with the **same spelling of `-p`**, because a relative-vs-absolute path invalidates on "MPR path changed" and hides the bug; confirm the run says "Loading cached catalog … (from cache)" before believing a green result | ---