Skip to content

Commit 9b080b0

Browse files
authored
Merge pull request #688 from Andries-Smit/feat/xpath
feat(lint): expose XPath/expression AST to Starlark rules
2 parents b67a833 + e4c0060 commit 9b080b0

6 files changed

Lines changed: 528 additions & 0 deletions

File tree

.claude/skills/mendix/write-lint-rules.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def check():
4141
| `module_roles()` | list of module_role | All module roles (deduplicated from role mappings) |
4242
| `role_mappings()` | list of role_mapping | User role to module role assignments |
4343
| `project_security()` | project_security or None | Project-level security settings (requires MPR reader) |
44+
| `xpath_expressions()` | list of xpath_expression | All XPath constraint expressions in the catalog (access rules, retrieve actions, widgets) |
4445

4546
### Graph-analysis functions (architecture rules)
4647

@@ -193,6 +194,76 @@ def check():
193194
| `interval_seconds` | int | `86400``0` for unrecognised interval type |
194195
| `enabled` | bool | `True` if the event is active |
195196

197+
### xpath_expression
198+
199+
Returned by `xpath_expressions()`. Each row represents one XPath constraint used in a retrieve action, access rule, or widget data source.
200+
201+
| Property | Type | Example |
202+
|----------|------|---------|
203+
| `id` | string | Row UUID |
204+
| `document_type` | string | `"MICROFLOW"`, `"NANOFLOW"`, `"DOMAIN_MODEL"`, `"PAGE"`, `"SNIPPET"` |
205+
| `document_id` | string | Owning document UUID |
206+
| `document_qualified_name` | string | `"MyApp.GetActiveItems"` |
207+
| `component_type` | string | `"RETRIEVE_ACTION"`, `"ACCESS_RULE"`, `"WIDGET"` |
208+
| `component_id` | string | Component UUID |
209+
| `component_name` | string | Activity/rule name (may be empty) |
210+
| `xpath_expression` | string | Raw XPath string, may include outer `[ ]` |
211+
| `target_entity` | string | Qualified name of entity being queried, e.g. `"MyApp.Order"` |
212+
| `referenced_entities` | string | Comma-separated qualified names of entities referenced by the XPath |
213+
| `is_parameterized` | bool | True when the XPath contains `$variable` references |
214+
| `usage_type` | string | `"RETRIEVE"`, `"SECURITY"`, `"DATASOURCE"` |
215+
| `module_name` | string | `"MyApp"` |
216+
217+
### expr
218+
219+
Returned by `parse_xpath(s)`. Every node has a `kind` field; additional fields depend on the kind.
220+
221+
| `kind` | Additional fields | Description |
222+
|--------|-------------------|-------------|
223+
| `"bin"` | `op` (string), `left` (expr), `right` (expr) | Binary operator: `=`, `!=`, `<`, `>`, `<=`, `>=`, `and`, `or` |
224+
| `"unary"` | `op` (string), `operand` (expr) | Unary operator: `not`, `-` |
225+
| `"call"` | `name` (string), `args` (list of expr) | Function call, e.g. `contains(…)`, `length(…)` |
226+
| `"string"` | `value` (string) | String literal |
227+
| `"number"` | `value` (string) | Numeric literal (kept as string to preserve precision) |
228+
| `"bool"` | `value` (bool) | `true` or `false` |
229+
| `"empty"` || Mendix `empty` keyword |
230+
| `"variable"` | `name` (string) | `$ParameterName` |
231+
| `"attr_path"` | `variable` (string), `path` (list of string) | `$Obj/Association/Attribute` |
232+
| `"qname"` | `module` (string), `name` (string), `sub` (string) | Qualified name, e.g. `MyApp.Status.Active` |
233+
| `"paren"` | `inner` (expr) | Parenthesised expression |
234+
| `"if"` | `cond` (expr), `then` (expr), `else_` (expr) | If-then-else expression |
235+
| `"constant"` | `qname` (string) | Mendix constant reference, e.g. `[%MyConst%]` |
236+
| `"token"` | `token` (string), `arg` (string) | Mendix token expression, e.g. `[%CurrentUser%]` |
237+
| `"recovered"` | `source` (string), `reason` (string) | Parse failure — node carries the raw source fragment |
238+
| `"null"` || Nil / missing node |
239+
| `"unknown"` || Unrecognised AST node type |
240+
241+
**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`.
242+
243+
Example — count `not(…)` calls in an XPath (using `parse_xpath`):
244+
245+
```python
246+
def count_not(node):
247+
if node.kind in ("null", "unknown", "recovered", "string", "number",
248+
"bool", "empty", "variable", "qname", "constant", "token"):
249+
return 0
250+
if node.kind == "call" and node.name == "not":
251+
return 1 + sum([count_not(a) for a in node.args])
252+
if node.kind == "call":
253+
return sum([count_not(a) for a in node.args])
254+
if node.kind == "bin":
255+
return count_not(node.left) + count_not(node.right)
256+
if node.kind == "unary":
257+
return count_not(node.operand)
258+
if node.kind == "paren":
259+
return count_not(node.inner)
260+
if node.kind == "if":
261+
return count_not(node.cond) + count_not(node.then) + count_not(node.else_)
262+
if node.kind == "attr_path":
263+
return 0
264+
return 0
265+
```
266+
196267
### attribute
197268
| Property | Type | Example |
198269
|----------|------|---------|
@@ -298,6 +369,7 @@ Returned by `project_security()`. Returns `none` if no MPR reader is available.
298369
|----------|-------------|
299370
| `violation(message, location?, suggestion?)` | Create a violation to return |
300371
| `location(module, document_type, document_name, document_id?)` | Create a location for a violation |
372+
| `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. |
301373
| `is_pascal_case(s)` | Returns True if string is PascalCase |
302374
| `is_camel_case(s)` | Returns True if string is camelCase |
303375
| `matches(s, pattern)` | Returns True if string matches regex |

