From 53846dbf83a2660b3d4a8ee91e42938be03465b5 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Sat, 31 Jan 2026 17:01:04 +0100 Subject: [PATCH 1/5] mxlint.io functions accept paths relative to the modelsource directory --- lint/lint.go | 14 ++-- lint/lint_javascript.go | 41 +++++++----- lint/lint_javascript_test.go | 64 +++++++++---------- lint/lint_typscript.go | 14 ++-- lint/rules.go | 2 +- lint/utils.go | 16 +++++ resources/rules/001_0004_readfile_feature.js | 36 ++++++----- .../rules/001_0004_readfile_feature_test.yaml | 5 +- 8 files changed, 113 insertions(+), 79 deletions(-) diff --git a/lint/lint.go b/lint/lint.go index 7961a08..e9e4f7b 100644 --- a/lint/lint.go +++ b/lint/lint.go @@ -287,14 +287,14 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool) (*Testsui 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) if err != nil { return nil, err } } } else { // ignoreNoqa is true, skip cache and evaluate directly - testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa) + testcase, err = evalTestcaseWithCaching(rule, queryString, inputFile, cacheKey, ignoreNoqa, modelSourcePath) if err != nil { return nil, err } @@ -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 @@ -340,16 +340,16 @@ 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) (*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 { diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index b0fce4b..0d94c30 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -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) { @@ -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 @@ -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 @@ -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))) } @@ -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))) } @@ -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))) } @@ -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) @@ -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) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index 15094d7..1ba8e5c 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -19,7 +19,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { } t.Run("read file with relative path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.readfile("test.txt")` result, err := vm.RunString(script) @@ -33,7 +33,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("read file with absolute path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.readfile("` + testFilePath + `")` result, err := vm.RunString(script) @@ -47,7 +47,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("read nonexistent file throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -68,14 +68,14 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("path traversal with .. is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.readfile("../../../etc/passwd"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -89,14 +89,14 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("absolute path outside working directory is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.readfile("/etc/passwd"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -110,7 +110,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) t.Run("readfile without argument throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -145,7 +145,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { t.Fatalf("Failed to create subfile: %v", err) } - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.readfile("subdir/subfile.txt")` result, err := vm.RunString(script) @@ -182,7 +182,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { } t.Run("list directory with relative path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir(".").sort())` result, err := vm.RunString(script) @@ -197,7 +197,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("list directory with absolute path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir("` + tempDir + `").sort())` result, err := vm.RunString(script) @@ -212,7 +212,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("list subdirectory", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir("subdir"))` result, err := vm.RunString(script) @@ -227,7 +227,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("list nonexistent directory throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -248,14 +248,14 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("path traversal with .. is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.listdir("../../../etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -269,14 +269,14 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("absolute path outside working directory is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.listdir("/etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -290,7 +290,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("listdir without argument throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -318,7 +318,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { t.Fatalf("Failed to create empty directory: %v", err) } - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `JSON.stringify(mxlint.io.listdir("empty"))` result, err := vm.RunString(script) @@ -333,7 +333,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) t.Run("listdir on file throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -369,7 +369,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { } t.Run("isdir returns true for directory with relative path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("subdir")` result, err := vm.RunString(script) @@ -383,7 +383,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns true for directory with absolute path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("` + filepath.Join(tempDir, "subdir") + `")` result, err := vm.RunString(script) @@ -397,7 +397,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns false for file", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("file.txt")` result, err := vm.RunString(script) @@ -411,7 +411,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns false for nonexistent path", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("nonexistent")` result, err := vm.RunString(script) @@ -425,7 +425,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir returns true for current directory", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir(".")` result, err := vm.RunString(script) @@ -439,14 +439,14 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("path traversal with .. is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.isdir("../../../etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -460,14 +460,14 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("absolute path outside working directory is blocked", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { mxlint.io.isdir("/etc"); "no error"; } catch (e) { - e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + e.message.includes("outside modelsource root") ? "blocked" : "other error: " + e.message; } ` result, err := vm.RunString(script) @@ -481,7 +481,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { }) t.Run("isdir without argument throws error", func(t *testing.T) { - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := ` try { @@ -509,7 +509,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { t.Fatalf("Failed to create nested directory: %v", err) } - vm := setupJavascriptVM(tempDir) + vm := setupJavascriptVM(tempDir, tempDir) script := `mxlint.io.isdir("subdir/nested")` result, err := vm.RunString(script) @@ -524,7 +524,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { } func TestMxlintObjectAvailable(t *testing.T) { - vm := setupJavascriptVM(".") + vm := setupJavascriptVM(".", ".") // Check that mxlint object is available script := `typeof mxlint` diff --git a/lint/lint_typscript.go b/lint/lint_typscript.go index 1bfc5f6..8f0c8eb 100644 --- a/lint/lint_typscript.go +++ b/lint/lint_typscript.go @@ -76,7 +76,7 @@ func transpileTypescriptRule(rulePath string) (string, error) { return code, nil } -func evalTestcase_Typescript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) { +func evalTestcase_Typescript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool, modelSourcePath string) (*Testcase, error) { ruleContent, err := transpileTypescriptRule(rulePath) if err != nil { return nil, err @@ -117,9 +117,13 @@ func evalTestcase_Typescript(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(ruleContent) if err != nil { panic(err) @@ -251,7 +255,7 @@ func runTypescriptTestCases(rule Rule) error { // Use the directory containing the rule file as the working directory workingDirectory := filepath.Dir(rule.Path) - vm := setupJavascriptVM(workingDirectory) + vm := setupJavascriptVM(workingDirectory, workingDirectory) _, err = vm.RunString(ruleContent) if err != nil { panic(err) diff --git a/lint/rules.go b/lint/rules.go index d9cda83..19e3f9a 100644 --- a/lint/rules.go +++ b/lint/rules.go @@ -129,7 +129,7 @@ func runJavaScriptTestCases(rule Rule) error { // Use the directory containing the rule file as the working directory workingDirectory := filepath.Dir(rule.Path) - vm := setupJavascriptVM(workingDirectory) + vm := setupJavascriptVM(workingDirectory, workingDirectory) _, err = vm.RunString(string(ruleContent)) if err != nil { panic(err) diff --git a/lint/utils.go b/lint/utils.go index 1f3af1c..e58c09e 100644 --- a/lint/utils.go +++ b/lint/utils.go @@ -16,6 +16,22 @@ func SetLogger(logger *logrus.Logger) { log = logger } +// resolveAllowedRoot returns a directory path that rules are allowed to access. +// If modelSourcePath is a file path, its directory is used instead. +func resolveAllowedRoot(modelSourcePath string) string { + if modelSourcePath == "" { + return "" + } + info, err := os.Stat(modelSourcePath) + if err != nil { + return modelSourcePath + } + if info.IsDir() { + return modelSourcePath + } + return filepath.Dir(modelSourcePath) +} + // parseNoqaDirective parses a noqa directive and returns the list of rules to skip // and the reason (if provided). // Supports two formats: diff --git a/resources/rules/001_0004_readfile_feature.js b/resources/rules/001_0004_readfile_feature.js index 0ea4359..8c44b9b 100644 --- a/resources/rules/001_0004_readfile_feature.js +++ b/resources/rules/001_0004_readfile_feature.js @@ -1,15 +1,15 @@ const metadata = { scope: "package", - title: "Project settings must have valid configuration", - description: "Validates project settings by reading related configuration files", + title: "Files in directory must not be empty", + description: "Validates that all files in the current directory contain content and are not empty", authors: ["Test "], custom: { - category: "Configuration", - rulename: "ProjectSettingsValidation", + category: "Integrity", + rulename: "NoEmptyFiles", severity: "LOW", rulenumber: "001_0004", - remediation: "Ensure Settings$ProjectSettings.yaml exists and contains valid configuration", - input: ".*Security\\$ProjectSecurity\\.yaml" + remediation: "Ensure all files in the directory have content. Remove or populate empty files.", + input: ".*Microflows\\$Microflow\\.yaml" } }; @@ -17,22 +17,24 @@ const metadata = { function rule(input = {}) { const errors = []; - // Use mxlint.readfile to read the Settings$ProjectSettings.yaml file - // which should be in the same directory as the Security$ProjectSecurity.yaml input file + // Use mxlint.io.listdir and mxlint.io.readfile to read file in another directory try { - const settingsContent = mxlint.io.readfile("Settings$ProjectSettings.yaml"); - // Check if the settings file contains expected content - if (!settingsContent.includes("$Type:")) { - errors.push("Settings file does not contain expected $Type field"); + const items = mxlint.io.listdir("."); + if (items.length === 0) { + errors.push("No items found in the current directory"); } - - // Verify we can read the content correctly - if (!settingsContent.includes("Settings$ProjectSettings")) { - errors.push("Settings file does not contain Settings$ProjectSettings type"); + for (const item of items) { + const itemPath = item; + if (!mxlint.io.isdir(itemPath)) { + const content = mxlint.io.readfile(itemPath); + if (content === "") { + errors.push("item " + itemPath + " is empty"); + } + } } } catch (e) { - errors.push("Failed to read Settings$ProjectSettings.yaml: " + e.message); + errors.push("Failed to read items in the current directory: " + e.message); } // Determine final authorization decision diff --git a/resources/rules/001_0004_readfile_feature_test.yaml b/resources/rules/001_0004_readfile_feature_test.yaml index 9018323..a561650 100644 --- a/resources/rules/001_0004_readfile_feature_test.yaml +++ b/resources/rules/001_0004_readfile_feature_test.yaml @@ -1,6 +1,7 @@ TestCases: -- name: validates_project_settings_exist +- name: validates_yaml_files_contain_type_field input: - EnableDemoUsers: false + $Type: "Core.Microflow" + Name: "TestMicroflow" allow: true From 20cf7c4005b31a08d87dc5a34dbe58f7e275d4ff Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Sat, 31 Jan 2026 17:03:24 +0100 Subject: [PATCH 2/5] update readme --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/README.md b/README.md index 9375efd..c7ef1c1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,122 @@ 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 | +| `--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. From daa61b378179a1c91fb12d4bdbacf09656017029 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Sat, 31 Jan 2026 17:11:03 +0100 Subject: [PATCH 3/5] Caching is now turned off by default. Turn on with --cache if desired --- README.md | 1 + lint/lint.go | 26 +++++++++++++------------- lint/lint_test.go | 6 +++--- main.go | 4 +++- serve/serve.go | 2 +- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c7ef1c1..6e04d10 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ mxlint-cli lint [flags] | `--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 | +| `--cache` | | `false` | Enable caching of lint results. When enabled, results are cached and reused if rules and model files haven't changed | | `--verbose` | | `false` | Turn on for debug logs | --- diff --git a/lint/lint.go b/lint/lint.go index e9e4f7b..b6b9ea4 100644 --- a/lint/lint.go +++ b/lint/lint.go @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) @@ -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, modelSourcePath) + 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, modelSourcePath) + // 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 } @@ -340,7 +340,7 @@ 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, modelSourcePath string) (*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 @@ -356,9 +356,9 @@ func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, ca 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 diff --git a/lint/lint_test.go b/lint/lint_test.go index 80bce0c..bb839bb 100644 --- a/lint/lint_test.go +++ b/lint/lint_test.go @@ -19,7 +19,7 @@ func TestLintSingle(t *testing.T) { // }) t.Run("single Rego rule passes", func(t *testing.T) { rule, _ := parseRuleMetadata_Rego("./../resources/rules/001_0003_security_checks.rego") - result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false) + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) if err != nil { t.Errorf("Failed to evaluate") @@ -31,7 +31,7 @@ func TestLintSingle(t *testing.T) { }) t.Run("single JS rule passes", func(t *testing.T) { rule, _ := parseRuleMetadata_Javascript("./../resources/rules/001_0002_demo_users_disabled.js") - result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false) + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) if err != nil { t.Errorf("Failed to evaluate") @@ -45,7 +45,7 @@ func TestLintSingle(t *testing.T) { func TestLintBundle(t *testing.T) { t.Run("all-rules", func(t *testing.T) { - err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", "", false) + err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", "", false, false) if err != nil { t.Errorf("No failures expected: %v", err) diff --git a/main.go b/main.go index 3a892f9..c4deaa6 100644 --- a/main.go +++ b/main.go @@ -60,6 +60,7 @@ func main() { JsonFile, _ := cmd.Flags().GetString("json-file") verbose, _ := cmd.Flags().GetBool("verbose") ignoreNoqa, _ := cmd.Flags().GetBool("ignore-noqa") + useCache, _ := cmd.Flags().GetBool("cache") log := logrus.New() if verbose { @@ -69,7 +70,7 @@ func main() { } lint.SetLogger(log) - err := lint.EvalAll(rulesDirectory, modelDirectory, xunitReport, JsonFile, ignoreNoqa) + err := lint.EvalAll(rulesDirectory, modelDirectory, xunitReport, JsonFile, ignoreNoqa, useCache) if err != nil { log.Errorf("lint failed: %s", err) os.Exit(1) @@ -83,6 +84,7 @@ func main() { cmdLint.Flags().StringP("json-file", "j", "", "Path to output file for JSON report. If not provided, no JSON file will be generated") cmdLint.Flags().Bool("verbose", false, "Turn on for debug logs") cmdLint.Flags().Bool("ignore-noqa", false, "Ignore noqa directives in documents") + cmdLint.Flags().Bool("cache", false, "Enable caching of lint results. When enabled, results are cached and reused if rules and model files haven't changed") rootCmd.AddCommand(cmdLint) // Add the serve command diff --git a/serve/serve.go b/serve/serve.go index 85996e2..0bbf819 100644 --- a/serve/serve.go +++ b/serve/serve.go @@ -219,7 +219,7 @@ func runServe(cmd *cobra.Command, args []string) { lintErr = fmt.Errorf("lint operation panicked: %v", r) } }() - results, lintErr = lint.EvalAllWithResults(rulesDirectory, outputDirectory, "", "", false) + results, lintErr = lint.EvalAllWithResults(rulesDirectory, outputDirectory, "", "", false, true) }() if lintErr != nil { From 026b6b2d0e7372e22116173c1e0968b7bf15bf4a Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Sat, 31 Jan 2026 17:15:47 +0100 Subject: [PATCH 4/5] default cache, allow disabling --- README.md | 2 +- main.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6e04d10..1b11719 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ mxlint-cli lint [flags] | `--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 | -| `--cache` | | `false` | Enable caching of lint results. When enabled, results are cached and reused if rules and model files haven't changed | +| `--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 | --- diff --git a/main.go b/main.go index c4deaa6..258d595 100644 --- a/main.go +++ b/main.go @@ -60,7 +60,7 @@ func main() { JsonFile, _ := cmd.Flags().GetString("json-file") verbose, _ := cmd.Flags().GetBool("verbose") ignoreNoqa, _ := cmd.Flags().GetBool("ignore-noqa") - useCache, _ := cmd.Flags().GetBool("cache") + noCache, _ := cmd.Flags().GetBool("no-cache") log := logrus.New() if verbose { @@ -70,7 +70,7 @@ func main() { } lint.SetLogger(log) - err := lint.EvalAll(rulesDirectory, modelDirectory, xunitReport, JsonFile, ignoreNoqa, useCache) + err := lint.EvalAll(rulesDirectory, modelDirectory, xunitReport, JsonFile, ignoreNoqa, !noCache) if err != nil { log.Errorf("lint failed: %s", err) os.Exit(1) @@ -84,7 +84,7 @@ func main() { cmdLint.Flags().StringP("json-file", "j", "", "Path to output file for JSON report. If not provided, no JSON file will be generated") cmdLint.Flags().Bool("verbose", false, "Turn on for debug logs") cmdLint.Flags().Bool("ignore-noqa", false, "Ignore noqa directives in documents") - cmdLint.Flags().Bool("cache", false, "Enable caching of lint results. When enabled, results are cached and reused if rules and model files haven't changed") + cmdLint.Flags().Bool("no-cache", false, "Disable caching of lint results. By default, results are cached and reused if rules and model files haven't changed") rootCmd.AddCommand(cmdLint) // Add the serve command From ca109a099693b50270d07c4ab39146a1f788de92 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Sat, 31 Jan 2026 17:32:28 +0100 Subject: [PATCH 5/5] add more tests --- lint/lint_rego_test.go | 562 +++++++++++++++++++++++++++++++ lint/lint_test.go | 622 +++++++++++++++++++++++++++++++++-- lint/lint_typescript_test.go | 534 ++++++++++++++++++++++++++++++ 3 files changed, 1695 insertions(+), 23 deletions(-) create mode 100644 lint/lint_rego_test.go create mode 100644 lint/lint_typescript_test.go diff --git a/lint/lint_rego_test.go b/lint/lint_rego_test.go new file mode 100644 index 0000000..60080ae --- /dev/null +++ b/lint/lint_rego_test.go @@ -0,0 +1,562 @@ +package lint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseRuleMetadata_Rego(t *testing.T) { + tempDir := t.TempDir() + + t.Run("parse valid metadata", func(t *testing.T) { + regoContent := `# METADATA +# scope: package +# title: Test Rego Rule +# description: A test rule for validation +# authors: +# - Test Author +# custom: +# category: Testing +# rulename: TestRegoRule +# severity: HIGH +# rulenumber: "001_0001" +# remediation: Fix the issue +# input: .*\.yaml +package test.rule + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + not input.Name + error := "Name is required" +} +` + regoPath := filepath.Join(tempDir, "valid_metadata.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.Title != "Test Rego Rule" { + t.Errorf("Expected title 'Test Rego Rule', got %q", rule.Title) + } + if rule.Description != "A test rule for validation" { + t.Errorf("Expected description 'A test rule for validation', got %q", rule.Description) + } + if rule.Category != "Testing" { + t.Errorf("Expected category 'Testing', got %q", rule.Category) + } + if rule.Severity != "HIGH" { + t.Errorf("Expected severity 'HIGH', got %q", rule.Severity) + } + if rule.RuleNumber != "001_0001" { + t.Errorf("Expected rulenumber '001_0001', got %q", rule.RuleNumber) + } + if rule.Remediation != "Fix the issue" { + t.Errorf("Expected remediation 'Fix the issue', got %q", rule.Remediation) + } + if rule.RuleName != "TestRegoRule" { + t.Errorf("Expected rulename 'TestRegoRule', got %q", rule.RuleName) + } + if rule.Pattern != ".*\\.yaml" { + t.Errorf("Expected pattern '.*\\.yaml', got %q", rule.Pattern) + } + if rule.PackageName != "test.rule" { + t.Errorf("Expected package name 'test.rule', got %q", rule.PackageName) + } + if rule.Language != LanguageRego { + t.Errorf("Expected language 'rego', got %q", rule.Language) + } + if rule.Path != regoPath { + t.Errorf("Expected path %q, got %q", regoPath, rule.Path) + } + }) + + t.Run("parse metadata with unquoted rulenumber", func(t *testing.T) { + regoContent := `# METADATA +# scope: package +# title: Unquoted Rulenumber Test +# custom: +# rulenumber: 002_0002 +package test.unquoted + +default allow := true +` + regoPath := filepath.Join(tempDir, "unquoted_rulenumber.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.Title != "Unquoted Rulenumber Test" { + t.Errorf("Expected title 'Unquoted Rulenumber Test', got %q", rule.Title) + } + // The rulenumber should be preserved as a string + if rule.RuleNumber != "002_0002" { + t.Errorf("Expected rulenumber '002_0002', got %q", rule.RuleNumber) + } + }) + + t.Run("parse without metadata", func(t *testing.T) { + regoContent := `package test.no_metadata + +default allow := true +` + regoPath := filepath.Join(tempDir, "no_metadata.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.PackageName != "test.no_metadata" { + t.Errorf("Expected package name 'test.no_metadata', got %q", rule.PackageName) + } + if rule.Title != "" { + t.Errorf("Expected empty title, got %q", rule.Title) + } + if rule.Language != LanguageRego { + t.Errorf("Expected language 'rego', got %q", rule.Language) + } + }) + + t.Run("parse nonexistent file returns error", func(t *testing.T) { + _, err := parseRuleMetadata_Rego(filepath.Join(tempDir, "nonexistent.rego")) + if err == nil { + t.Error("Expected error for nonexistent file") + } + }) + + t.Run("parse empty metadata block", func(t *testing.T) { + regoContent := `# METADATA +package test.empty_metadata + +default allow := true +` + regoPath := filepath.Join(tempDir, "empty_metadata.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Rego(regoPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.PackageName != "test.empty_metadata" { + t.Errorf("Expected package name 'test.empty_metadata', got %q", rule.PackageName) + } + }) +} + +func TestEvalTestcase_Rego(t *testing.T) { + tempDir := t.TempDir() + + t.Run("evaluate passing rule", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0001" +package test.pass + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "pass_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Name: "TestEntity"` + yamlPath := filepath.Join(tempDir, "pass_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.pass", yamlPath, "001_0001", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure, got: %s", testcase.Failure.Message) + } + if testcase.Name != yamlPath { + t.Errorf("Expected name %q, got %q", yamlPath, testcase.Name) + } + }) + + t.Run("evaluate failing rule", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0002" +package test.fail + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + not input.Name + error := "Name is required" +} +` + regoPath := filepath.Join(tempDir, "fail_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Value: "NoNameField"` + yamlPath := filepath.Join(tempDir, "fail_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.fail", yamlPath, "001_0002", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure == nil { + t.Error("Expected failure, got nil") + } else if testcase.Failure.Message != "Name is required" { + t.Errorf("Expected message 'Name is required', got %q", testcase.Failure.Message) + } + if testcase.Failure.Type != "AssertionError" { + t.Errorf("Expected type 'AssertionError', got %q", testcase.Failure.Type) + } + }) + + t.Run("evaluate skipped rule with noqa", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0003" +package test.noqa + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains "Always fails" +` + regoPath := filepath.Join(tempDir, "noqa_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0003" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.noqa", yamlPath, "001_0003", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped == nil { + t.Error("Expected testcase to be skipped") + } + if testcase.Failure != nil { + t.Error("Expected no failure when skipped") + } + }) + + t.Run("noqa ignored when ignoreNoqa is true", func(t *testing.T) { + regoContent := `# METADATA +# title: Test Rule +# custom: +# rulenumber: "001_0004" +package test.ignore_noqa + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains "Always fails" +` + regoPath := filepath.Join(tempDir, "ignore_noqa_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0004" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "ignore_noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.ignore_noqa", yamlPath, "001_0004", true) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped != nil { + t.Error("Expected testcase not to be skipped when ignoreNoqa is true") + } + if testcase.Failure == nil { + t.Error("Expected failure when ignoreNoqa is true") + } + }) + + t.Run("error reading nonexistent input file", func(t *testing.T) { + regoContent := `package test.error + +import rego.v1 + +default allow := true + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "error_test_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + _, err = evalTestcase_Rego(regoPath, "data.test.error", filepath.Join(tempDir, "nonexistent.yaml"), "001_0005", false) + if err == nil { + t.Error("Expected error for nonexistent input file") + } + }) + + t.Run("evaluate rule with multiple errors", func(t *testing.T) { + regoContent := `# METADATA +# title: Multiple Errors Rule +# custom: +# rulenumber: "001_0006" +package test.multiple_errors + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + not input.Name + error := "Name is required" +} + +errors contains error if { + not input.Description + error := "Description is required" +} +` + regoPath := filepath.Join(tempDir, "multiple_errors_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Value: "NoRequiredFields"` + yamlPath := filepath.Join(tempDir, "multiple_errors_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.multiple_errors", yamlPath, "001_0006", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure == nil { + t.Error("Expected failure, got nil") + } + // Multiple errors should be joined with newlines + if testcase.Failure != nil { + // Both errors should be present (order may vary) + msg := testcase.Failure.Message + hasNameError := containsSubstring(msg, "Name is required") + hasDescError := containsSubstring(msg, "Description is required") + if !hasNameError || !hasDescError { + t.Errorf("Expected both errors in message, got: %q", msg) + } + } + }) + + t.Run("evaluate rule with complex input", func(t *testing.T) { + regoContent := `# METADATA +# title: Complex Input Rule +# custom: +# rulenumber: "001_0007" +package test.complex + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains error if { + count(input.Items) < 2 + error := "At least 2 items required" +} +` + regoPath := filepath.Join(tempDir, "complex_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := ` +Items: + - name: "Item1" + - name: "Item2" + - name: "Item3" +` + yamlPath := filepath.Join(tempDir, "complex_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.complex", yamlPath, "001_0007", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure with 3 items, got: %s", testcase.Failure.Message) + } + }) + + t.Run("testcase time is recorded", func(t *testing.T) { + regoContent := `package test.time + +import rego.v1 + +default allow := true + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "time_rule.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "time_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Rego(regoPath, "data.test.time", yamlPath, "001_0008", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Time <= 0 { + t.Error("Expected positive time value") + } + }) +} + +func TestQuoteRegoMetadataRulenumberIntegration(t *testing.T) { + // Integration test to ensure rulenumber quoting works correctly during eval + tempDir := t.TempDir() + + t.Run("unquoted rulenumber with leading zero is handled", func(t *testing.T) { + // This tests the integration of quoteRegoMetadataRulenumber with evalTestcase_Rego + regoContent := `# METADATA +# title: Leading Zero Rule +# custom: +# rulenumber: 002_0001 +package test.leading_zero + +import rego.v1 + +default allow := true + +errors contains error if { + false + error := "never triggered" +} +` + regoPath := filepath.Join(tempDir, "leading_zero.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "leading_zero_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + // This should not fail due to YAML 1.1 octal interpretation + testcase, err := evalTestcase_Rego(regoPath, "data.test.leading_zero", yamlPath, "002_0001", false) + if err != nil { + t.Fatalf("Failed to evaluate testcase (possibly rulenumber quoting issue): %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure, got: %s", testcase.Failure.Message) + } + }) +} + +// Helper function to check if string contains substring +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/lint/lint_test.go b/lint/lint_test.go index bb839bb..a99e97a 100644 --- a/lint/lint_test.go +++ b/lint/lint_test.go @@ -1,54 +1,630 @@ package lint import ( + "os" + "path/filepath" "testing" ) -// TestAdd tests the Add function to ensure it returns correct results. -func TestLintSingle(t *testing.T) { - // t.Run("single policy skipped", func(t *testing.T) { - // result, err := evalTestsuite("./../policies/001_project_settings/001_0004_strong_password.rego", "./../resources/modelsource-v1") - - // if err != nil { - // t.Errorf("Failed to evaluate") - // } - - // if result.Skipped != 1 { - // t.Errorf("Policy not skipped") - // } - // }) +func TestEvalTestsuite_Rego(t *testing.T) { t.Run("single Rego rule passes", func(t *testing.T) { - rule, _ := parseRuleMetadata_Rego("./../resources/rules/001_0003_security_checks.rego") - result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) + rule, err := parseRuleMetadata_Rego("./../resources/rules/001_0003_security_checks.rego") + if err != nil { + t.Fatalf("Failed to parse rule metadata: %v", err) + } + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) if err != nil { - t.Errorf("Failed to evaluate") + t.Fatalf("Failed to evaluate testsuite: %v", err) } if result.Failures != 0 { - t.Errorf("Policy passes") + t.Errorf("Expected no failures, got %d", result.Failures) + } + if result.Tests == 0 { + t.Error("Expected at least one test case") + } + }) + + t.Run("Rego rule with failures", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that always fails + regoContent := `# METADATA +# title: Always Fail Rule +# custom: +# rulenumber: "099_0001" +# input: .*\.yaml +package test.always_fail + +import rego.v1 + +default allow := false +allow if count(errors) == 0 + +errors contains "Always fails" +` + regoPath := filepath.Join(tempDir, "always_fail.rego") + err := os.WriteFile(regoPath, []byte(regoContent), 0644) + if err != nil { + t.Fatalf("Failed to write rego file: %v", err) + } + + // Create a test input file + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: regoPath, + Pattern: ".*\\.yaml", + PackageName: "test.always_fail", + RuleNumber: "099_0001", + Language: LanguageRego, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Failures != 1 { + t.Errorf("Expected 1 failure, got %d", result.Failures) } }) +} + +func TestEvalTestsuite_Javascript(t *testing.T) { t.Run("single JS rule passes", func(t *testing.T) { - rule, _ := parseRuleMetadata_Javascript("./../resources/rules/001_0002_demo_users_disabled.js") + rule, err := parseRuleMetadata_Javascript("./../resources/rules/001_0002_demo_users_disabled.js") + if err != nil { + t.Fatalf("Failed to parse rule metadata: %v", err) + } + + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Failures != 0 { + t.Errorf("Expected no failures, got %d", result.Failures) + } + if result.Tests == 0 { + t.Error("Expected at least one test case") + } + }) + + t.Run("JS rule with failures", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that always fails + jsContent := ` +const metadata = { + title: "Always Fail Rule", + custom: { rulenumber: "099_0002", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; +} +` + jsPath := filepath.Join(tempDir, "always_fail.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create a test input file + yamlContent := `Name: "Test"` + yamlPath := filepath.Join(tempDir, "input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0002", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Failures != 1 { + t.Errorf("Expected 1 failure, got %d", result.Failures) + } + }) +} + +func TestEvalTestsuite_Typescript(t *testing.T) { + t.Run("single TS rule passes", func(t *testing.T) { + rule, err := parseRuleMetadata_Typescript("./../resources/rules/001_0005_typescript_example.ts") + if err != nil { + t.Fatalf("Failed to parse rule metadata: %v", err) + } + result, err := evalTestsuite(*rule, "./../resources/modelsource-v1", false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Failures != 0 { + t.Errorf("Expected no failures, got %d", result.Failures) + } + }) +} + +func TestEvalTestsuite_WithNoqa(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that always fails + jsContent := ` +const metadata = { + title: "Noqa Test Rule", + custom: { rulenumber: "099_0003", input: ".*\\.yaml" } +}; +function rule(input) { + return { allow: false, errors: ["Should be skipped"] }; +} +` + jsPath := filepath.Join(tempDir, "noqa_test.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create input file with noqa directive + yamlContent := ` +Documentation: "#noqa:099_0003" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0003", + Language: LanguageJavascript, + } + + t.Run("noqa skips the rule", func(t *testing.T) { + result, err := evalTestsuite(rule, tempDir, false, false) if err != nil { - t.Errorf("Failed to evaluate") + t.Fatalf("Failed to evaluate testsuite: %v", err) } + if result.Skipped != 1 { + t.Errorf("Expected 1 skipped, got %d", result.Skipped) + } if result.Failures != 0 { - t.Errorf("Policy passes") + t.Errorf("Expected 0 failures when skipped, got %d", result.Failures) + } + }) + + t.Run("ignoreNoqa runs the rule anyway", func(t *testing.T) { + result, err := evalTestsuite(rule, tempDir, true, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Skipped != 0 { + t.Errorf("Expected 0 skipped when ignoreNoqa=true, got %d", result.Skipped) + } + if result.Failures != 1 { + t.Errorf("Expected 1 failure when ignoreNoqa=true, got %d", result.Failures) + } + }) +} + +func TestEvalTestsuite_MultipleFiles(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule that checks for Name field + jsContent := ` +const metadata = { + title: "Name Required Rule", + custom: { rulenumber: "099_0004", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + jsPath := filepath.Join(tempDir, "name_required.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create passing input file + err = os.WriteFile(filepath.Join(tempDir, "pass.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + // Create failing input file + err = os.WriteFile(filepath.Join(tempDir, "fail.yaml"), []byte(`Value: "NoName"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0004", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Tests != 2 { + t.Errorf("Expected 2 tests, got %d", result.Tests) + } + if result.Failures != 1 { + t.Errorf("Expected 1 failure, got %d", result.Failures) + } +} + +func TestCountTotalTestcases(t *testing.T) { + tests := []struct { + name string + testsuites []Testsuite + expected int + }{ + { + name: "Empty testsuites", + testsuites: []Testsuite{}, + expected: 0, + }, + { + name: "Single testsuite with one testcase", + testsuites: []Testsuite{ + {Testcases: []Testcase{{Name: "test1"}}}, + }, + expected: 1, + }, + { + name: "Multiple testsuites", + testsuites: []Testsuite{ + {Testcases: []Testcase{{Name: "test1"}, {Name: "test2"}}}, + {Testcases: []Testcase{{Name: "test3"}}}, + {Testcases: []Testcase{{Name: "test4"}, {Name: "test5"}, {Name: "test6"}}}, + }, + expected: 6, + }, + { + name: "Testsuite with no testcases", + testsuites: []Testsuite{ + {Testcases: []Testcase{}}, + {Testcases: []Testcase{{Name: "test1"}}}, + }, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := countTotalTestcases(tt.testsuites) + if result != tt.expected { + t.Errorf("Expected %d, got %d", tt.expected, result) + } + }) + } +} + +func TestReadRulesMetadata(t *testing.T) { + t.Run("read rules from resources directory", func(t *testing.T) { + rules, err := ReadRulesMetadata("./../resources/rules") + if err != nil { + t.Fatalf("Failed to read rules metadata: %v", err) + } + + if len(rules) == 0 { + t.Error("Expected at least one rule") + } + + // Verify we have different languages + hasRego := false + hasJS := false + hasTS := false + for _, rule := range rules { + switch rule.Language { + case LanguageRego: + hasRego = true + case LanguageJavascript: + hasJS = true + case LanguageTypescript: + hasTS = true + } + } + + if !hasRego { + t.Error("Expected at least one Rego rule") + } + if !hasJS { + t.Error("Expected at least one JavaScript rule") + } + if !hasTS { + t.Error("Expected at least one TypeScript rule") + } + }) + + t.Run("read from empty directory", func(t *testing.T) { + tempDir := t.TempDir() + rules, err := ReadRulesMetadata(tempDir) + if err != nil { + t.Fatalf("Failed to read rules metadata: %v", err) + } + + if len(rules) != 0 { + t.Errorf("Expected 0 rules from empty directory, got %d", len(rules)) + } + }) + + t.Run("ignores test files", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a regular rule with proper metadata structure + jsContent := ` +const metadata = { + scope: "package", + title: "Test Rule", + description: "A test rule", + custom: { + category: "Test", + rulename: "TestRule", + severity: "LOW", + rulenumber: "001_0001", + remediation: "Fix it", + input: ".*\\.yaml" + } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + err := os.WriteFile(filepath.Join(tempDir, "rule.js"), []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create a test file (should be ignored) + err = os.WriteFile(filepath.Join(tempDir, "rule_test.js"), []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rules, err := ReadRulesMetadata(tempDir) + if err != nil { + t.Fatalf("Failed to read rules metadata: %v", err) + } + + if len(rules) != 1 { + t.Errorf("Expected 1 rule (test file should be ignored), got %d", len(rules)) } }) } -func TestLintBundle(t *testing.T) { - t.Run("all-rules", func(t *testing.T) { +func TestEvalAll(t *testing.T) { + t.Run("all rules pass", func(t *testing.T) { err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", "", false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + }) + + t.Run("with xunit report", func(t *testing.T) { + tempDir := t.TempDir() + xunitPath := filepath.Join(tempDir, "report.xml") + + err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", xunitPath, "", false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + + // Verify report was created + if _, err := os.Stat(xunitPath); os.IsNotExist(err) { + t.Error("Expected xunit report to be created") + } + }) + t.Run("with json report", func(t *testing.T) { + tempDir := t.TempDir() + jsonPath := filepath.Join(tempDir, "report.json") + + err := EvalAll("./../resources/rules", "./../resources/modelsource-v1", "", jsonPath, false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + + // Verify report was created + if _, err := os.Stat(jsonPath); os.IsNotExist(err) { + t.Error("Expected json report to be created") + } + }) +} + +func TestEvalAllWithResults(t *testing.T) { + t.Run("returns results", func(t *testing.T) { + result, err := EvalAllWithResults("./../resources/rules", "./../resources/modelsource-v1", "", "", false, false) + if err != nil { + t.Errorf("Expected no failures: %v", err) + } + + testSuites, ok := result.(TestSuites) + if !ok { + t.Fatal("Expected result to be TestSuites") + } + + if len(testSuites.Testsuites) == 0 { + t.Error("Expected at least one testsuite") + } + if len(testSuites.Rules) == 0 { + t.Error("Expected at least one rule in results") + } + }) + + t.Run("reports failures correctly", func(t *testing.T) { + tempDir := t.TempDir() + + // Create a failing rule with proper metadata structure + jsContent := ` +const metadata = { + scope: "package", + title: "Always Fail Rule", + description: "This rule always fails", + custom: { + category: "Test", + rulename: "AlwaysFailRule", + severity: "HIGH", + rulenumber: "099_0099", + remediation: "Cannot be fixed", + input: ".*\\.yaml" + } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; +} +` + jsPath := filepath.Join(tempDir, "fail.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create input file + yamlPath := filepath.Join(tempDir, "input.yaml") + err = os.WriteFile(yamlPath, []byte(`Name: "Test"`), 0644) if err != nil { - t.Errorf("No failures expected: %v", err) + t.Fatalf("Failed to write yaml file: %v", err) + } + + _, err = EvalAllWithResults(tempDir, tempDir, "", "", false, false) + if err == nil { + t.Error("Expected error due to failures") } }) } + +func TestEvalTestsuite_PatternMatching(t *testing.T) { + tempDir := t.TempDir() + + // Create a rule with specific pattern + jsContent := ` +const metadata = { + title: "Pattern Test Rule", + custom: { rulenumber: "099_0005", input: ".*\\.entity\\.yaml" } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + jsPath := filepath.Join(tempDir, "pattern_test.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + // Create matching file + err = os.WriteFile(filepath.Join(tempDir, "test.entity.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + // Create non-matching file + err = os.WriteFile(filepath.Join(tempDir, "test.other.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.entity\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0005", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + // Only the .entity.yaml file should match + if result.Tests != 1 { + t.Errorf("Expected 1 test (pattern should match only .entity.yaml), got %d", result.Tests) + } +} + +func TestEvalTestsuite_TimeTracking(t *testing.T) { + tempDir := t.TempDir() + + jsContent := ` +const metadata = { + title: "Time Test Rule", + custom: { rulenumber: "099_0006", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + jsPath := filepath.Join(tempDir, "time_test.js") + err := os.WriteFile(jsPath, []byte(jsContent), 0644) + if err != nil { + t.Fatalf("Failed to write js file: %v", err) + } + + err = os.WriteFile(filepath.Join(tempDir, "input.yaml"), []byte(`Name: "Test"`), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + rule := Rule{ + Path: jsPath, + Pattern: ".*\\.yaml", + PackageName: jsPath, + RuleNumber: "099_0006", + Language: LanguageJavascript, + } + + result, err := evalTestsuite(rule, tempDir, false, false) + if err != nil { + t.Fatalf("Failed to evaluate testsuite: %v", err) + } + + if result.Time <= 0 { + t.Error("Expected positive total time") + } + + for _, tc := range result.Testcases { + if tc.Time <= 0 { + t.Errorf("Expected positive time for testcase %s", tc.Name) + } + } +} diff --git a/lint/lint_typescript_test.go b/lint/lint_typescript_test.go new file mode 100644 index 0000000..3fac732 --- /dev/null +++ b/lint/lint_typescript_test.go @@ -0,0 +1,534 @@ +package lint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHashRuleContent(t *testing.T) { + tests := []struct { + name string + content []byte + expected string + }{ + { + name: "Empty content", + content: []byte(""), + expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "Simple content", + content: []byte("hello world"), + expected: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + }, + { + name: "Same content produces same hash", + content: []byte("test content"), + expected: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hashRuleContent(tt.content) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestTranspileTypescriptRule(t *testing.T) { + tempDir := t.TempDir() + + t.Run("transpile valid TypeScript", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0001" } +}; + +function rule(input: { Name?: string }): { allow: boolean; errors: string[] } { + const errors: string[] = []; + return { allow: true, errors }; +} +` + tsPath := filepath.Join(tempDir, "test_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + result, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("Failed to transpile TypeScript: %v", err) + } + + // Check that TypeScript type annotations are removed + if result == "" { + t.Error("Expected non-empty transpiled code") + } + + // TypeScript types should be removed + if containsString(result, ": string[]") { + t.Error("Expected TypeScript type annotations to be removed") + } + }) + + t.Run("transpile caches result", func(t *testing.T) { + tsContent := `const x: number = 1;` + tsPath := filepath.Join(tempDir, "cached_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + // First call + result1, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("First transpile failed: %v", err) + } + + // Second call should use cache + result2, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("Second transpile failed: %v", err) + } + + if result1 != result2 { + t.Error("Expected cached result to match original result") + } + }) + + t.Run("cache invalidated on content change", func(t *testing.T) { + tsPath := filepath.Join(tempDir, "changing_rule.ts") + + // Write initial content + err := os.WriteFile(tsPath, []byte(`const x: number = 1;`), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + result1, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("First transpile failed: %v", err) + } + + // Modify the file + err = os.WriteFile(tsPath, []byte(`const y: number = 2;`), 0644) + if err != nil { + t.Fatalf("Failed to modify test file: %v", err) + } + + result2, err := transpileTypescriptRule(tsPath) + if err != nil { + t.Fatalf("Second transpile failed: %v", err) + } + + if result1 == result2 { + t.Error("Expected different result after content change") + } + }) + + t.Run("transpile nonexistent file returns error", func(t *testing.T) { + _, err := transpileTypescriptRule(filepath.Join(tempDir, "nonexistent.ts")) + if err == nil { + t.Error("Expected error for nonexistent file") + } + }) + + t.Run("transpile invalid TypeScript returns error", func(t *testing.T) { + tsContent := ` +function rule(input { // missing colon - syntax error + return { allow: true }; +} +` + tsPath := filepath.Join(tempDir, "invalid_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = transpileTypescriptRule(tsPath) + if err == nil { + t.Error("Expected error for invalid TypeScript") + } + }) +} + +func TestParseRuleMetadata_Typescript(t *testing.T) { + tempDir := t.TempDir() + + t.Run("parse valid metadata", func(t *testing.T) { + tsContent := ` +const metadata = { + scope: "package", + title: "Test TypeScript Rule", + description: "A test rule for validation", + authors: ["Test Author "], + custom: { + category: "Testing", + rulename: "TestTypescriptRule", + severity: "HIGH", + rulenumber: "001_0001", + remediation: "Fix the issue", + input: ".*\\.yaml" + } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + tsPath := filepath.Join(tempDir, "valid_metadata.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + rule, err := parseRuleMetadata_Typescript(tsPath) + if err != nil { + t.Fatalf("Failed to parse metadata: %v", err) + } + + if rule.Title != "Test TypeScript Rule" { + t.Errorf("Expected title 'Test TypeScript Rule', got %q", rule.Title) + } + if rule.Description != "A test rule for validation" { + t.Errorf("Expected description 'A test rule for validation', got %q", rule.Description) + } + if rule.Category != "Testing" { + t.Errorf("Expected category 'Testing', got %q", rule.Category) + } + if rule.Severity != "HIGH" { + t.Errorf("Expected severity 'HIGH', got %q", rule.Severity) + } + if rule.RuleNumber != "001_0001" { + t.Errorf("Expected rulenumber '001_0001', got %q", rule.RuleNumber) + } + if rule.Remediation != "Fix the issue" { + t.Errorf("Expected remediation 'Fix the issue', got %q", rule.Remediation) + } + if rule.RuleName != "TestTypescriptRule" { + t.Errorf("Expected rulename 'TestTypescriptRule', got %q", rule.RuleName) + } + if rule.Pattern != ".*\\.yaml" { + t.Errorf("Expected pattern '.*\\.yaml', got %q", rule.Pattern) + } + if rule.Language != LanguageTypescript { + t.Errorf("Expected language 'typescript', got %q", rule.Language) + } + if rule.Path != tsPath { + t.Errorf("Expected path %q, got %q", tsPath, rule.Path) + } + }) +} + +func TestEvalTestcase_Typescript(t *testing.T) { + tempDir := t.TempDir() + + t.Run("evaluate passing rule", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0001", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + tsPath := filepath.Join(tempDir, "pass_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := `Name: "TestEntity"` + yamlPath := filepath.Join(tempDir, "pass_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0001", false, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure != nil { + t.Errorf("Expected no failure, got: %s", testcase.Failure.Message) + } + if testcase.Name != yamlPath { + t.Errorf("Expected name %q, got %q", yamlPath, testcase.Name) + } + }) + + t.Run("evaluate failing rule", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0002", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + tsPath := filepath.Join(tempDir, "fail_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := `Value: "NoNameField"` + yamlPath := filepath.Join(tempDir, "fail_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0002", false, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Failure == nil { + t.Error("Expected failure, got nil") + } else if testcase.Failure.Message != "Name is required" { + t.Errorf("Expected message 'Name is required', got %q", testcase.Failure.Message) + } + }) + + t.Run("evaluate skipped rule with noqa", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0003", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; +} +` + tsPath := filepath.Join(tempDir, "noqa_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0003" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0003", false, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped == nil { + t.Error("Expected testcase to be skipped") + } + if testcase.Failure != nil { + t.Error("Expected no failure when skipped") + } + }) + + t.Run("noqa ignored when ignoreNoqa is true", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0004", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: false, errors: ["Always fails"] }; +} +` + tsPath := filepath.Join(tempDir, "ignore_noqa_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + yamlContent := ` +Documentation: "#noqa:001_0004" +Name: "Test" +` + yamlPath := filepath.Join(tempDir, "ignore_noqa_input.yaml") + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write yaml file: %v", err) + } + + testcase, err := evalTestcase_Typescript(tsPath, yamlPath, "001_0004", true, tempDir) + if err != nil { + t.Fatalf("Failed to evaluate testcase: %v", err) + } + + if testcase.Skipped != nil { + t.Error("Expected testcase not to be skipped when ignoreNoqa is true") + } + if testcase.Failure == nil { + t.Error("Expected failure when ignoreNoqa is true") + } + }) + + t.Run("error reading nonexistent input file", func(t *testing.T) { + tsContent := ` +const metadata = { title: "Test", custom: { rulenumber: "001_0005" } }; +function rule(input) { return { allow: true, errors: [] }; } +` + tsPath := filepath.Join(tempDir, "error_test_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + _, err = evalTestcase_Typescript(tsPath, filepath.Join(tempDir, "nonexistent.yaml"), "001_0005", false, tempDir) + if err == nil { + t.Error("Expected error for nonexistent input file") + } + }) +} + +func TestRunTypescriptTestCases(t *testing.T) { + tempDir := t.TempDir() + + t.Run("run passing test cases", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0001", input: ".*\\.yaml" } +}; + +function rule(input) { + const errors = []; + if (!input.Name) { + errors.push("Name is required"); + } + return { allow: errors.length === 0, errors }; +} +` + tsPath := filepath.Join(tempDir, "testcase_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + testYamlContent := ` +TestCases: + - name: "Test with name present" + input: + Name: "TestName" + allow: true + - name: "Test with name missing" + input: + Value: "SomeValue" + allow: false +` + testYamlPath := filepath.Join(tempDir, "testcase_rule_test.yaml") + err = os.WriteFile(testYamlPath, []byte(testYamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write test yaml file: %v", err) + } + + rule := Rule{ + Path: tsPath, + Language: LanguageTypescript, + } + + err = runTypescriptTestCases(rule) + if err != nil { + t.Errorf("Expected test cases to pass, got error: %v", err) + } + }) + + t.Run("run failing test cases", func(t *testing.T) { + tsContent := ` +const metadata = { + title: "Test Rule", + custom: { rulenumber: "001_0002", input: ".*\\.yaml" } +}; + +function rule(input) { + return { allow: true, errors: [] }; +} +` + tsPath := filepath.Join(tempDir, "failing_testcase_rule.ts") + err := os.WriteFile(tsPath, []byte(tsContent), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + testYamlContent := ` +TestCases: + - name: "This should fail because rule always allows but test expects deny" + input: + Name: "TestName" + allow: false +` + testYamlPath := filepath.Join(tempDir, "failing_testcase_rule_test.yaml") + err = os.WriteFile(testYamlPath, []byte(testYamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write test yaml file: %v", err) + } + + rule := Rule{ + Path: tsPath, + Language: LanguageTypescript, + } + + err = runTypescriptTestCases(rule) + if err == nil { + t.Error("Expected test cases to fail") + } + }) + + t.Run("test file not found", func(t *testing.T) { + tsPath := filepath.Join(tempDir, "no_test_file.ts") + err := os.WriteFile(tsPath, []byte(`const metadata = {}; function rule(input) { return { allow: true, errors: [] }; }`), 0644) + if err != nil { + t.Fatalf("Failed to write rule file: %v", err) + } + + rule := Rule{ + Path: tsPath, + Language: LanguageTypescript, + } + + err = runTypescriptTestCases(rule) + if err == nil { + t.Error("Expected error when test file is missing") + } + }) +} + +// Helper function to check if a string contains a substring +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStringHelper(s, substr)) +} + +func containsStringHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}