diff --git a/.claude/skills/mendix/write-lint-rules.md b/.claude/skills/mendix/write-lint-rules.md index 3bdb59d67..ccd39df10 100644 --- a/.claude/skills/mendix/write-lint-rules.md +++ b/.claude/skills/mendix/write-lint-rules.md @@ -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) @@ -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 | |----------|------|---------| @@ -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 | diff --git a/mdl/linter/context.go b/mdl/linter/context.go index 42f65a1a9..794c896d1 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -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 diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index 2531587d3..104f48474 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -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. @@ -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), @@ -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 diff --git a/mdl/linter/starlark_xpath.go b/mdl/linter/starlark_xpath.go new file mode 100644 index 000000000..ca1637fe0 --- /dev/null +++ b/mdl/linter/starlark_xpath.go @@ -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), + }) +} diff --git a/mdl/linter/starlark_xpath_integration_test.go b/mdl/linter/starlark_xpath_integration_test.go new file mode 100644 index 000000000..60a19c020 --- /dev/null +++ b/mdl/linter/starlark_xpath_integration_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +const xpathTestRule = ` +RULE_ID = "PERF001" +RULE_NAME = "xpath builtins" +DESCRIPTION = "exercises xpath_expressions and parse_xpath builtins" +CATEGORY = "performance" +SEVERITY = "info" + +def check(): + out = [] + for e in xpath_expressions(): + ast = parse_xpath(e.xpath_expression) + out.append(violation( + message = "entry %s kind %s" % (e.document_qualified_name, ast.kind), + )) + return out +` + +func TestStarlarkXPathBuiltins(t *testing.T) { + // Use a file-based catalog: an in-memory one pools separate connections, so + // inserts on one aren't visible to queries on another. + cat, err := catalog.NewFromFile(filepath.Join(t.TempDir(), "cat.db")) + if err != nil { + t.Fatalf("catalog.NewFromFile: %v", err) + } + defer cat.Close() + db := cat.CatalogDB() + + // One retrieve action in module MyApp with a not(…) XPath. + if _, err := db.Exec( + `INSERT INTO xpath_expressions_data + (Id, DocumentType, DocumentId, DocumentQualifiedName, + ComponentType, ComponentId, XPathExpression, + IsParameterized, UsageType, ModuleName, ProjectId, SnapshotId) + VALUES ('x1','MICROFLOW','mf-1','MyApp.GetActiveItems', + 'RETRIEVE_ACTION','ra-1','[not(Status = ''MyApp.Status.Active'')]', + 0,'RETRIEVE','MyApp','default','s1')`); err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + path := filepath.Join(dir, "xpath_test.star") + if err := os.WriteFile(path, []byte(xpathTestRule), 0644); err != nil { + t.Fatal(err) + } + + rule, err := linter.LoadStarlarkRule(path) + if err != nil { + t.Fatalf("LoadStarlarkRule: %v", err) + } + + ctx := linter.NewLintContext(cat, nil) + violations := rule.Check(ctx) + + var msgs []string + for _, v := range violations { + msgs = append(msgs, v.Message) + } + joined := strings.Join(msgs, "\n") + + for _, want := range []string{ + "entry MyApp.GetActiveItems", // xpath_expressions() returned the row + "kind unary", // parse_xpath() parsed not(…) as a unary node + } { + if !strings.Contains(joined, want) { + t.Errorf("missing %q in violations:\n%s", want, joined) + } + } +} diff --git a/mdl/linter/starlark_xpath_test.go b/mdl/linter/starlark_xpath_test.go new file mode 100644 index 000000000..654d7c23d --- /dev/null +++ b/mdl/linter/starlark_xpath_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter + +import ( + "testing" + + "go.starlark.net/starlarkstruct" + + "github.com/mendixlabs/mxcli/mdl/exprcheck" +) + +func TestRobustExprToStarlark_nil(t *testing.T) { + v := robustExprToStarlark(nil) + s, ok := v.(*starlarkstruct.Struct) + if !ok { + t.Fatal("expected *starlarkstruct.Struct") + } + kind, err := s.Attr("kind") + if err != nil || kind.String() != `"null"` { + t.Errorf("expected kind=null, got %v %v", kind, err) + } +} + +func TestRobustExprToStarlark_BinExpr(t *testing.T) { + node := &exprcheck.BinExpr{ + Op: "!=", + L: &exprcheck.VariableExpr{Name: "Status"}, + R: &exprcheck.QNameExpr{Module: "Mod", Name: "Enum", Sub: "A"}, + } + + v := robustExprToStarlark(node) + s, ok := v.(*starlarkstruct.Struct) + if !ok { + t.Fatal("expected *starlarkstruct.Struct") + } + + kind, _ := s.Attr("kind") + if kind.String() != `"bin"` { + t.Errorf("expected kind=bin, got %v", kind) + } + + op, _ := s.Attr("op") + if op.String() != `"!="` { + t.Errorf("expected op=!=, got %v", op) + } + + left, err := s.Attr("left") + if err != nil || left == nil { + t.Errorf("expected left attr, got %v %v", left, err) + } +} + +func TestRobustExprToStarlark_UnaryExpr(t *testing.T) { + node := &exprcheck.UnaryExpr{ + Op: "NOT", + Operand: &exprcheck.BoolLit{Value: true}, + } + + v := robustExprToStarlark(node) + s := v.(*starlarkstruct.Struct) + kind, _ := s.Attr("kind") + if kind.String() != `"unary"` { + t.Errorf("expected kind=unary, got %v", kind) + } + op, _ := s.Attr("op") + if op.String() != `"NOT"` { + t.Errorf("expected op=NOT, got %v", op) + } +} + +func TestRobustExprToStarlark_CallExpr(t *testing.T) { + node := &exprcheck.CallExpr{ + Name: "not", + Args: []exprcheck.RobustExpr{&exprcheck.BoolLit{Value: false}}, + } + + v := robustExprToStarlark(node) + s := v.(*starlarkstruct.Struct) + kind, _ := s.Attr("kind") + if kind.String() != `"call"` { + t.Errorf("expected kind=call, got %v", kind) + } + name, _ := s.Attr("name") + if name.String() != `"not"` { + t.Errorf("expected name=not, got %v", name) + } +} + +func TestStripXPathBrackets(t *testing.T) { + cases := []struct{ in, want string }{ + {"[Status = 'Active']", "Status = 'Active'"}, + {"Status = 'Active'", "Status = 'Active'"}, + {" [foo] ", "foo"}, + {"", ""}, + // Chained predicates must not be partially stripped. + {"[a = 1][b = 2]", "[a = 1][b = 2]"}, + {"[a = 1][b = 2][c = 3]", "[a = 1][b = 2][c = 3]"}, + } + for _, c := range cases { + got := stripXPathBrackets(c.in) + if got != c.want { + t.Errorf("stripXPathBrackets(%q) = %q, want %q", c.in, got, c.want) + } + } +}