Skip to content
Merged
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
117 changes: 117 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,123 @@ You will see a summary of the policy evaluations in the terminal and a report in

Do you want to create your own policies? Please refer to our guide [Create new policy](./docs/create-new-policy.md)

## Subcommands Reference

### export-model

Export Mendix model to yaml files. The output is a text representation of the model. It is a one-way conversion that aims to keep the semantics yet readable for humans and computers.

**Usage:**
```bash
mxlint-cli export-model [flags]
```

**Flags:**
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--input` | `-i` | `.` | Path to directory or mpr file to export. If it's a directory, all mpr files will be exported |
| `--output` | `-o` | `modelsource` | Path to directory to write the yaml files. If it doesn't exist, it will be created |
| `--mode` | `-m` | `basic` | Export mode. Valid options: `basic`, `advanced` |
| `--filter` | `-f` | | Regex pattern to filter units by name. Only units with names matching the pattern will be exported |
| `--raw` | | `false` | If set, the output yaml will include all attributes as they are in the model |
| `--appstore` | | `false` | If set, appstore modules will be included in the output |
| `--verbose` | | `false` | Turn on for debug logs |

---

### lint

Evaluate Mendix model against rules. Requires the model to be exported first. The model is evaluated against a set of rules defined in OPA Rego files or JavaScript. The output is a list of checked rules and their outcome.

**Usage:**
```bash
mxlint-cli lint [flags]
```

**Flags:**
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--rules` | `-r` | `.mendix-cache/rules` | Path to directory with rules |
| `--modelsource` | `-m` | `modelsource` | Path to directory with exported model |
| `--xunit-report` | `-x` | | Path to output file for xunit report. If not provided, no xunit report will be generated |
| `--json-file` | `-j` | | Path to output file for JSON report. If not provided, no JSON file will be generated |
| `--ignore-noqa` | | `false` | Ignore noqa directives in documents |
| `--no-cache` | | `false` | Disable caching of lint results. By default, results are cached and reused if rules and model files haven't changed |
| `--verbose` | | `false` | Turn on for debug logs |

---

### serve

Run a server that exports model and lints whenever the input MPR file changes. Works in standalone mode and via integration with the Mendix Studio Pro extension.

**Usage:**
```bash
mxlint-cli serve [flags]
```

**Flags:**
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--input` | `-i` | `.` | Path to directory or mpr file to export. If it's a directory, all mpr files will be exported |
| `--output` | `-o` | `modelsource` | Path to directory to write the yaml files. If it doesn't exist, it will be created |
| `--mode` | `-m` | `basic` | Export mode. Valid options: `basic`, `advanced` |
| `--rules` | `-r` | `.mendix-cache/rules` | Path to directory with rules |
| `--port` | `-p` | `8082` | Port to run the server on |
| `--debounce` | `-d` | `500` | Debounce time in milliseconds for file change events |
| `--verbose` | | `false` | Turn on for debug logs |

---

### test-rules

Ensure rules are working as expected against predefined test cases. When you are developing a new rule, you can use this command to ensure it works as expected.

**Usage:**
```bash
mxlint-cli test-rules [flags]
```

**Flags:**
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--rules` | `-r` | `.mendix-cache/rules` | Path to directory with rules |
| `--verbose` | | `false` | Turn on for debug logs |

---

### cache-clear

Clear the lint results cache. Removes all cached lint results. The cache is used to speed up repeated linting operations when rules and model files haven't changed.

**Usage:**
```bash
mxlint-cli cache-clear [flags]
```

**Flags:**
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--verbose` | | `false` | Turn on for debug logs |

---

### cache-stats

Show cache statistics. Displays information about the cached lint results, including number of entries and total size.

**Usage:**
```bash
mxlint-cli cache-stats [flags]
```

**Flags:**
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--verbose` | | `false` | Turn on for debug logs |

---

## export-model

