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
2 changes: 2 additions & 0 deletions default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ lint:
noCache: false
concurrency: 4
regoTrace: false
# skip: maps document path (relative to model source, or absolute) to rules to skip.
# Use the map key "*" (quoted in YAML: "*") to apply the listed rules to every document, after path-specific entries.
skip: {}
cache:
directory: .mendix-cache/mxlint
Expand Down
23 changes: 18 additions & 5 deletions lint/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (

const configFileName = "mxlint.yaml"

// skipPathAllDocuments is the lint.skip map key that applies listed rules to every document.
const skipPathAllDocuments = "*"

type Config struct {
Rules ConfigRulesSpec `yaml:"rules"`
Lint ConfigLintSpec `yaml:"lint"`
Expand Down Expand Up @@ -352,6 +355,15 @@ func mergeConfig(base *Config, overlay *Config) {
}
}

func matchConfigSkipRules(entries []ConfigSkipRule, ruleNumber string) (bool, string) {
for _, entry := range entries {
if entry.Rule == "" || entry.Rule == "*" || entry.Rule == ruleNumber {
return true, formatConfigSkipReason(entry)
}
}
return false, ""
}

func shouldSkipByConfig(inputFilePath string, ruleNumber string, modelSourcePath string) (bool, string) {
cfg := getConfig()
if cfg == nil || len(cfg.Lint.Skip) == 0 {
Expand All @@ -363,14 +375,15 @@ func shouldSkipByConfig(inputFilePath string, ruleNumber string, modelSourcePath
if !ok {
continue
}

for _, entry := range entries {
if entry.Rule == "" || entry.Rule == "*" || entry.Rule == ruleNumber {
return true, formatConfigSkipReason(entry)
}
if skip, reason := matchConfigSkipRules(entries, ruleNumber); skip {
return true, reason
}
}

if entries, ok := cfg.Lint.Skip[skipPathAllDocuments]; ok {
return matchConfigSkipRules(entries, ruleNumber)
}

return false, ""
}

Expand Down
50 changes: 50 additions & 0 deletions lint/config_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestNormalizeSkipPath(t *testing.T) {
{name: "leading slash", input: "/example/doc.yaml", expected: "example/doc.yaml"},
{name: "collapse separators", input: "example//nested/../doc", expected: "example/doc"},
{name: "dot path becomes empty", input: ".", expected: ""},
{name: "all-documents wildcard key", input: "*", expected: "*"},
}

for _, tt := range tests {
Expand Down Expand Up @@ -130,3 +131,52 @@ func TestShouldSkipByConfig(t *testing.T) {
}
})
}

func TestShouldSkipByConfig_AllDocumentsWildcard(t *testing.T) {
SetConfig(&Config{
Lint: ConfigLintSpec{
Skip: map[string][]ConfigSkipRule{
skipPathAllDocuments: {
{Rule: "001_002", Reason: "skip everywhere"},
},
},
},
})
t.Cleanup(func() {
SetConfig(&Config{})
})

skip, reason := shouldSkipByConfig("/tmp/modelsource/example/other.yaml", "001_002", "/tmp/modelsource")
if !skip {
t.Fatal("expected skip=true for lint.skip document path *")
}
if reason != "skip everywhere" {
t.Fatalf("expected global skip reason, got %q", reason)
}
}

func TestShouldSkipByConfig_PathSpecificBeforeAllDocumentsWildcard(t *testing.T) {
SetConfig(&Config{
Lint: ConfigLintSpec{
Skip: map[string][]ConfigSkipRule{
skipPathAllDocuments: {
{Rule: "001_002", Reason: "from star"},
},
"example/doc": {
{Rule: "001_002", Reason: "from path"},
},
},
},
})
t.Cleanup(func() {
SetConfig(&Config{})
})

skip, reason := shouldSkipByConfig("/tmp/modelsource/example/doc.yaml", "001_002", "/tmp/modelsource")
if !skip {
t.Fatal("expected skip=true")
}
if reason != "from path" {
t.Fatalf("expected path-specific skip to win, got %q", reason)
}
}
35 changes: 35 additions & 0 deletions lint/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,41 @@ func TestLoadMergedConfig_NormalizesSkipMapKeys(t *testing.T) {
}
}

func TestLoadMergedConfig_SkipAllDocumentsWildcardKey(t *testing.T) {
projectDir := t.TempDir()
setDefaultConfigForTest(t, "")
projectConfig := `lint:
skip:
"*":
- rule: "001_002"
reason: global doc skip
`
if err := os.WriteFile(filepath.Join(projectDir, "mxlint.yaml"), []byte(projectConfig), 0644); err != nil {
t.Fatalf("failed to write project config: %v", err)
}

cfg, err := LoadMergedConfig(projectDir)
if err != nil {
t.Fatalf("LoadMergedConfig returned error: %v", err)
}

if _, ok := cfg.Lint.Skip["*"]; !ok {
t.Fatalf("expected skip key *, got %#v", cfg.Lint.Skip)
}
SetConfig(cfg)
t.Cleanup(func() {
SetConfig(&Config{})
})

skip, reason := shouldSkipRule("", "001_002", true, "/tmp/modelsource/any/nested/file.yaml", "/tmp/modelsource")
if !skip {
t.Fatal("expected lint.skip * document path to match any file")
}
if reason != "global doc skip" {
t.Fatalf("expected configured reason, got %s", reason)
}
}

