Writing Custom Starlark Lint Rules

SkillAI & models

Lets your agent write claude skill lint rules in Starlark that enforce your project's conventions automatically.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Writing Custom Starlark Lint Rules skill

About this capability

Write custom Starlark lint rules in .claude/lint-rules/ that run beside the built-ins under `mxcli lint`. Use when a project convention should be enforced automatically.

What this skill tells your AI

The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/write-lint-rules/SKILL.md and read by ahel’s review.

Custom lint rules are written in Starlark (a Python-like language) and placed in .claude/lint-rules/ as .star files. They run alongside the built-in rules when mxcli lint -p app.mpr is executed.

Rule File Structure

Every .star file must define metadata constants and a check() function:

RULE_ID = "CUSTOM001"          # unique identifier
RULE_NAME = "MyRule"           # Short display name
description = "What it checks" # One-line description
CATEGORY = "security"          # Category: naming, quality, design, security, etc.
SEVERITY = "warning"           # hint, info, warning, error

def check():
    violations = []
    # ... iterate data, find issues, append violations ...
    return violations

Catalog data requirements (refs_to, cycles, …)

Some builtins need a deeper catalog than the default fast build:

  • refs_to / refs_from need REFRESH CATALOG FULL (the refs table).
  • The graph-analysis builtins (cycles, module_dependencies, community_of, layer_of, centrality, god_nodes, integration_surface) need REFRESH CATALOG COMMUNITIES (the graph_* tables).

You don't have to do anything: mxcli lint (and the LINT statement) auto-detect these builtins in your rule's source and build the catalog at the required depth automatically. If a helper hides the call from the source scan, or you want to be explicit, declare it:

REQUIRES = ["full"]          # or ["communities"] — raises the auto-detected depth