Mendix models are stored in a binary file with `.mpr` extension. This project exports Mendix model to a human readable format, such as Yaml. This enables developers to use traditional code analysis tools on Mendix models. Think of quality checks like linting, code formatting, etc.
Expand Down
34 changes: 17 additions & 17 deletions lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func printTestsuite(ts Testsuite) {

// EvalAllWithResults evaluates all rules and returns the results
// This is similar to EvalAll but returns the results instead of just printing them
func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool) (interface{}, error) {
func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool) (interface{}, error) {
rules, err := ReadRulesMetadata(rulesPath)
if err != nil {
return nil, err
Expand All @@ -54,7 +54,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st
go func(index int, r Rule) {
defer wg.Done()

testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa)
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache)
if err != nil {
errChan <- err
return
Expand Down Expand Up @@ -138,7 +138,7 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st
return testsuitesContainer, nil
}

func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool) error {
func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonFile string, ignoreNoqa bool, useCache bool) error {
rules, err := ReadRulesMetadata(rulesPath)
if err != nil {
return err
Expand All @@ -162,7 +162,7 @@ func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonF
go func(index int, r Rule) {
defer wg.Done()

testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa)
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache)
if err != nil {
errChan <- err
return
Expand Down Expand Up @@ -259,7 +259,7 @@ func countTotalTestcases(testsuites []Testsuite) int {
return count
}

func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsuite, error) {
func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool, useCache bool) (*Testsuite, error) {

log.Debugf("evaluating rule %s", rule.Path)

Expand All @@ -276,25 +276,25 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui

for _, inputFile := range inputFiles {

// Try to load from cache first (but skip cache if ignoreNoqa is true)
// Try to load from cache first (but skip cache if ignoreNoqa is true or useCache is false)
cacheKey, err := createCacheKey(rule.Path, inputFile)
if err != nil {
log.Debugf("Error creating cache key: %v", err)
} else if !ignoreNoqa {
} else if useCache && !ignoreNoqa {
cachedTestcase, found := loadCachedTestcase(*cacheKey)
if found {
testcase = cachedTestcase
log.Debugf("Using cached result for %s", inputFile)
} else {
// Cache miss - evaluate and save to cache
testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa)
testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa, modelSourcePath, useCache)
if err != nil {
return nil, err
}
}
} else {
// ignoreNoqa is true, skip cache and evaluate directly
testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa)
// useCache is false or ignoreNoqa is true, skip cache and evaluate directly
testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa, modelSourcePath, useCache)
if err != nil {
return nil, err
}
Expand All @@ -305,9 +305,9 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui
if rule.Language == LanguageRego {
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa)
} else if rule.Language == LanguageJavascript {
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath)
} else if rule.Language == LanguageTypescript {
testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath)
}
if err != nil {
return nil, err
Expand Down Expand Up @@ -340,25 +340,25 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui
}

// evalTestcaseWithCaching evaluates a testcase and saves the result to cache
func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, cacheKey *CacheKey, ignoreNoqa bool) (*Testcase, error) {
func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, cacheKey *CacheKey, ignoreNoqa bool, modelSourcePath string, useCache bool) (*Testcase, error) {
var testcase *Testcase
var err error

if rule.Language == LanguageRego {
testcase, err = evalTestcase_Rego(rule.Path, queryString, inputFile, rule.RuleNumber, ignoreNoqa)
} else if rule.Language == LanguageJavascript {
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
testcase, err = evalTestcase_Javascript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath)
} else if rule.Language == LanguageTypescript {
testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa)
testcase, err = evalTestcase_Typescript(rule.Path, inputFile, rule.RuleNumber, ignoreNoqa, modelSourcePath)
}

if err != nil {
return nil, err
}

