Skip to content

Commit 1b96f60

Browse files
Andries-Smitclaude
andcommitted
feat(lint): expose XPath/expression AST to Starlark rules
Add two new builtins available to all `.star` lint rules: - `xpath_expressions()` — iterates XPathExpressionEntry records from the catalog, covering RETRIEVE, SECURITY, and DATASOURCE usage types across microflows, access rules, and pages. - `parse_xpath(expr)` — parses a raw XPath string into a structured Starlark tree using the existing exprcheck parser. Returns a recursive struct tree with a `kind` discriminator field and type-specific child fields (left/right for bin, operand for unary, args for call, etc.). Internal additions: - `starlark_xpath.go` — robustExprToStarlark() converter and xpathExpressionEntryToStarlark() mapper - `starlark_xpath_test.go` — unit tests for the converter - `LintContext.XPathExpressions()` iterator (context.go) - `"PERF001": "Performance"` mapping in report.go These builtins make it possible to write performance and security lint rules that inspect the parsed expression tree rather than raw strings — for example, flagging negative enumeration XPath conditions that prevent database index usage (PERF001). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGEk1wbb3i1kMc6HBAodHQ
1 parent d1b17a4 commit 1b96f60

4 files changed

Lines changed: 352 additions & 0 deletions

File tree

mdl/linter/context.go

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

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

264+
// XPath / expression analysis
265+
"xpath_expressions": starlark.NewBuiltin("xpath_expressions", r.builtinXPathExpressions),
266+
"parse_xpath": starlark.NewBuiltin("parse_xpath", r.builtinParseXPath),
267+
262268
// Violation helpers
263269
"violation": starlark.NewBuiltin("violation", builtinViolation),
264270
"location": starlark.NewBuiltin("location", builtinLocation),
@@ -815,6 +821,34 @@ func scheduledEventToStarlark(se ScheduledEvent) starlark.Value {
815821
})
816822
}
817823

