Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .claude/skills/mendix/write-lint-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def check():
| `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) |

### Graph-analysis functions (architecture rules)

Expand Down Expand Up @@ -193,6 +194,76 @@ def check():
| `interval_seconds` | int | `86400` — `0` for unrecognised interval type |
| `enabled` | bool | `True` 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.

| 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 |
|----------|------|---------|
Expand Down Expand Up @@ -298,6 +369,7 @@ Returned by `project_security()`. Returns `none` if no MPR reader is available.
|----------|-------------|
| `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 |
Expand Down
63 changes: 63 additions & 0 deletions mdl/linter/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,69 @@ func (ctx *LintContext) ScheduledEvents() iter.Seq[ScheduledEvent] {
}
}

// XPathExpressionEntry represents an XPath constraint expression from the catalog.
type XPathExpressionEntry struct {
ID string
DocumentType string // MICROFLOW, NANOFLOW, DOMAIN_MODEL, PAGE, SNIPPET
DocumentID string
DocumentQualifiedName string
ComponentType string // RETRIEVE_ACTION, ACCESS_RULE, WIDGET
ComponentID string
ComponentName string
XPathExpression string // raw XPath string, may include outer [ ]
TargetEntity string // qualified name of entity being queried
ReferencedEntities string // comma-separated qualified names
IsParameterized bool // true when XPath contains $variable references
UsageType string // RETRIEVE, SECURITY, DATASOURCE
ModuleName string
}

// XPathExpressions returns an iterator over all XPath expression entries in the catalog.
func (ctx *LintContext) XPathExpressions() iter.Seq[XPathExpressionEntry] {
return func(yield func(XPathExpressionEntry) bool) {
rows, err := ctx.db.Query(`
SELECT Id, DocumentType, DocumentId, DocumentQualifiedName,
ComponentType, ComponentId, ComponentName,
XPathExpression, TargetEntity, ReferencedEntities,
IsParameterized, UsageType, ModuleName
FROM xpath_expressions
ORDER BY ModuleName, DocumentQualifiedName
`)
if err != nil {
return
}
defer rows.Close()

for rows.Next() {
var e XPathExpressionEntry
var componentName, targetEntity, refEntities, moduleName sql.NullString
var isParam int
err := rows.Scan(
&e.ID, &e.DocumentType, &e.DocumentID, &e.DocumentQualifiedName,
&e.ComponentType, &e.ComponentID, &componentName,
&e.XPathExpression, &targetEntity, &refEntities,
&isParam, &e.UsageType, &moduleName,
)
if err != nil {
continue
}
e.ComponentName = componentName.String
e.TargetEntity = targetEntity.String
e.ReferencedEntities = refEntities.String
e.ModuleName = moduleName.String
e.IsParameterized = isParam == 1

if ctx.IsExcluded(e.ModuleName) {
continue
}

if !yield(e) {
return
}
}
}
}

// DatabaseConnection represents a database connection from the catalog.
type DatabaseConnection struct {
ID string
Expand Down
34 changes: 34 additions & 0 deletions mdl/linter/starlark.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (

"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"

"github.com/mendixlabs/mxcli/mdl/exprcheck"
)

// StarlarkRule is a lint rule implemented in Starlark.
Expand Down Expand Up @@ -259,6 +261,10 @@ func (r *StarlarkRule) buildPredeclared() starlark.StringDict {
"role_mappings": starlark.NewBuiltin("role_mappings", r.builtinRoleMappings),
"project_security": starlark.NewBuiltin("project_security", r.builtinProjectSecurity),

// XPath / expression analysis
"xpath_expressions": starlark.NewBuiltin("xpath_expressions", r.builtinXPathExpressions),
"parse_xpath": starlark.NewBuiltin("parse_xpath", r.builtinParseXPath),

// Violation helpers
"violation": starlark.NewBuiltin("violation", builtinViolation),
"location": starlark.NewBuiltin("location", builtinLocation),
Expand Down Expand Up @@ -815,6 +821,34 @@ func scheduledEventToStarlark(se ScheduledEvent) starlark.Value {
})
}

// builtinXPathExpressions returns all XPath expression entries from the catalog.
func (r *StarlarkRule) builtinXPathExpressions(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
if r.ctx == nil {
return starlark.NewList(nil), nil
}

var result []starlark.Value
for e := range r.ctx.XPathExpressions() {
result = append(result, xpathExpressionEntryToStarlark(e))
}

return starlark.NewList(result), nil
}

// builtinParseXPath parses a raw XPath/expression string and returns its AST as a Starlark struct tree.
// Outer [ ] brackets are stripped automatically. Parse failures produce a "recovered" root node.
func (r *StarlarkRule) builtinParseXPath(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var s starlark.String
if err := starlark.UnpackArgs("parse_xpath", args, kwargs, "s", &s); err != nil {
return nil, err
}

inner := stripXPathBrackets(string(s))
parser := exprcheck.NewParser()
ast, _ := parser.Parse(inner, exprcheck.NewSyntaxContext("", ""))
return robustExprToStarlark(ast), nil
}