mdl/linter/context.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,69 @@ func (ctx *LintContext) ScheduledEvents() iter.Seq[ScheduledEvent] {
829829
}
830830
}
831831

832+
// XPathExpressionEntry represents an XPath constraint expression from the catalog.
833+
type XPathExpressionEntry struct {
834+
ID string
835+
DocumentType string // MICROFLOW, NANOFLOW, DOMAIN_MODEL, PAGE, SNIPPET
836+
DocumentID string
837+
DocumentQualifiedName string
838+
ComponentType string // RETRIEVE_ACTION, ACCESS_RULE, WIDGET
839+
ComponentID string
840+
ComponentName string
841+
XPathExpression string // raw XPath string, may include outer [ ]
842+
TargetEntity string // qualified name of entity being queried
843+
ReferencedEntities string // comma-separated qualified names
844+
IsParameterized bool // true when XPath contains $variable references
845+
UsageType string // RETRIEVE, SECURITY, DATASOURCE
846+
ModuleName string
847+
}
848+
849+
// XPathExpressions returns an iterator over all XPath expression entries in the catalog.
850+
func (ctx *LintContext) XPathExpressions() iter.Seq[XPathExpressionEntry] {
851+
return func(yield func(XPathExpressionEntry) bool) {
852+
rows, err := ctx.db.Query(`
853+
SELECT Id, DocumentType, DocumentId, DocumentQualifiedName,
854+
ComponentType, ComponentId, ComponentName,
855+
XPathExpression, TargetEntity, ReferencedEntities,
856+
IsParameterized, UsageType, ModuleName
857+
FROM xpath_expressions
858+
ORDER BY ModuleName, DocumentQualifiedName
859+
`)
860+
if err != nil {
861+
return
862+
}
863+
defer rows.Close()
864+
865+
for rows.Next() {
866+
var e XPathExpressionEntry
867+
var componentName, targetEntity, refEntities, moduleName sql.NullString
868+
var isParam int
869+
err := rows.Scan(
870+
&e.ID, &e.DocumentType, &e.DocumentID, &e.DocumentQualifiedName,
871+
&e.ComponentType, &e.ComponentID, &componentName,
872+
&e.XPathExpression, &targetEntity, &refEntities,
873+
&isParam, &e.UsageType, &moduleName,
874+
)
875+
if err != nil {
876+
continue
877+
}
878+
e.ComponentName = componentName.String
879+
e.TargetEntity = targetEntity.String
880+
e.ReferencedEntities = refEntities.String
881+
e.ModuleName = moduleName.String
882+
e.IsParameterized = isParam == 1
883+
884+
if ctx.IsExcluded(e.ModuleName) {
885+
continue
886+
}
887+
888+
if !yield(e) {
889+
return
890+
}
891+
}
892+
}
893+
}
894+
832895
// DatabaseConnection represents a database connection from the catalog.
833896
type DatabaseConnection struct {
834897
ID string

mdl/linter/starlark.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212

1313
"go.starlark.net/starlark"
1414
"go.starlark.net/starlarkstruct"
15+
16+
"github.com/mendixlabs/mxcli/mdl/exprcheck"
1517
)
1618