824+
// builtinXPathExpressions returns all XPath expression entries from the catalog.
825+
func (r *StarlarkRule) builtinXPathExpressions(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
826+
if r.ctx == nil {
827+
return starlark.NewList(nil), nil
828+
}
829+
830+
var result []starlark.Value
831+
for e := range r.ctx.XPathExpressions() {
832+
result = append(result, xpathExpressionEntryToStarlark(e))
833+
}
834+
835+
return starlark.NewList(result), nil
836+
}
837+
838+
// builtinParseXPath parses a raw XPath/expression string and returns its AST as a Starlark struct tree.
839+
// Outer [ ] brackets are stripped automatically. Parse failures produce a "recovered" root node.
840+
func (r *StarlarkRule) builtinParseXPath(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
841+
var s starlark.String
842+
if err := starlark.UnpackArgs("parse_xpath", args, kwargs, "s", &s); err != nil {
843+
return nil, err
844+
}
845+
846+
inner := stripXPathBrackets(string(s))
847+
parser := exprcheck.NewParser()
848+
ast, _ := parser.Parse(inner, exprcheck.NewSyntaxContext("", ""))
849+
return robustExprToStarlark(ast), nil
850+
}
851+
818852
// builtinViolation creates a violation struct.
819853
func builtinViolation(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
820854
var message starlark.String

mdl/linter/starlark_xpath.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
func stripXPathBrackets(s string) string {
17+
s = strings.TrimSpace(s)
18+
if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
19+
return s[1 : len(s)-1]
20+
}
21+
return s
22+
}
23+
24+
// robustExprToStarlark converts a RobustExpr AST node to a Starlark struct tree.
25+
// Each node is a struct with a "kind" field and type-specific fields.
26+
// Returns starlark.None for a nil node.
27+
func robustExprToStarlark(expr exprcheck.RobustExpr) starlark.Value {
28+
if expr == nil {
29+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
30+
"kind": starlark.String("null"),
31+
})
32+
}
33+
34+
switch n := expr.(type) {
35+
case *exprcheck.BinExpr:
36+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
37+
"kind": starlark.String("bin"),
38+
"op": starlark.String(n.Op),
39+
"left": robustExprToStarlark(n.L),
40+
"right": robustExprToStarlark(n.R),
41+
})
42+
case *exprcheck.UnaryExpr:
43+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
44+
"kind": starlark.String("unary"),
45+
"op": starlark.String(n.Op),
46+
"operand": robustExprToStarlark(n.Operand),
47+
})
48+
case *exprcheck.CallExpr:
49+
args := make([]starlark.Value, len(n.Args))
50+
for i, a := range n.Args {
51+
args[i] = robustExprToStarlark(a)
52+
}
53+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
54+
"kind": starlark.String("call"),
55+
"name": starlark.String(n.Name),
56+
"args": starlark.NewList(args),
57+
})
58+
case *exprcheck.StringLit:
59+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
60+
"kind": starlark.String("string"),
61+
"value": starlark.String(n.Value),
62+
})
63+
case *exprcheck.NumberLit:
64+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
65+
"kind": starlark.String("number"),
66+
"value": starlark.String(n.Value),
67+
})
68+
case *exprcheck.BoolLit:
69+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
70+
"kind": starlark.String("bool"),
71+
"value": starlark.Bool(n.Value),
72+
})
73+
case *exprcheck.EmptyExpr:
74+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
75+
"kind": starlark.String("empty"),
76+
})
77+
case *exprcheck.VariableExpr:
78+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
79+
"kind": starlark.String("variable"),
80+
"name": starlark.String(n.Name),
81+
})
82+
case *exprcheck.AttributePathExpr:
83+
pathParts := make([]starlark.Value, len(n.Path))
84+
for i, p := range n.Path {
85+
pathParts[i] = starlark.String(p)
86+
}
87+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
88+
"kind": starlark.String("attr_path"),
89+
"variable": starlark.String(n.Variable),
90+
"path": starlark.NewList(pathParts),
91+
})
92+
case *exprcheck.QNameExpr:
93+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
94+
"kind": starlark.String("qname"),
95+
"module": starlark.String(n.Module),
96+
"name": starlark.String(n.Name),
97+
"sub": starlark.String(n.Sub),
98+
})
99+
case *exprcheck.ParenExpr:
100+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
101+
"kind": starlark.String("paren"),
102+
"inner": robustExprToStarlark(n.Inner),
103+
})
104+
case *exprcheck.IfThenElseExpr:
105+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
106+
"kind": starlark.String("if"),
107+
"cond": robustExprToStarlark(n.Cond),
108+
"then": robustExprToStarlark(n.Then),
109+
"else_": robustExprToStarlark(n.Else),
110+
})
111+
case *exprcheck.ConstantRef:
112+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
113+
"kind": starlark.String("constant"),
114+
"qname": starlark.String(n.QName),
115+
})
116+
case *exprcheck.TokenExpr:
117+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
118+
"kind": starlark.String("token"),
119+
"token": starlark.String(n.Token),
120+
"arg": starlark.String(n.Arg),
121+
})
122+
case *exprcheck.RecoveredExpr:
123+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
124+
"kind": starlark.String("recovered"),
125+
"source": starlark.String(n.SourceFragment),
126+
"reason": starlark.String(n.Reason),
127+
})
128+
default:
129+
return starlarkstruct.FromStringDict(starlark.String("expr"), starlark.StringDict{
130+
"kind": starlark.String("unknown"),
131+
})
132+
}
133+
}
134+
135+
// xpathExpressionEntryToStarlark converts an XPathExpressionEntry to a Starlark struct.
136+
func xpathExpressionEntryToStarlark(e XPathExpressionEntry) starlark.Value {
137+
return starlarkstruct.FromStringDict(starlark.String("xpath_expression"), starlark.StringDict{
138+
"id": starlark.String(e.ID),
139+
"document_type": starlark.String(e.DocumentType),
140+
"document_id": starlark.String(e.DocumentID),
141+
"document_qualified_name": starlark.String(e.DocumentQualifiedName),
142+
"component_type": starlark.String(e.ComponentType),
143+
"component_id": starlark.String(e.ComponentID),
144+
"component_name": starlark.String(e.ComponentName),
145+
"xpath_expression": starlark.String(e.XPathExpression),
146+
"target_entity": starlark.String(e.TargetEntity),
147+
"referenced_entities": starlark.String(e.ReferencedEntities),
148+
"is_parameterized": starlark.Bool(e.IsParameterized),
149+
"usage_type": starlark.String(e.UsageType),
150+
"module_name": starlark.String(e.ModuleName),
151+
})
152+
}