// builtinViolation creates a violation struct.
func builtinViolation(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var message starlark.String
Expand Down
171 changes: 171 additions & 0 deletions mdl/linter/starlark_xpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: Apache-2.0

package linter

import (
"strings"

"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"

"github.com/mendixlabs/mxcli/mdl/exprcheck"
)

// stripXPathBrackets removes the outer [ and ] from a Mendix XPath constraint string.
// Returns the inner expression ready for parsing.
// Only strips when the opening [ at position 0 is matched by the final ] (i.e. they
// form a single outer pair). Chained predicates like [a = 1][b = 2] are returned as-is.
func stripXPathBrackets(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
return s
}
// Walk forward to find where the first '[' closes.
depth := 0
for i, ch := range s {
switch ch {
case '[':
depth++
case ']':
depth--
if depth == 0 {
if i == len(s)-1 {
return s[1 : len(s)-1]
}
// First '[' closes before the end — chained predicates; don't strip.
return s
}
}
}
return s
}

// robustExprToStarlark converts a RobustExpr AST node to a Starlark struct tree.
// Each node is a struct with a "kind" field and type-specific fields.
// Returns a struct with kind="null" for a nil node.
func robustExprToStarlark(expr exprcheck.RobustExpr) starlark.Value {
if expr == nil {
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("null"),
})
}

switch n := expr.(type) {
case *exprcheck.BinExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("bin"),
"op": starlark.String(n.Op),
"left": robustExprToStarlark(n.L),
"right": robustExprToStarlark(n.R),
})
case *exprcheck.UnaryExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("unary"),
"op": starlark.String(n.Op),
"operand": robustExprToStarlark(n.Operand),
})
case *exprcheck.CallExpr:
args := make([]starlark.Value, len(n.Args))
for i, a := range n.Args {
args[i] = robustExprToStarlark(a)
}
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("call"),
"name": starlark.String(n.Name),
"args": starlark.NewList(args),
})
case *exprcheck.StringLit:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("string"),
"value": starlark.String(n.Value),
})
case *exprcheck.NumberLit:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("number"),
"value": starlark.String(n.Value),
})
case *exprcheck.BoolLit:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("bool"),
"value": starlark.Bool(n.Value),
})
case *exprcheck.EmptyExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("empty"),
})
case *exprcheck.VariableExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("variable"),
"name": starlark.String(n.Name),
})
case *exprcheck.AttributePathExpr:
pathParts := make([]starlark.Value, len(n.Path))
for i, p := range n.Path {
pathParts[i] = starlark.String(p)
}
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("attr_path"),
"variable": starlark.String(n.Variable),
"path": starlark.NewList(pathParts),
})
case *exprcheck.QNameExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("qname"),
"module": starlark.String(n.Module),
"name": starlark.String(n.Name),
"sub": starlark.String(n.Sub),
})
case *exprcheck.ParenExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("paren"),
"inner": robustExprToStarlark(n.Inner),
})
case *exprcheck.IfThenElseExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("if"),
"cond": robustExprToStarlark(n.Cond),
"then": robustExprToStarlark(n.Then),
"else_": robustExprToStarlark(n.Else),
})
case *exprcheck.ConstantRef:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("constant"),
"qname": starlark.String(n.QName),
})
case *exprcheck.TokenExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("token"),
"token": starlark.String(n.Token),
"arg": starlark.String(n.Arg),
})
case *exprcheck.RecoveredExpr:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("recovered"),
"source": starlark.String(n.SourceFragment),
"reason": starlark.String(n.Reason),
})
default:
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
"kind": starlark.String("unknown"),
})
}
}

// xpathExpressionEntryToStarlark converts an XPathExpressionEntry to a Starlark struct.
func xpathExpressionEntryToStarlark(e XPathExpressionEntry) starlark.Value {
return starlarkstruct.FromStringDict(starlark.String("xpath_expression"), starlark.StringDict{
"id": starlark.String(e.ID),
"document_type": starlark.String(e.DocumentType),
"document_id": starlark.String(e.DocumentID),
"document_qualified_name": starlark.String(e.DocumentQualifiedName),
"component_type": starlark.String(e.ComponentType),
"component_id": starlark.String(e.ComponentID),
"component_name": starlark.String(e.ComponentName),
"xpath_expression": starlark.String(e.XPathExpression),
"target_entity": starlark.String(e.TargetEntity),
"referenced_entities": starlark.String(e.ReferencedEntities),
"is_parameterized": starlark.Bool(e.IsParameterized),
"usage_type": starlark.String(e.UsageType),
"module_name": starlark.String(e.ModuleName),
})
}
Loading