// Only save to cache when ignoreNoqa is false
// Only save to cache when useCache is true and ignoreNoqa is false
// When ignoreNoqa is true, the result might differ from the normal behavior
if !ignoreNoqa {
if useCache && !ignoreNoqa {
if cacheErr := saveCachedTestcase(*cacheKey, testcase); cacheErr != nil {
log.Debugf("Error saving to cache: %v", cacheErr)
// Don't fail the evaluation if cache save fails
Expand Down
41 changes: 26 additions & 15 deletions lint/lint_javascript.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import (
)

// resolvePath resolves the given path relative to the working directory and validates
// that it stays within the working directory. Returns the absolute path or an error.
func resolvePath(pathArg string, workingDirectory string) (string, error) {
// that it stays within the allowed root. Returns the absolute path or an error.
func resolvePath(pathArg string, workingDirectory string, allowedRoot string) (string, error) {
if allowedRoot == "" {
allowedRoot = workingDirectory
}
// Resolve the path relative to working directory
var fullPath string
if filepath.IsAbs(pathArg) {
Expand All @@ -29,15 +32,19 @@ func resolvePath(pathArg string, workingDirectory string) (string, error) {
}
absFullPath = filepath.Clean(absFullPath)

absWorkingDir, err := filepath.Abs(workingDirectory)
absAllowedRoot, err := filepath.Abs(allowedRoot)
if err != nil {
return "", fmt.Errorf("failed to resolve working directory: %w", err)
}
absWorkingDir = filepath.Clean(absWorkingDir)
absAllowedRoot = filepath.Clean(absAllowedRoot)

// Check that the resolved path is within the working directory
if !strings.HasPrefix(absFullPath, absWorkingDir+string(filepath.Separator)) && absFullPath != absWorkingDir {
return "", fmt.Errorf("path %q is outside working directory %q", pathArg, workingDirectory)
// Check that the resolved path is within the allowed root
relPath, err := filepath.Rel(absAllowedRoot, absFullPath)
if err != nil {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
if relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path %q is outside modelsource root %q", absFullPath, absAllowedRoot)
}

return absFullPath, nil
Expand All @@ -51,7 +58,7 @@ func resolvePath(pathArg string, workingDirectory string) (string, error) {
// The path is resolved relative to the workingDirectory.
// - mxlint.io.isdir(path): Returns true if the path is a directory, false otherwise.
// The path is resolved relative to the workingDirectory.
func setupJavascriptVM(workingDirectory string) *sobek.Runtime {
func setupJavascriptVM(workingDirectory string, allowedRoot string) *sobek.Runtime {
vm := sobek.New()

// Create the mxlint object
Expand All @@ -69,7 +76,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime {
}
filepathArg := call.Argument(0).String()

absPath, err := resolvePath(filepathArg, workingDirectory)
absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot)
if err != nil {
panic(vm.NewGoError(fmt.Errorf("mxlint.io.readfile: %w", err)))
}
Expand All @@ -88,7 +95,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime {
}
dirpathArg := call.Argument(0).String()

absPath, err := resolvePath(dirpathArg, workingDirectory)
absPath, err := resolvePath(dirpathArg, workingDirectory, allowedRoot)
if err != nil {
panic(vm.NewGoError(fmt.Errorf("mxlint.io.listdir: %w", err)))
}
Expand All @@ -114,7 +121,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime {
}
pathArg := call.Argument(0).String()

absPath, err := resolvePath(pathArg, workingDirectory)
absPath, err := resolvePath(pathArg, workingDirectory, allowedRoot)
if err != nil {
panic(vm.NewGoError(fmt.Errorf("mxlint.io.isdir: %w", err)))
}
Expand All @@ -133,7 +140,7 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime {
return vm
}

func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) {
func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool, modelSourcePath string) (*Testcase, error) {
ruleContent, _ := os.ReadFile(rulePath)
log.Debugf("js file: \n%s", ruleContent)

Expand Down Expand Up @@ -171,9 +178,13 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s

startTime := time.Now()

// Use the directory containing the input file as the working directory
workingDirectory := filepath.Dir(inputFilePath)
vm := setupJavascriptVM(workingDirectory)
// Use the modelsource path as the working directory, falling back to input file's directory
workingDirectory := modelSourcePath
if workingDirectory == "" {
workingDirectory = filepath.Dir(inputFilePath)
}
allowedRoot := resolveAllowedRoot(modelSourcePath)
vm := setupJavascriptVM(workingDirectory, allowedRoot)
_, err = vm.RunString(string(ruleContent))
if err != nil {
panic(err)
Expand Down
Loading
Loading