1719
// StarlarkRule is a lint rule implemented in Starlark.
@@ -265,6 +267,10 @@ func (r *StarlarkRule) buildPredeclared() starlark.StringDict {
265267
"role_mappings": starlark.NewBuiltin("role_mappings", r.builtinRoleMappings),
266268
"project_security": starlark.NewBuiltin("project_security", r.builtinProjectSecurity),
267269

270+
// XPath / expression analysis
271+
"xpath_expressions": starlark.NewBuiltin("xpath_expressions", r.builtinXPathExpressions),
272+
"parse_xpath": starlark.NewBuiltin("parse_xpath", r.builtinParseXPath),
273+
268274
// Violation helpers
269275
"violation": starlark.NewBuiltin("violation", builtinViolation),
270276
"location": starlark.NewBuiltin("location", builtinLocation),
@@ -859,6 +865,34 @@ func scheduledEventToStarlark(se ScheduledEvent) starlark.Value {
859865
})
860866
}
861867

868+
// builtinXPathExpressions returns all XPath expression entries from the catalog.
869+
func (r *StarlarkRule) builtinXPathExpressions(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
870+
if r.ctx == nil {
871+
return starlark.NewList(nil), nil
872+
}
873+
874+
var result []starlark.Value
875+
for e := range r.ctx.XPathExpressions() {
876+
result = append(result, xpathExpressionEntryToStarlark(e))
877+
}
878+
879+
return starlark.NewList(result), nil
880+
}
881+
882+
// builtinParseXPath parses a raw XPath/expression string and returns its AST as a Starlark struct tree.
883+
// Outer [ ] brackets are stripped automatically. Parse failures produce a "recovered" root node.
884+
func (r *StarlarkRule) builtinParseXPath(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
885+
var s starlark.String
886+
if err := starlark.UnpackArgs("parse_xpath", args, kwargs, "s", &s); err != nil {
887+
return nil, err
888+
}
889+
890+
inner := stripXPathBrackets(string(s))
891+
parser := exprcheck.NewParser()
892+
ast, _ := parser.Parse(inner, exprcheck.NewSyntaxContext("", ""))
893+
return robustExprToStarlark(ast), nil
894+
}
895+
862896
// builtinViolation creates a violation struct.
863897
func builtinViolation(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
864898
var message starlark.String

mdl/linter/starlark_xpath.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package linter
4+
5+
import (
6+
"strings"
7+
8+
"go.starlark.net/starlark"
9+
"go.starlark.net/starlarkstruct"
10+
11+
"github.com/mendixlabs/mxcli/mdl/exprcheck"
12+
)
13+
14+
// stripXPathBrackets removes the outer [ and ] from a Mendix XPath constraint string.
15+
// Returns the inner expression ready for parsing.
16+
// Only strips when the opening [ at position 0 is matched by the final ] (i.e. they
17+
// form a single outer pair). Chained predicates like [a = 1][b = 2] are returned as-is.
18+
func stripXPathBrackets(s string) string {
19+
s = strings.TrimSpace(s)
20+
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
21+
return s
22+
}
23+
// Walk forward to find where the first '[' closes.
24+
depth := 0
25+
for i, ch := range s {
26+
switch ch {
27+
case '[':
28+
depth++
29+
case ']':
30+
depth--
31+
if depth == 0 {
32+
if i == len(s)-1 {
33+
return s[1 : len(s)-1]
34+
}
35+
// First '[' closes before the end — chained predicates; don't strip.
36+
return s
37+
}
38+
}
39+
}
40+
return s
41+
}
42+
43+
// robustExprToStarlark converts a RobustExpr AST node to a Starlark struct tree.
44+
// Each node is a struct with a "kind" field and type-specific fields.
45+
// Returns a struct with kind="null" for a nil node.
46+
func robustExprToStarlark(expr exprcheck.RobustExpr) starlark.Value {
47+
if expr == nil {
48+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
49+
"kind": starlark.String("null"),
50+
})
51+
}
52+
53+
switch n := expr.(type) {
54+
case *exprcheck.BinExpr:
55+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
56+
"kind": starlark.String("bin"),
57+
"op": starlark.String(n.Op),
58+
"left": robustExprToStarlark(n.L),
59+
"right": robustExprToStarlark(n.R),
60+
})
61+
case *exprcheck.UnaryExpr:
62+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
63+
"kind": starlark.String("unary"),
64+
"op": starlark.String(n.Op),
65+
"operand": robustExprToStarlark(n.Operand),
66+
})
67+
case *exprcheck.CallExpr:
68+
args := make([]starlark.Value, len(n.Args))
69+
for i, a := range n.Args {
70+
args[i] = robustExprToStarlark(a)
71+
}
72+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
73+
"kind": starlark.String("call"),
74+
"name": starlark.String(n.Name),
75+
"args": starlark.NewList(args),
76+
})
77+
case *exprcheck.StringLit:
78+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
79+
"kind": starlark.String("string"),
80+
"value": starlark.String(n.Value),
81+
})
82+
case *exprcheck.NumberLit:
83+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
84+
"kind": starlark.String("number"),
85+
"value": starlark.String(n.Value),
86+
})
87+
case *exprcheck.BoolLit:
88+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
89+
"kind": starlark.String("bool"),
90+
"value": starlark.Bool(n.Value),
91+
})
92+
case *exprcheck.EmptyExpr:
93+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
94+
"kind": starlark.String("empty"),
95+
})
96+
case *exprcheck.VariableExpr:
97+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
98+
"kind": starlark.String("variable"),
99+
"name": starlark.String(n.Name),
100+
})
101+
case *exprcheck.AttributePathExpr:
102+
pathParts := make([]starlark.Value, len(n.Path))
103+
for i, p := range n.Path {
104+
pathParts[i] = starlark.String(p)
105+
}
106+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
107+
"kind": starlark.String("attr_path"),
108+
"variable": starlark.String(n.Variable),
109+
"path": starlark.NewList(pathParts),
110+
})
111+
case *exprcheck.QNameExpr:
112+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
113+
"kind": starlark.String("qname"),
114+
"module": starlark.String(n.Module),
115+
"name": starlark.String(n.Name),
116+
"sub": starlark.String(n.Sub),
117+
})
118+
case *exprcheck.ParenExpr:
119+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
120+
"kind": starlark.String("paren"),
121+
"inner": robustExprToStarlark(n.Inner),
122+
})
123+
case *exprcheck.IfThenElseExpr:
124+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
125+
"kind": starlark.String("if"),
126+
"cond": robustExprToStarlark(n.Cond),
127+
"then": robustExprToStarlark(n.Then),
128+
"else_": robustExprToStarlark(n.Else),
129+
})
130+
case *exprcheck.ConstantRef:
131+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
132+
"kind": starlark.String("constant"),
133+
"qname": starlark.String(n.QName),
134+
})
135+
case *exprcheck.TokenExpr:
136+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
137+
"kind": starlark.String("token"),
138+
"token": starlark.String(n.Token),
139+
"arg": starlark.String(n.Arg),
140+
})
141+
case *exprcheck.RecoveredExpr:
142+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
143+
"kind": starlark.String("recovered"),
144+
"source": starlark.String(n.SourceFragment),
145+
"reason": starlark.String(n.Reason),
146+
})
147+
default:
148+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
149+
"kind": starlark.String("unknown"),
150+
})
151+
}
152+
}
153+
154+
// xpathExpressionEntryToStarlark converts an XPathExpressionEntry to a Starlark struct.
155+
func xpathExpressionEntryToStarlark(e XPathExpressionEntry) starlark.Value {
156+
return starlarkstruct.FromStringDict(starlark.String("xpath_expression"), starlark.StringDict{
157+
"id": starlark.String(e.ID),
158+
"document_type": starlark.String(e.DocumentType),
159+
"document_id": starlark.String(e.DocumentID),
160+
"document_qualified_name": starlark.String(e.DocumentQualifiedName),
161+
"component_type": starlark.String(e.ComponentType),
162+
"component_id": starlark.String(e.ComponentID),
163+
"component_name": starlark.String(e.ComponentName),
164+
"xpath_expression": starlark.String(e.XPathExpression),
165+
"target_entity": starlark.String(e.TargetEntity),
166+
"referenced_entities": starlark.String(e.ReferencedEntities),
167+
"is_parameterized": starlark.Bool(e.IsParameterized),
168+
"usage_type": starlark.String(e.UsageType),
169+
"module_name": starlark.String(e.ModuleName),
170+
})
171+
}

0 commit comments

Comments
 (0)