Without this, a rule that queries refs/graph_* under a fast build would silently return empty results (issue #721).

Available Query Functions

FunctionReturnsDescription
entities()list of entityAll non-system entities
microflows()list of microflowAll non-system microflows
pages()list of pageAll non-system pages
enumerations()list of enumerationAll non-system enumerations
constants()list of constantAll non-system constants
widgets()list of widgetAll non-system widgets
snippets()list of snippetAll non-system snippets
scheduled_events()list of scheduled_eventAll non-system scheduled events (requires MPR reader)
rest_clients()list of rest_clientConsumed REST service documents (excluding platform modules)
rest_operations()list of rest_operationOperations on consumed REST services, including their timeout
attributes_for(entity_qualified_name)list of attributeAttributes for a specific entity
activities_for(microflow_qualified_name)list of activityActivities for a microflow (requires FULL catalog)
permissions()list of permissionAll permissions across all element types
permissions_for(entity_qualified_name)list of permissionAccess rules for a specific entity
refs_to(target_name)list of referenceCross-references to a target
refs_from(source_name)list of referenceCross-references from a source (outbound)
user_roles()list of user_roleUser roles from project security
module_roles()list of module_roleAll module roles (deduplicated from role mappings)
role_mappings()list of role_mappingUser role to module role assignments
project_security()project_security or NoneProject-level security settings (requires MPR reader)
xpath_expressions()list of xpath_expressionAll XPath constraint expressions in the catalog (access rules, retrieve actions, widgets)

Graph-analysis functions (architecture rules)

These expose the dependency-graph facts so you can enforce your own architecture policy (layering, allowed module dependencies, no cycles, coupling budgets). They require refresh catalog communities to have populated the graph tables; otherwise they return empty/None (the rule degrades gracefully — it does not fail). In a session, run refresh catalog communities before lint.

FunctionReturnsDescription
layer_of(asset)int or NoneTopological layer sequence number (no opinion on ordering)
community_of(asset)struct{id, label} or NoneThe asset's detected community (bounded context)
cycles()list of struct{id, size, members}Dependency cycles (SCCs > 1 node)
module_dependencies()list of struct{source_module, target_module, ref_kind, edges}Directed module→module edges
centrality(asset)struct{in, out, total, pagerank, betweenness} or NoneCentrality of an asset
god_nodes(metric="degree"|"pagerank"|"betweenness", min=N)list of struct{asset, object_type, module_name, degree, pagerank, betweenness}High-centrality assets above a threshold
integration_surface()list of struct{source_community, target_community, ref_kind, edges, mechanism}Cross-community contract edges (for app-splitting)

Example — a team enforcing its own strict layering (mxcli ships no such rule):

RULE_ID = "ARCH900"
RULE_NAME = "Layering"
DESCRIPTION = "A module may only depend on lower or equal layers"
CATEGORY = "architecture"
SEVERITY = "error"

def check():
    out = []
    for d in module_dependencies():
        if d.ref_kind in ("layout", "show_page"):  # ignore UI navigation
            continue
        ls, lt = layer_of(d.source_module + ".x"), layer_of(d.target_module + ".x")
        # (resolve a real asset per module in practice; shown simplified)
        if ls != None and lt != None and ls < lt:
            out.append(violation(message = "%s depends upward on %s" % (d.source_module, d.target_module)))
    return out

Another team bans a specific dependency:

def check():
    return [violation(message = "Payments must not depend on Reporting")
            for d in module_dependencies()
            if d.source_module == "Payments" and d.target_module == "Reporting"]

Object Properties

The example values below are the real ones — do not adapt their case or their spelling. A filter on a value the catalog never emits is silent: the rule compiles, runs, matches nothing and reports a clean pass. Two traps in particular:

  • Case is not cosmetic. Document and element kinds are upper-case ("MICROFLOW", "ENTITY", "READ"), attribute data types are TitleCase ("String", "DateTime"), and ref_kind is lower-case ("call", "show_page"). Guessing wrong matches zero rows.
  • action_type is the SDK name, never Mendix's BSON storage name. The catalog reports ShowPageAction / ClosePageAction / CreateObjectAction / CommitObjectsAction; the storage names ShowFormAction, CloseFormAction, CreateChangeAction and CommitAction that appear in .mpr documents never reach a rule. A rule that allow-lists the storage names flags every microflow that opens a page — the inversion measured at 49% false positives in mendixlabs/mxcli#1027.

To check a value against your own project rather than trusting any list:

sqlite3 .mxcli/catalog.db "SELECT DISTINCT ActionType FROM activities;"
sqlite3 .mxcli/catalog.db "SELECT DISTINCT SourceType, TargetType, RefKind FROM refs;"

Absence from your project means the construct is not used there; a value absent from the tables below is one the catalog never produces anywhere.

entity

PropertyTypeExample
idstringDocument UUID
namestring"Customer"
qualified_namestring"Sales.Customer"
module_namestring"Sales"
folderstring"DomainModel" — folder path within module
entity_typestring"persistent", "NonPersistent", "view"
descriptionstringDocumentation text
generalizationstringParent entity qualified name
attribute_countintNumber of attributes
access_rule_countintNumber of access rules
validation_rule_countintNumber of validation rules
has_event_handlersboolTrue if entity has event handlers
is_externalboolTrue if entity is from an external service

microflow

PropertyTypeExample
idstringDocument UUID
namestring"ACT_Customer_Create"
qualified_namestring"Sales.ACT_Customer_Create"
module_namestring"Sales"
folderstring"microflows/Customer" — folder path within module
microflow_typestring"microflow" or "nanoflow"
descriptionstringDocumentation text
return_typestringReturn type
parameter_countintNumber of parameters
activity_countintNumber of activities
complexityintMcCabe cyclomatic complexity

page

PropertyTypeExample
idstringDocument UUID
namestring"Customer_Overview"
qualified_namestring"Sales.Customer_Overview"
module_namestring"Sales"
folderstring"pages/Customer" — folder path within module
titlestringPage title
urlstringPage URL
descriptionstringDocumentation text
widget_countintNumber of widgets

enumeration

PropertyTypeExample
idstringDocument UUID
namestring"OrderStatus"
qualified_namestring"Sales.OrderStatus"
module_namestring"Sales"
folderstring"enumerations" — folder path within module
descriptionstringDocumentation text
value_countintNumber of enum values

constant

PropertyTypeExample
idstringDocument UUID
namestring"AppBaseUrl"
qualified_namestring"MyModule.AppBaseUrl"
module_namestring"MyModule"
folderstring"constants" — folder path within module
descriptionstringDocumentation text
default_valuestring"https://example.com"
exposed_to_clientbooltrue if constant is exposed to client

widget

PropertyTypeExample
idstringWidget UUID
namestringWidget name
widget_typestring"dataview", "listview", etc.
container_idstringContainer UUID
container_qualified_namestring"Sales.Customer_Overview"
container_typestring"page" or "snippet"
module_namestring"Sales"
entity_refstringReferenced entity qualified name
attribute_refstringReferenced attribute path
microflow_refstringAction/datasource microflow qualified name (e.g. a microflow-datasource ListView), else ""
nanoflow_refstringAction/datasource nanoflow qualified name, else ""

snippet

PropertyTypeExample
idstringDocument UUID
namestring"SNIPPET_CustomerCard"
qualified_namestring"Sales.SNIPPET_CustomerCard"
module_namestring"Sales"
folderstring"snippets" — folder path within module
widget_countintNumber of widgets

scheduled_event

PropertyTypeExample
namestring"SE_NightlyCleanup"
qualified_namestring"MyModule.SE_NightlyCleanup"
module_namestring"MyModule"
microflow_namestring"MyModule.MF_NightlyCleanup" — resolved from catalog; raw UUID when catalog not built
interval_secondsint86400 — 0 for unrecognised interval type
enabledboolTrue if the event is active

xpath_expression

Returned by xpath_expressions(). Each row represents one XPath constraint used in a retrieve action, access rule, or widget data source.

PropertyTypeExample
idstringRow UUID
document_typestring"MICROFLOW", "NANOFLOW", "DOMAIN_MODEL", "PAGE", "SNIPPET"
document_idstringOwning document UUID
document_qualified_namestring"MyApp.GetActiveItems"
component_typestring"RETRIEVE_ACTION", "ACCESS_RULE", "WIDGET"
component_idstringComponent UUID
component_namestringActivity/rule name (may be empty)
xpath_expressionstringRaw XPath string, may include outer [ ]
target_entitystringQualified name of entity being queried, e.g. "MyApp.Order"
referenced_entitiesstringComma-separated qualified names of entities referenced by the XPath
is_parameterizedboolTrue when the XPath contains $variable references
usage_typestring"RETRIEVE", "SECURITY", "DATASOURCE"
module_namestring"MyApp"

expr

Returned by parse_xpath(s). Every node has a kind field; additional fields depend on the kind.

kindAdditional fieldsDescription
"bin"op (string), left (expr), right (expr)Binary operator: =, !=, <, >, <=, >=, and, or
"unary"op (string), operand (expr)Unary operator: not, -
"call"name (string), args (list of expr)Function call, e.g. contains(…), length(…)
"string"value (string)String literal
"number"value (string)Numeric literal (kept as string to preserve precision)
"bool"value (bool)true or false
"empty"—Mendix empty keyword
"variable"name (string)$ParameterName
"attr_path"variable (string), path (list of string)$Obj/Association/Attribute
"qname"module (string), name (string), sub (string)Qualified name, e.g. MyApp.Status.Active
"paren"inner (expr)Parenthesised expression
"if"cond (expr), then (expr), else_ (expr)If-then-else expression
"constant"qname (string)Mendix constant reference, e.g. [%MyConst%]
"token"token (string), arg (string)Mendix token expression, e.g. [%CurrentUser%]
"recovered"source (string), reason (string)Parse failure — node carries the raw source fragment
"null"—Nil / missing node
"unknown"—Unrecognised AST node type

Walking an expr tree: check node.kind and recurse into child fields. Leaf kinds (no child nodes) are: string, number, bool, empty, variable, qname, constant, token, recovered, null, unknown.

Example — count not(…) calls in an XPath (using parse_xpath):

def count_not(node):
    if node.kind in ("null", "unknown", "recovered", "string", "number",
                     "bool", "empty", "variable", "qname", "constant", "token"):
        return 0
    if node.kind == "call" and node.name == "not":
        return 1 + sum([count_not(a) for a in node.args])
    if node.kind == "call":
        return sum([count_not(a) for a in node.args])
    if node.kind == "bin":
        return count_not(node.left) + count_not(node.right)
    if node.kind == "unary":
        return count_not(node.operand)
    if node.kind == "paren":
        return count_not(node.inner)
    if node.kind == "if":
        return count_not(node.cond) + count_not(node.then) + count_not(node.else_)
    if node.kind == "attr_path":
        return 0
    return 0

attribute

PropertyTypeExample
idstringAttribute UUID
namestring"Name"
entity_idstringParent entity UUID
entity_qualified_namestring"Sales.Customer"
module_namestring"Sales"
data_typestring"String", "Integer", "Long", "Decimal", "Boolean", "DateTime", "Date", "Enumeration", "AutoNumber", "Binary", "HashedString"
lengthintField length (for strings)
is_uniqueboolHas unique constraint
is_requiredboolIs required
default_valuestringDefault value
is_calculatedboolTrue if attribute is calculated (virtual)
descriptionstringDocumentation text

activity

PropertyTypeExample
idstringActivity UUID
namestringActivity name
captionstringActivity caption
activity_typestring"ActionActivity", "ExclusiveSplit", "ExclusiveMerge", "LoopedActivity", "InheritanceSplit", "StartEvent", "EndEvent"
action_typestringThe action inside an ActionActivity: "CreateObjectAction", "ChangeObjectAction", "CommitObjectsAction", "DeleteObjectAction", "RetrieveAction", "MicroflowCallAction", "ShowPageAction", "ClosePageAction", "LogMessageAction", "JavaActionCallAction". Empty for an activity that is not an action
microflow_idstringParent microflow UUID
microflow_qualified_namestring"Sales.ACT_Customer_Create"
module_namestring"Sales"
entity_refstringReferenced entity qualified name
service_refstringCalled service document (REST / web service / OData client); empty when the activity calls none
action_refstringOperation or action within that service; empty when the activity calls none

rest_client

PropertyTypeExample
idstringDocument UUID
namestring"CustomerApi"
qualified_namestring"Sales.CustomerApi"
module_namestring"Sales"
folderstringFolder path within module
base_urlstring"https://api.example.com/v1"
auth_schemestringAuthentication scheme, empty when none
operation_countintNumber of operations on the service
documentationstringDocumentation text

rest_operation

PropertyTypeExample
idstringOperation UUID
service_idstringOwning service UUID
service_qualified_namestring"Sales.CustomerApi"
namestring"GetCustomer"
http_methodstring"GET", "POST", …
pathstring"/customers/{id}"
parameter_countintNumber of parameters
has_bodyboolTrue when the request carries a body
response_typestringResponse type name
timeoutintConfigured timeout in milliseconds; 0 when none is set
module_namestring"Sales"

permission

Returned by permissions() (all types) or permissions_for() (entity-specific).

PropertyTypeExample
module_role_namestring"Admin"
element_typestring"ENTITY", "MICROFLOW", "PAGE", "ODATA_SERVICE" (from permissions() only)
element_namestring"Sales.Customer"
module_namestring"Sales"
entity_namestring"Sales.Customer" (from permissions_for() only)
access_typestring"CREATE", "READ", "WRITE", "DELETE" (entity), "EXECUTE" (microflow), "VIEW" (page), "ACCESS" (OData service), "MEMBER_READ", "MEMBER_WRITE"
member_namestringAttribute name (for MEMBER_READ/MEMBER_WRITE)
xpath_constraintstringXPath constraint or empty
is_constrainedboolTrue if XPath constraint is set

user_role

PropertyTypeExample
namestring"Administrator"
is_anonymousboolTrue if this is the anonymous/guest role
module_roleslist of string["Sales.Admin", "HR.Viewer"]

module_role

PropertyTypeExample
namestring"Sales.Admin" — qualified module role name
module_namestring"Sales"
descriptionstringModule role description

role_mapping

PropertyTypeExample
user_role_namestring"Administrator"
module_role_namestring"Sales.Admin"
module_namestring"Sales"

reference

PropertyTypeExample
source_typestringThe document the edge comes FROM, upper-case: "MICROFLOW", "NANOFLOW", "RULE", "PAGE", "SNIPPET", "ENTITY", "ASSOCIATION", "WORKFLOW", "NAVIGATION", "SCHEDULED_EVENT", "PUBLISHED_REST_OPERATION", "PROJECT_SETTINGS"
source_idstringSource UUID
source_namestring"Sales.ACT_Customer_Create"
target_typestringWhat it points AT, upper-case: "ENTITY", "ASSOCIATION", "MICROFLOW", "NANOFLOW", "RULE", "PAGE", "LAYOUT", "WORKFLOW", "WIDGET", "JAVA_ACTION", "REST_OPERATION", "REGULAR_EXPRESSION". LAYOUT and WIDGET are only ever targets; SCHEDULED_EVENT and PROJECT_SETTINGS only ever sources
target_idstringTarget UUID
target_namestring"Sales.Customer"
ref_kindstringHow it references: "call", "create", "retrieve", "change", "delete", "show_page", "datasource", "action", "layout", "parameter", "return", "generalize", "associate", "home_page", "login_page", "menu_item", "calculate", "schedule", "validate", "settings", "widget", "sync", "publish", "event" — lower-case, unlike the types above
module_namestringSource module

project_security

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

PropertyTypeDescription
security_levelstring"CheckNothing" (Off), "CheckFormsAndMicroflows" (Prototype), "CheckEverything" (Production)
enable_demo_usersboolWhether demo users are enabled
enable_guest_accessboolWhether anonymous/guest access is enabled
check_securityboolWhether security checking is active
strict_modeboolStrict security mode
password_policystructNested password policy settings
password_policy (nested in project_security)
PropertyTypeDescription
min_lengthintMinimum password length
require_digitboolMust contain a digit
require_mixed_caseboolMust contain upper and lower case
require_symbolboolMust contain a symbol

Helper Functions

FunctionDescription
violation(message, location?, suggestion?)Create a violation to return
location(module, document_type, document_name, document_id?)Create a location for a violation
parse_xpath(s)Parse a raw XPath/expression string and return its AST as an expr struct tree. Outer [ ] are stripped automatically. Parse failures produce a recovered root node rather than raising.
is_pascal_case(s)Returns True if string is PascalCase
is_camel_case(s)Returns True if string is camelCase
matches(s, pattern)Returns True if string matches regex

Common Patterns

Pattern 1: Iterate entities and check a property

RULE_ID = "SEC001"
RULE_NAME = "NoEntityAccessRules"
description = "persistent entities should have access rules"
CATEGORY = "security"
SEVERITY = "warning"

def check():
    violations = []
    for e in entities():
        if e.entity_type == "persistent" and not e.is_external and e.access_rule_count == 0:
            violations.append(violation(
                message="persistent entity '{}' has no access rules".format(e.qualified_name),
                location=location(module=e.module_name, document_type="entity", document_name=e.name),
                suggestion="grant <role> on {} (read *)".format(e.qualified_name),
            ))
    return violations

Pattern 2: Check project-level security settings

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
122
Forks
49
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
write-lint-rules
Source
github.com/mendixlabs/mxcli