-
Notifications
You must be signed in to change notification settings - Fork 37
feat(lint): expose XPath/expression AST to Starlark rules #688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1b96f60
e1cd25b
a7f55be
b863d5b
e4c0060
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(` | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: this iterator goes straight to |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
| 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 | ||
|
|
||
| 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), | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doc nuance: "Parse failures produce a
recoveredroot node rather than raising" is accurate for the common case (unrecognized tokens at the primary position yield aRecoveredExpr, and it never raises). But a partial parse with trailing garbage returns the partial tree (trailing tokens are dropped and the hints discarded viaast, _ :=), and a nil child surfaces askind="null". Might be worth softening to "rather than raising; malformed input may yield a partial tree or anull/recoverednode."