func TestLoadMergedConfig_LintConcurrencyAndTrace(t *testing.T) {
projectDir := t.TempDir()
setDefaultConfigForTest(t, "")
Expand Down
94 changes: 79 additions & 15 deletions lint/lint_javascript.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package lint

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -50,10 +51,49 @@ func resolvePath(pathArg string, workingDirectory string, allowedRoot string) (s
return absFullPath, nil
}

// readYAMLDocumentFromPath reads a YAML file and decodes it into a map suitable for JavaScript rules.
func readYAMLDocumentFromPath(absPath string) (map[string]interface{}, error) {
documentContent, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}

var data map[string]interface{}
var node yaml.Node
err = yaml.Unmarshal(documentContent, &node)
if err != nil {
return nil, err
}
err = node.Decode(&data)
if err != nil {
return nil, err
}
return data, nil
}

// readJSONDocumentFromPath reads a JSON file and decodes it into a value suitable for JavaScript rules.
func readJSONDocumentFromPath(absPath string) (interface{}, error) {
documentContent, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}

var data interface{}
err = json.Unmarshal(documentContent, &data)
if err != nil {
return nil, err
}
return data, nil
}

// setupJavascriptVM creates a new sobek VM with the mxlint object exposed.
// The mxlint object provides utility functions for JavaScript rules:
// - mxlint.io.readfile(path): Reads a file and returns its contents as a string.
// The path is resolved relative to the workingDirectory.
// - mxlint.io.readYaml(path): Reads a YAML file and returns its parsed content as an object.
// The path is resolved relative to the workingDirectory.
// - mxlint.io.readJson(path): Reads a JSON file and returns its parsed content.
// The path is resolved relative to the workingDirectory.
// - mxlint.io.listdir(path): Lists the contents of a directory and returns an array of filenames.
// The path is resolved relative to the workingDirectory.
// - mxlint.io.isdir(path): Returns true if the path is a directory, false otherwise.
Expand Down Expand Up @@ -88,6 +128,44 @@ func setupJavascriptVM(workingDirectory string, allowedRoot string) *sobek.Runti
return vm.ToValue(string(content))
})

// Set the readYaml function
io.Set("readYaml", func(call sobek.FunctionCall) sobek.Value {
if len(call.Arguments) == 0 {
panic(vm.NewGoError(fmt.Errorf("mxlint.io.readYaml requires a file path argument")))
}
filepathArg := call.Argument(0).String()

absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot)
if err != nil {
panic(vm.NewGoError(fmt.Errorf("mxlint.io.readYaml: %w", err)))
}

data, err := readYAMLDocumentFromPath(absPath)
if err != nil {
panic(vm.NewGoError(err))
}
return vm.ToValue(data)
})

// Set the readJson function
io.Set("readJson", func(call sobek.FunctionCall) sobek.Value {
if len(call.Arguments) == 0 {
panic(vm.NewGoError(fmt.Errorf("mxlint.io.readJson requires a file path argument")))
}
filepathArg := call.Argument(0).String()

absPath, err := resolvePath(filepathArg, workingDirectory, allowedRoot)
if err != nil {
panic(vm.NewGoError(fmt.Errorf("mxlint.io.readJson: %w", err)))
}

data, err := readJSONDocumentFromPath(absPath)
if err != nil {
panic(vm.NewGoError(err))
}
return vm.ToValue(data)
})

// Set the listdir function
io.Set("listdir", func(call sobek.FunctionCall) sobek.Value {
if len(call.Arguments) == 0 {
Expand Down Expand Up @@ -144,26 +222,12 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s
ruleContent, _ := os.ReadFile(rulePath)
log.Debugf("js file: \n%s", ruleContent)

documentContent, err := os.ReadFile(inputFilePath)
data, err := readYAMLDocumentFromPath(inputFilePath)
if err != nil {
log.Errorf("Error reading YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err)
return nil, err
}

// parse the input file as YAML
var data map[string]interface{}
var node yaml.Node
err = yaml.Unmarshal(documentContent, &node)
if err != nil {
log.Errorf("Error parsing YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err)
return nil, err
}
err = node.Decode(&data)
if err != nil {
log.Errorf("Error decoding YAML file %q (rule: %q): %s\n", inputFilePath, rulePath, err)
return nil, err
}

// Check if this rule should be skipped based on noqa directives
doc, _ := data["Documentation"].(string)
shouldSkip, reason := shouldSkipRule(doc, ruleNumber, ignoreNoqa, inputFilePath, modelSourcePath)
Expand Down
Loading
Loading