Skip to content
Back to skills

Write Lint Rules

ASecurity

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.

  • 128 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 26, 2026
ai-agentspythonrustgojavabashsqlnodeexpressgitapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 4, 2026

npx -y skills add mendixlabs/mxcli --skill write-lint-rules --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Write Lint Rules?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Write Lint Rules
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/mendixlabs-write-lint-rules/badge)](https://www.skillsdirectory.com/skills/mendixlabs-write-lint-rules)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: write-lint-rules
description: "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."
---

# Writing Custom Starlark Lint Rules

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:

```python
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 (`widgets`, `refs_to`, `cycles`, …)

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

- `refs_to`, `refs_from`, `widgets`, `xpath_expressions`, `activities_for`,
  `permissions`, `permissions_for`, `strings` and the `widget_count` field of a
  page or snippet need **`REFRESH CATALOG FULL`** — the `refs`, `widgets`,
  `xpath_expressions`, `activities`, `permissions` and `strings` tables and the
  widget counts are only written by a full build.
- 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 (a call `name(`) and `.widget_count` in your
rule's source and build the catalog at the required depth automatically. If the
scan cannot see it — e.g. `getattr(p, "widget_count")`, or a builtin passed
around by name — or you want to be explicit, declare it:

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

Without this, a rule that reads a full-only table under a fast build gets
`[]` / `0` with no warning and reports a clean pass (issue #721).

## Available Query Functions

| Function | Returns | Description |
|----------|---------|-------------|
| `entities()` | list of entity | All non-system entities |
| `microflows()` | list of microflow | All non-system microflows, nanoflows **and rules** — they share one catalog table. Name the document with `document_noun_title`, never a hardcoded `"Microflow"` |
| `pages()` | list of page | All non-system pages |
| `enumerations()` | list of enumeration | All non-system enumerations |
| `constants()` | list of constant | All non-system constants |
| `widgets()` | list of widget | All non-system page and snippet widgets (full catalog — auto-detected) |
| `snippets()` | list of snippet | All non-system snippets |
| `scheduled_events()` | list of scheduled_event | All non-system scheduled events (requires MPR reader) |
| `queues()` | list of queue | All non-system task queues |
| `java_actions()` | list of java_action | All non-system, non-marketplace Java actions, each carrying its parameters |
| `database_connections()` | list of database_connection | All non-system external database connections |
| `documents()` | list of document | Every App Explorer document outside System and Marketplace modules, as one uniform projection with `folder` — for rules about where a document *lives* |
| `documentable_elements()` | list of documentable | Every element that can carry documentation, across all document types, with its `description` — for documentation sweeps. Leaves out microflows and Java actions; use `microflows()` / `java_actions()` for those |
| `navigation_targets()` | list of navigation_target | Every page a navigation profile routes to: profile home pages, role home pages and menu items. Login and not-found pages are excluded |
| `rest_clients()` | list of rest_client | Consumed REST service documents (excluding platform modules) |
| `rest_operations()` | list of rest_operation | Operations on consumed REST services, including their `timeout` |
| `attributes_for(entity_qualified_name)` | list of attribute | Attributes for a specific entity |
| `activities_for(microflow_qualified_name, nested = False)` | list of activity | Activities of a microflow, nanoflow or rule, in flow order (full catalog — auto-detected). By default only the top level: a loop is one activity and its body is left out. `nested = True` adds every object inside a loop, at any depth, right after its loop, with `parent_loop_id` and `loop_depth` set |
| `permissions()` | list of permission | All permissions across all element types (full catalog — auto-detected) |
| `permissions_for(entity_qualified_name)` | list of permission | Access rules for a specific entity (full catalog — auto-detected) |
| `refs_to(target_name)` | list of reference | Cross-references *to* a target (full catalog — auto-detected) |
| `refs_from(source_name)` | list of reference | Cross-references *from* a source (outbound) (full catalog — auto-detected) |
| `user_roles()` | list of user_role | User roles from project security |
| `module_roles()` | list of module_role | All module roles (deduplicated from role mappings) |
| `role_mappings()` | list of role_mapping | User role to module role assignments |
| `project_security()` | project_security or None | Project-level security settings (requires MPR reader) |
| `xpath_expressions()` | list of xpath_expression | All XPath constraint expressions in the catalog (access rules, retrieve actions, widgets) (full catalog — auto-detected) |
| `modules()` | list of module | The user's modules (not System, not Marketplace), with their domain model's documentation |
| `associations()` | list of association | All non-system associations, same-module and cross-module, with the delete behaviour of both ends |
| `entity_event_handlers()` | list of entity_event_handler | Every before/after event handler on a non-system entity: which moment, which event, which microflow |
| `navigation_menu_items()` | list of navigation_menu_item | Every navigation menu item of every profile, at every depth. Navigation belongs to the project, so no module filter applies |
| `jar_dependencies()` | list of jar_dependency | Maven dependencies declared by non-system, non-marketplace modules |
| `strings(language = None)` | list of catalog_string | User-facing and documentary text, one row per text and language; pass `language` (`"nl_NL"`) to narrow. An untranslated language has **no row**. Needs a FULL catalog, which `mxcli lint` builds automatically for a rule that calls it |
| `layouts()` | list of layout | All non-system layouts |
| `published_rest_operations()` | list of published_rest_operation | Operations of published REST services, with the microflow behind each |

The fields of `module`, `association`, `entity_event_handler`,
`navigation_menu_item`, `jar_dependency`, `catalog_string`, `layout` and
`published_rest_operation` are in [catalog-tables.md](catalog-tables.md).

### 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`.

| Function | Returns | Description |
|----------|---------|-------------|
| `layer_of(asset)` | int or None | Topological layer sequence number (no opinion on ordering) |
| `community_of(asset)` | struct{id, label} or None | The asset's detected community (bounded context) |
| `cycles()` | list of struct{id, size, members} | Dependency cycles (SCCs > 1 node) |
| `module_cycles()` | list of struct{id, size, members} | Module-level dependency cycles over every reference kind; `members` are module names. Use this, not `cycles()`, for "no circular module dependencies" — modules can reference each other through documents that form no asset-level cycle |
| `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 None | Centrality 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):

```python
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:

```python
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. The one exception is **`widget.action_type`**, which
>   is the raw stored type of a page action (`"Forms$DeleteClientAction"`) —
>   page actions have no SDK-name mapping in the catalog.
>
> To check a value against your own project rather than trusting any list:
>
> ```bash
> 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
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"Customer"` |
| `qualified_name` | string | `"Sales.Customer"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | `"DomainModel"` — folder path within module |
| `entity_type` | string | exactly `"Persistent"`, `"NonPersistent"` or `"View"` — any other spelling matches nothing and the rule silently reports nothing |
| `description` | string | Documentation text |
| `generalization` | string | Parent entity qualified name |
| `attribute_count` | int | Number of attributes |
| `access_rule_count` | int | Number of access rules |
| `validation_rule_count` | int | Number of validation rules |
| `has_event_handlers` | bool | True if entity has event handlers |
| `is_external` | bool | True if entity is from an external service |
| `has_created_date` | bool | True if the entity stores `createdDate` (an audit member, not counted in `attribute_count`) |
| `has_changed_date` | bool | True if the entity stores `changedDate` |
| `has_owner` | bool | True if the entity stores `owner` |
| `has_changed_by` | bool | True if the entity stores `changedBy` |

### microflow
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"ACT_Customer_Create"` |
| `qualified_name` | string | `"Sales.ACT_Customer_Create"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | `"microflows/Customer"` — folder path within module |
| `microflow_type` | string | exactly `"MICROFLOW"`, `"NANOFLOW"` or `"RULE"` — upper-case, unlike `entity_type`. `microflows()` yields all three flavours, so a rule meant for microflows only must filter on `"MICROFLOW"` |
| `description` | string | Documentation text |
| `return_type` | string | Return type |
| `parameter_count` | int | Number of parameters |
| `activity_count` | int | Number of activities at the top level of the flow, excluding start/end events and merges. A loop counts as one; its body is not counted |
| `total_activity_count` | int | `activity_count` plus every activity inside a loop, at any depth — the size of the flow including loop bodies. Equal to `activity_count` for a flow without loops |
| `complexity` | int | McCabe cyclomatic complexity |
| `document_noun` | string | `"microflow"`, `"nanoflow"` or `"rule"` — for mid-sentence use in a message |
| `document_noun_title` | string | `"Microflow"`, `"Nanoflow"` or `"Rule"` — for `document_type=` and a message that opens with it |

### page
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"Customer_Overview"` |
| `qualified_name` | string | `"Sales.Customer_Overview"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | `"pages/Customer"` — folder path within module |
| `title` | string | Page title in the project's default language (else en_US, else the lowest-sorted non-empty language); `""` when the page has none |
| `url` | string | Page URL |
| `description` | string | Documentation text |
| `widget_count` | int | Number of widgets (full catalog — auto-detected) |

### enumeration
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"OrderStatus"` |
| `qualified_name` | string | `"Sales.OrderStatus"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | `"enumerations"` — folder path within module |
| `description` | string | Documentation text |
| `value_count` | int | Number of enum values |

### constant
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"AppBaseUrl"` |
| `qualified_name` | string | `"MyModule.AppBaseUrl"` |
| `module_name` | string | `"MyModule"` |
| `folder` | string | `"constants"` — folder path within module |
| `description` | string | Documentation text |
| `default_value` | string | `"https://example.com"` |
| `exposed_to_client` | bool | `true` if constant is exposed to client |

**widget** — the struct returned by `widgets()` — identity, references, tree
position (`parent_widget_id`, `depth`), appearance (`class_name`, `style`) and
primary action (`action_type`, `has_confirmation`) — is documented in
[catalog-tables.md](catalog-tables.md#widget), with an example rule.

### snippet
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"SNIPPET_CustomerCard"` |
| `qualified_name` | string | `"Sales.SNIPPET_CustomerCard"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | `"snippets"` — folder path within module |
| `widget_count` | int | Number of widgets (full catalog — auto-detected) |

### scheduled_event
| Property | Type | Example |
|----------|------|---------|
| `name` | string | `"SE_NightlyCleanup"` |
| `qualified_name` | string | `"MyModule.SE_NightlyCleanup"` |
| `module_name` | string | `"MyModule"` |
| `microflow_name` | string | `"MyModule.MF_NightlyCleanup"` — resolved from catalog; raw UUID when catalog not built |
| `interval_seconds` | int | `86400` — `0` for unrecognised interval type |
| `repeat` | string | Schedule variant: `"Minute"`, `"Hour"`, `"Day"`, `"Week"`, `"MonthDate"`, `"MonthWeekday"`, `"YearDate"` or `"YearWeekday"`; `""` when the event has no schedule |
| `on_overlap` | string | `"DelayNext"` or `"SkipNext"` — what happens when a run is still going at the next start |
| `time_zone` | string | Time zone the schedule is evaluated in |
| `enabled` | bool | `True` if the event is active |

### queue
| Property | Type | Example |
|----------|------|---------|
| `name` | string | `"ImportQueue"` |
| `qualified_name` | string | `"Sales.ImportQueue"` |
| `module_name` | string | `"Sales"` |
| `parallelism` | string | `"3"` — an **expression**, stored as a string; do not assume it parses as an integer |
| `cluster_wide` | bool | `True` if parallelism applies across the cluster rather than per node |

### java_action
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"JA_ParseJson"` |
| `qualified_name` | string | `"Sales.JA_ParseJson"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | Folder path within module |
| `documentation` | string | Documentation text |
| `description` | string | Same as `documentation`, so a rule sweeping mixed document kinds can read one field name |
| `export_level` | string | `"Hidden"` or `"API"` |
| `return_type` | string | Return type |
| `parameter_count` | int | Number of parameters |
| `parameters` | list of java_action_parameter | The action's parameters, in order |

#### java_action_parameter (nested in java_action)
| Property | Type | Example |
|----------|------|---------|
| `name` | string | `"InputString"` |
| `description` | string | Parameter documentation |
| `parameter_type` | string | Parameter type |
| `is_required` | bool | `True` if the parameter is required |

### database_connection
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"LegacyDB"` |
| `qualified_name` | string | `"Integration.LegacyDB"` |
| `module_name` | string | `"Integration"` |
| `folder` | string | Folder path within module |
| `database_type` | string | Database engine of the connection |
| `query_count` | int | Number of queries defined on the connection |

### document
Returned by `documents()`.

| Property | Type | Example |
|----------|------|---------|
| `kind` | string | Catalog object type, upper-case: `"MICROFLOW"`, `"PAGE"`, `"WORKFLOW"`, … |
| `name` | string | `"Customer_Overview"` |
| `qualified_name` | string | `"Sales.Customer_Overview"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | Folder path within module; `""` means directly in the module root |

### documentable
Returned by `documentable_elements()`.

| Property | Type | Example |
|----------|------|---------|
| `kind` | string | Mendix term, TitleCase: `"Page"`, `"Enumeration"`, `"Workflow"`, … |
| `name` | string | `"OrderStatus"` |
| `qualified_name` | string | `"Sales.OrderStatus"` |
| `module_name` | string | `"Sales"` |
| `description` | string | Documentation text, whichever of the element's Documentation/Description properties holds it |

### navigation_target
Returned by `navigation_targets()`.

| Property | Type | Example |
|----------|------|---------|
| `profile` | string | Navigation profile: `"Responsive"`, `"Phone"`, `"Tablet"`, … |
| `kind` | string | `"home"`, `"role_home"` or `"menu"` |
| `role` | string | User role, for a `"role_home"` target; `""` otherwise |
| `caption` | string | Menu item caption, for a `"menu"` target; `""` otherwise |
| `page` | string | Qualified name of the target page |

### xpath_expression

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

| Property | Type | Example |
|----------|------|---------|
| `id` | string | Row UUID |
| `document_type` | string | `"MICROFLOW"`, `"NANOFLOW"`, `"DOMAIN_MODEL"`, `"PAGE"`, `"SNIPPET"` |
| `document_id` | string | Owning document UUID |
| `document_qualified_name` | string | `"MyApp.GetActiveItems"` |
| `component_type` | string | `"RETRIEVE_ACTION"`, `"ACCESS_RULE"`, `"WIDGET"` |
| `component_id` | string | Component UUID |
| `component_name` | string | Activity/rule name (may be empty) |
| `xpath_expression` | string | Raw XPath string, may include outer `[ ]` |
| `target_entity` | string | Qualified name of entity being queried, e.g. `"MyApp.Order"` |
| `referenced_entities` | string | Comma-separated qualified names of entities referenced by the XPath |
| `is_parameterized` | bool | True when the XPath contains `$variable` references |
| `usage_type` | string | `"RETRIEVE"`, `"SECURITY"`, `"DATASOURCE"` |
| `module_name` | string | `"MyApp"` |

### expr

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

| `kind` | Additional fields | Description |
|--------|-------------------|-------------|
| `"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`):

```python
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
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Attribute UUID |
| `name` | string | `"Name"` |
| `entity_id` | string | Parent entity UUID |
| `entity_qualified_name` | string | `"Sales.Customer"` |
| `module_name` | string | `"Sales"` |
| `data_type` | string | `"String"`, `"Integer"`, `"Long"`, `"Decimal"`, `"Boolean"`, `"DateTime"`, `"Date"`, `"Enumeration"`, `"AutoNumber"`, `"Binary"`, `"HashedString"` |
| `length` | int | Field length (for strings) |
| `is_unique` | bool | Has unique constraint |
| `is_required` | bool | Is required |
| `default_value` | string | Default value |
| `is_calculated` | bool | True if attribute is calculated (virtual) |
| `description` | string | Documentation text |

### activity
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Activity UUID |
| `name` | string | The `action_type` for an action activity, otherwise the `activity_type` |
| `caption` | string | The stored caption: an activity's, a split's (`"Is amount big?"`), or an annotation's text. Empty for objects Mendix stores no caption for (start/end events, merges, loops). When `auto_generate_caption` is true this is Studio Pro's stored placeholder (typically `"Activity"`), not the caption Studio Pro shows |
| `auto_generate_caption` | bool | Action activity: whether Studio Pro generates the caption. False for other objects |
| `description` | string | The documentation of an action activity, split or loop |
| `activity_type` | string | `"ActionActivity"`, `"ExclusiveSplit"`, `"ExclusiveMerge"`, `"LoopedActivity"`, `"InheritanceSplit"`, `"StartEvent"`, `"EndEvent"`, `"Annotation"` |
| `action_type` | string | The action inside an `ActionActivity`: `"CreateObjectAction"`, `"ChangeObjectAction"`, `"CommitObjectsAction"`, `"DeleteObjectAction"`, `"RetrieveAction"`, `"MicroflowCallAction"`, `"ShowPageAction"`, `"ClosePageAction"`, `"LogMessageAction"`, `"JavaActionCallAction"`, `"RestCallAction"`, `"WebServiceCallAction"`. Empty for an activity that is not an action |
| `microflow_id` | string | Parent microflow UUID |
| `microflow_qualified_name` | string | `"Sales.ACT_Customer_Create"` |
| `module_name` | string | `"Sales"` |
| `entity_ref` | string | Entity qualified name, for a create object and a database retrieve |
| `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 or Call web service: whether "Use a timeout" is enabled. False for other action types |
| `timeout_expression` | string | Call REST service or Call web service: the timeout in seconds, stored as an expression, e.g. `"300"` |
| `parent_loop_id` | string | `id` of the loop the activity is inside; empty at the top level. Only set with `activities_for(…, nested = True)` |
| `loop_depth` | int | Number of loops around the activity: 0 at the top level, 1 directly inside a loop, 2 in a loop inside a loop |
| `condition_expression` | string | Exclusive split: the condition expression, e.g. `"$Order/Amount > 10"`. Empty for a rule-based split |
| `condition_rule` | string | Exclusive split calling a rule: the rule's qualified name, e.g. `"Sales.IsValidOrder"` |
| `error_handling_type` | string | The stored error handling of an action, loop or split: exactly `"Rollback"`, `"Custom"`, `"CustomWithoutRollBack"` (capital **B**), `"Continue"` or `"Abort"`. Empty for objects without error handling |
| `log_level` | string | Log message: exactly `"Trace"`, `"Debug"`, `"Info"`, `"Warning"`, `"Error"` or `"Critical"` |
| `log_node_expression` | string | Log message: the log node as stored, an expression — `"'MyNode'"` (a quoted string literal) or `"getKey(Sales.LogNodes.Orders)"` |
| `log_message` | string | Log message: the message template, e.g. `"Amount is {1}"` |
| `commit_type` | string | Create or change object: exactly `"Yes"`, `"YesWithoutEvents"` or `"No"`. Empty for other actions |
| `with_events` | bool | True for a commit action with events, and for a create or change object with `commit_type` `"Yes"` |
| `retrieve_source` | string | Retrieve: exactly `"database"` or `"association"`. For `"database"`, `entity_ref` is the retrieved entity |

### rest_client
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Document UUID |
| `name` | string | `"CustomerApi"` |
| `qualified_name` | string | `"Sales.CustomerApi"` |
| `module_name` | string | `"Sales"` |
| `folder` | string | Folder path within module |
| `base_url` | string | `"https://api.example.com/v1"` |
| `auth_scheme` | string | Authentication scheme, empty when none |
| `operation_count` | int | Number of operations on the service |
| `documentation` | string | Documentation text |

### rest_operation
| Property | Type | Example |
|----------|------|---------|
| `id` | string | Operation UUID |
| `service_id` | string | Owning service UUID |
| `service_qualified_name` | string | `"Sales.CustomerApi"` |
| `name` | string | `"GetCustomer"` |
| `http_method` | string | `"GET"`, `"POST"`, … |
| `path` | string | `"/customers/{id}"` |
| `parameter_count` | int | Number of parameters |
| `has_body` | bool | True when the request carries a body |
| `response_type` | string | Response type name |
| `timeout` | int | Configured timeout in milliseconds; `0` when none is set |
| `module_name` | string | `"Sales"` |

### permission

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

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

### user_role
| Property | Type | Example |
|----------|------|---------|
| `name` | string | `"Administrator"` |
| `is_anonymous` | bool | True if this is the anonymous/guest role |
| `module_roles` | list of string | `["Sales.Admin", "HR.Viewer"]` |

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

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

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

### project_security

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

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

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

## Helper Functions

| Function | Description |
|----------|-------------|
| `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 |
| `get_option(key, default?)` | The rule's option `key` from the `options:` block under its rule ID in `.claude/lint-config.yaml`, or `default` (`None` if omitted) when unset |
| `struct(**kwargs)` | Build an ad-hoc struct, e.g. `struct(name="x", count=1)`, to group values inside a rule |

## Common Patterns

### Pattern 1: Iterate entities and check a property

```python
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

```python
RULE_ID = "SEC002"
RULE_NAME = "WeakPasswordPolicy"
description = "password policy should require at least 8 characters"
CATEGORY = "security"
SEVERITY = "warning"

def check():
    sec = project_security()
    if sec == none:
        return []
    if sec.password_policy.min_length < 8:
        return [violation(
            message="password minimum length is {} (recommended: 8+)".format(sec.password_policy.min_length),
            location=location(module="", document_type="security", document_name="ProjectSecurity"),
            suggestion="alter app security password POLICY minimum length 8",
        )]
    return []
```

### Pattern 3: Check cross-references

```python
RULE_ID = "CUSTOM003"
RULE_NAME = "UnreferencedEntity"
description = "entities should be referenced by at least one microflow or page"
CATEGORY = "quality"
SEVERITY = "info"

def check():
    violations = []
    for e in entities():
        refs = refs_to(e.qualified_name)
        if len(refs) == 0:
            violations.append(violation(
                message="entity '{}' is not referenced anywhere".format(e.qualified_name),
                location=location(module=e.module_name, document_type="entity", document_name=e.name),
            ))
    return violations
```

### Pattern 4: Check attributes of entities

```python
RULE_ID = "CUSTOM004"
RULE_NAME = "RequiredStringLength"
description = "string attributes should have a length limit"
CATEGORY = "design"
SEVERITY = "warning"

def check():
    violations = []
    for e in entities():
        for attr in attributes_for(e.qualified_name):
            if attr.data_type == "string" and attr.length == 0:
                violations.append(violation(
                    message="string attribute '{}.{}' has unlimited length".format(e.name, attr.name),
                    location=location(module=e.module_name, document_type="entity", document_name=e.name),
                ))
    return violations
```

## Validation

Test your rule by running the linter:

```bash
mxcli lint -p app.mpr --list-rules   # Verify rule is loaded
mxcli lint -p app.mpr                 # run all rules including yours
```

If a `.star` file has syntax errors, a warning is printed and the rule is skipped.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…