mdl/linter/starlark_xpath_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package linter
4+
5+
import (
6+
"testing"
7+
8+
"go.starlark.net/starlarkstruct"
9+
10+
"github.com/mendixlabs/mxcli/mdl/exprcheck"
11+
)
12+
13+
func TestRobustExprToStarlark_nil(t *testing.T) {
14+
v := robustExprToStarlark(nil)
15+
s, ok := v.(*starlarkstruct.Struct)
16+
if !ok {
17+
t.Fatal("expected *starlarkstruct.Struct")
18+
}
19+
kind, err := s.Attr("kind")
20+
if err != nil || kind.String() != `"null"` {
21+
t.Errorf("expected kind=null, got %v %v", kind, err)
22+
}
23+
}
24+
25+
func TestRobustExprToStarlark_BinExpr(t *testing.T) {
26+
node := &exprcheck.BinExpr{
27+
Op: "!=",
28+
L: &exprcheck.VariableExpr{Name: "Status"},
29+
R: &exprcheck.QNameExpr{Module: "Mod", Name: "Enum", Sub: "A"},
30+
}
31+
32+
v := robustExprToStarlark(node)
33+
s, ok := v.(*starlarkstruct.Struct)
34+
if !ok {
35+
t.Fatal("expected *starlarkstruct.Struct")
36+
}
37+
38+
kind, _ := s.Attr("kind")
39+
if kind.String() != `"bin"` {
40+
t.Errorf("expected kind=bin, got %v", kind)
41+
}
42+
43+
op, _ := s.Attr("op")
44+
if op.String() != `"!="` {
45+
t.Errorf("expected op=!=, got %v", op)
46+
}
47+
48+
left, err := s.Attr("left")
49+
if err != nil || left == nil {
50+
t.Errorf("expected left attr, got %v %v", left, err)
51+
}
52+
}
53+
54+
func TestRobustExprToStarlark_UnaryExpr(t *testing.T) {
55+
node := &exprcheck.UnaryExpr{
56+
Op: "NOT",
57+
Operand: &exprcheck.BoolLit{Value: true},
58+
}
59+
60+
v := robustExprToStarlark(node)
61+
s := v.(*starlarkstruct.Struct)
62+
kind, _ := s.Attr("kind")
63+
if kind.String() != `"unary"` {
64+
t.Errorf("expected kind=unary, got %v", kind)
65+
}
66+
op, _ := s.Attr("op")
67+
if op.String() != `"NOT"` {
68+
t.Errorf("expected op=NOT, got %v", op)
69+
}
70+
}
71+
72+
func TestRobustExprToStarlark_CallExpr(t *testing.T) {
73+
node := &exprcheck.CallExpr{
74+
Name: "not",
75+
Args: []exprcheck.RobustExpr{&exprcheck.BoolLit{Value: false}},
76+
}
77+
78+
v := robustExprToStarlark(node)
79+
s := v.(*starlarkstruct.Struct)
80+
kind, _ := s.Attr("kind")
81+
if kind.String() != `"call"` {
82+
t.Errorf("expected kind=call, got %v", kind)
83+
}
84+
name, _ := s.Attr("name")
85+
if name.String() != `"not"` {
86+
t.Errorf("expected name=not, got %v", name)
87+
}
88+
}
89+
90+
func TestStripXPathBrackets(t *testing.T) {
91+
cases := []struct{ in, want string }{
92+
{"[Status = 'Active']", "Status = 'Active'"},
93+
{"Status = 'Active'", "Status = 'Active'"},
94+
{" [foo] ", "foo"},
95+
{"", ""},
96+
}
97+
for _, c := range cases {
98+
got := stripXPathBrackets(c.in)
99+
if got != c.want {
100+
t.Errorf("stripXPathBrackets(%q) = %q, want %q", c.in, got, c.want)
101+
}
102+
}
103+
}

0 commit comments

Comments
 (0)