diff --git a/lint/cache.go b/lint/cache.go index 0e51ecf..3ab936a 100644 --- a/lint/cache.go +++ b/lint/cache.go @@ -7,11 +7,12 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "sync" ) -const cacheVersion = "v1" +const cacheVersion = "v2" var cacheDirConfig = struct { mu sync.RWMutex @@ -32,8 +33,9 @@ func getConfiguredCacheDirectory() string { // CacheKey represents the unique identifier for a cached result type CacheKey struct { - RuleHash string `json:"rule_hash"` - InputHash string `json:"input_hash"` + RuleHash string `json:"rule_hash"` + InputHash string `json:"input_hash"` + ConfigHash string `json:"config_hash"` } // CachedTestcase represents a cached testcase result @@ -98,12 +100,63 @@ func createCacheKey(rulePath string, inputFilePath string) (*CacheKey, error) { return nil, err } + configHash := computeCacheConfigHash() + return &CacheKey{ - RuleHash: ruleHash, - InputHash: inputHash, + RuleHash: ruleHash, + InputHash: inputHash, + ConfigHash: configHash, }, nil } +// computeCacheConfigHash returns a stable hash of config fields that can +// influence lint outcomes while still allowing cache reuse when irrelevant +// settings (e.g. verbosity) change. +func computeCacheConfigHash() string { + cfg := getConfig() + if cfg == nil || len(cfg.Lint.Skip) == 0 { + sum := sha256.Sum256([]byte("skip:{}")) + return fmt.Sprintf("%x", sum[:]) + } + + normalizedSkip := map[string][]ConfigSkipRule{} + keys := make([]string, 0, len(cfg.Lint.Skip)) + for path, entries := range cfg.Lint.Skip { + normalizedPath := normalizeSkipPath(path) + if normalizedPath == "" { + continue + } + if _, exists := normalizedSkip[normalizedPath]; !exists { + keys = append(keys, normalizedPath) + } + normalizedSkip[normalizedPath] = append([]ConfigSkipRule{}, entries...) + } + slices.Sort(keys) + + var builder strings.Builder + builder.WriteString("skip:{") + for _, key := range keys { + builder.WriteString(key) + builder.WriteString("=[") + entries := normalizedSkip[key] + for idx, entry := range entries { + if idx > 0 { + builder.WriteString(",") + } + builder.WriteString(entry.Rule) + builder.WriteString("|") + builder.WriteString(entry.Reason) + builder.WriteString("|") + builder.WriteString(entry.Date) + } + builder.WriteString("];") + } + builder.WriteString("}") + + sum := sha256.Sum256([]byte(builder.String())) + return fmt.Sprintf("%x", sum[:]) +} + // loadCachedTestcase loads a testcase from cache if it exists func loadCachedTestcase(cacheKey CacheKey) (*Testcase, bool) { cachePath, err := getCachePath(cacheKey) @@ -139,7 +192,9 @@ func loadCachedTestcase(cacheKey CacheKey) (*Testcase, bool) { } // Verify cache key matches - if cached.CacheKey.RuleHash != cacheKey.RuleHash || cached.CacheKey.InputHash != cacheKey.InputHash { + if cached.CacheKey.RuleHash != cacheKey.RuleHash || + cached.CacheKey.InputHash != cacheKey.InputHash || + cached.CacheKey.ConfigHash != cacheKey.ConfigHash { log.Debugf("Cache key mismatch") return nil, false } diff --git a/lint/cache_test.go b/lint/cache_test.go index d055f33..b7046a9 100644 --- a/lint/cache_test.go +++ b/lint/cache_test.go @@ -237,3 +237,84 @@ func TestGetCacheStats(t *testing.T) { } } +func TestCacheKeyChangesWhenLintSkipChanges(t *testing.T) { + tempDir := t.TempDir() + ruleFile := filepath.Join(tempDir, "rule.rego") + inputFile := filepath.Join(tempDir, "input.yaml") + + if err := os.WriteFile(ruleFile, []byte("package test"), 0644); err != nil { + t.Fatalf("Failed to create rule file: %v", err) + } + if err := os.WriteFile(inputFile, []byte("test: data"), 0644); err != nil { + t.Fatalf("Failed to create input file: %v", err) + } + + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + "Security$ProjectSecurity": { + {Rule: "001_0002", Reason: "first"}, + }, + }, + }, + }) + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + key1, err := createCacheKey(ruleFile, inputFile) + if err != nil { + t.Fatalf("Failed to create first cache key: %v", err) + } + + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + "Security$ProjectSecurity": { + {Rule: "001_0002", Reason: "second"}, + }, + }, + }, + }) + + key2, err := createCacheKey(ruleFile, inputFile) + if err != nil { + t.Fatalf("Failed to create second cache key: %v", err) + } + + if key1.ConfigHash == key2.ConfigHash { + t.Fatalf("expected cache config hash to change when lint.skip changes, got %s", key1.ConfigHash) + } +} + +func TestCacheConfigHashNormalizesSkipPath(t *testing.T) { + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + "./example/doc": { + {Rule: "001_0002", Reason: "same"}, + }, + }, + }, + }) + first := computeCacheConfigHash() + + SetConfig(&Config{ + Lint: ConfigLintSpec{ + Skip: map[string][]ConfigSkipRule{ + "example/doc": { + {Rule: "001_0002", Reason: "same"}, + }, + }, + }, + }) + second := computeCacheConfigHash() + t.Cleanup(func() { + SetConfig(&Config{}) + }) + + if first != second { + t.Fatalf("expected normalized skip paths to produce same cache config hash, got %s vs %s", first, second) + } +} + diff --git a/lint/config.go b/lint/config.go index 0de5e5c..c501d2b 100644 --- a/lint/config.go +++ b/lint/config.go @@ -26,6 +26,34 @@ type Config struct { type ConfigRulesSpec struct { Path string `yaml:"path"` Rulesets []string `yaml:"rulesets"` + rulesetsSet bool +} + +func (c *ConfigRulesSpec) UnmarshalYAML(value *yaml.Node) error { + type configRulesSpecAlias struct { + Path string `yaml:"path"` + Rulesets []string `yaml:"rulesets"` + } + + var decoded configRulesSpecAlias + if err := value.Decode(&decoded); err != nil { + return err + } + + c.Path = decoded.Path + c.Rulesets = append([]string{}, decoded.Rulesets...) + c.rulesetsSet = false + + if value.Kind == yaml.MappingNode { + for i := 0; i+1 < len(value.Content); i += 2 { + if value.Content[i].Value == "rulesets" { + c.rulesetsSet = true + break + } + } + } + + return nil } type ConfigExportSpec struct { @@ -267,7 +295,7 @@ func mergeConfig(base *Config, overlay *Config) { if strings.TrimSpace(overlay.Rules.Path) != "" { base.Rules.Path = strings.TrimSpace(overlay.Rules.Path) } - if len(overlay.Rules.Rulesets) > 0 { + if overlay.Rules.rulesetsSet { base.Rules.Rulesets = append([]string{}, overlay.Rules.Rulesets...) } diff --git a/lint/config_test.go b/lint/config_test.go index 472e3b1..9c581e3 100644 --- a/lint/config_test.go +++ b/lint/config_test.go @@ -244,6 +244,34 @@ func TestLoadMergedConfigFromPath_MissingExplicitReturnsError(t *testing.T) { } } +func TestLoadMergedConfigFromPath_ExplicitCanClearRulesets(t *testing.T) { + projectDir := t.TempDir() + setDefaultConfigForTest(t, "") + + projectConfig := `rules: + rulesets: + - file://project-rules +` + explicitConfig := `rules: + rulesets: [] +` + explicitPath := filepath.Join(projectDir, "custom.yaml") + if err := os.WriteFile(filepath.Join(projectDir, "mxlint.yaml"), []byte(projectConfig), 0644); err != nil { + t.Fatalf("failed to write project config: %v", err) + } + if err := os.WriteFile(explicitPath, []byte(explicitConfig), 0644); err != nil { + t.Fatalf("failed to write explicit config: %v", err) + } + + cfg, err := LoadMergedConfigFromPath(projectDir, "custom.yaml") + if err != nil { + t.Fatalf("LoadMergedConfigFromPath returned error: %v", err) + } + if len(cfg.Rules.Rulesets) != 0 { + t.Fatalf("expected explicit rulesets to clear inherited rulesets, got %#v", cfg.Rules.Rulesets) + } +} + func TestShouldSkipRule_ConfigSkipPathVariants(t *testing.T) { setDefaultConfigForTest(t, "") t.Cleanup(func() { diff --git a/lint/lint.go b/lint/lint.go index f18cd47..cbeeffd 100644 --- a/lint/lint.go +++ b/lint/lint.go @@ -324,6 +324,9 @@ func evalTestsuite(rule Rule, modelSourcePath string, ignoreNoqa bool, useCache } } + // Normalize testcase name for output consistency regardless of cache source. + testcase.Name = formatTestcaseName(inputFile, modelSourcePath) + if testcase.Failure != nil { failuresCount++ } @@ -378,6 +381,24 @@ func evalTestcaseWithCaching(rule Rule, queryString string, inputFile string, ca return testcase, nil } +func formatTestcaseName(inputFilePath string, modelSourcePath string) string { + trimmedInput := strings.TrimSpace(inputFilePath) + if trimmedInput == "" { + return "" + } + + if modelSourcePath != "" { + if relPath, err := filepath.Rel(modelSourcePath, inputFilePath); err == nil { + normalized := filepath.ToSlash(relPath) + if normalized != "." && !strings.HasPrefix(normalized, "../") { + return normalized + } + } + } + + return filepath.ToSlash(filepath.Base(inputFilePath)) +} + func ReadRulesMetadata(rulesPath string) ([]Rule, error) { rules := make([]Rule, 0) walkErr := filepath.Walk(rulesPath, func(path string, info os.FileInfo, err error) error { diff --git a/lint/lint_test.go b/lint/lint_test.go index d3e8087..e4540d2 100644 --- a/lint/lint_test.go +++ b/lint/lint_test.go @@ -335,6 +335,43 @@ func TestCountTotalTestcases(t *testing.T) { } } +func TestFormatTestcaseName(t *testing.T) { + tests := []struct { + name string + inputFilePath string + modelSourcePath string + expected string + }{ + { + name: "relative path strips modelsource prefix", + inputFilePath: "modelsource-v2/Security$ProjectSecurity.yaml", + modelSourcePath: "modelsource-v2", + expected: "Security$ProjectSecurity.yaml", + }, + { + name: "absolute path strips modelsource prefix", + inputFilePath: "/tmp/project/modelsource-v2/Module2/DomainModels$DomainModel.yaml", + modelSourcePath: "/tmp/project/modelsource-v2", + expected: "Module2/DomainModels$DomainModel.yaml", + }, + { + name: "outside modelsource falls back to basename", + inputFilePath: "/tmp/project/other/path/file.yaml", + modelSourcePath: "/tmp/project/modelsource-v2", + expected: "file.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := formatTestcaseName(tt.inputFilePath, tt.modelSourcePath) + if actual != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, actual) + } + }) + } +} + func TestReadRulesMetadata(t *testing.T) { t.Run("read rules from resources directory", func(t *testing.T) { rules, err := ReadRulesMetadata("./../resources/rules") diff --git a/main.go b/main.go index bbddb2b..4a10b2c 100644 --- a/main.go +++ b/main.go @@ -130,7 +130,7 @@ func main() { config.Lint.XunitReport, config.Lint.JSONFile, boolValue(config.Lint.IgnoreNoqa, false), - boolValue(config.Cache.Enable, true), + effectiveLintUseCache(config), ) if err != nil { log.Errorf("lint failed: %s", err) @@ -323,6 +323,16 @@ func boolValue(value *bool, fallback bool) bool { return *value } +func effectiveLintUseCache(config *lint.Config) bool { + if config == nil { + return true + } + if boolValue(config.Lint.NoCache, false) { + return false + } + return boolValue(config.Cache.Enable, true) +} + func isVerbose(cmd *cobra.Command) bool { verbose, err := cmd.Flags().GetBool("verbose") if err != nil { diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0f2d0d2 --- /dev/null +++ b/main_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "testing" + + "github.com/mxlint/mxlint-cli/lint" +) + +func TestEffectiveLintUseCache(t *testing.T) { + falseValue := false + trueValue := true + + tests := []struct { + name string + config *lint.Config + expected bool + }{ + { + name: "nil config defaults to cache enabled", + config: nil, + expected: true, + }, + { + name: "cache enabled by default", + config: &lint.Config{ + Cache: lint.ConfigCacheSpec{ + Enable: nil, + }, + Lint: lint.ConfigLintSpec{ + NoCache: nil, + }, + }, + expected: true, + }, + { + name: "cache disabled by cache.enable", + config: &lint.Config{ + Cache: lint.ConfigCacheSpec{ + Enable: &falseValue, + }, + }, + expected: false, + }, + { + name: "cache disabled by lint.noCache even when cache.enable true", + config: &lint.Config{ + Cache: lint.ConfigCacheSpec{ + Enable: &trueValue, + }, + Lint: lint.ConfigLintSpec{ + NoCache: &trueValue, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := effectiveLintUseCache(tt.config); got != tt.expected { + t.Fatalf("expected %t, got %t", tt.expected, got) + } + }) + } +} diff --git a/serve/serve.go b/serve/serve.go index 4b5af95..89e7700 100644 --- a/serve/serve.go +++ b/serve/serve.go @@ -204,7 +204,13 @@ func runServe(cmd *cobra.Command, args []string) { }() log.Infof("Running export and lint") - err := mpr.ExportModel(inputDirectory, outputDirectory, false, false, "") + err := mpr.ExportModel( + inputDirectory, + outputDirectory, + boolValue(config.Export.Raw, false), + boolValue(config.Export.Appstore, false), + config.Export.Filter, + ) if err != nil { log.Warningf("Export failed: %s", err) resultMutex.Lock() @@ -231,7 +237,14 @@ func runServe(cmd *cobra.Command, args []string) { lintErr = fmt.Errorf("lint operation panicked: %v", r) } }() - results, lintErr = lint.EvalAllWithResults(rulesDirectory, outputDirectory, "", "", false, boolValue(config.Cache.Enable, true)) + results, lintErr = lint.EvalAllWithResults( + rulesDirectory, + outputDirectory, + "", + "", + boolValue(config.Lint.IgnoreNoqa, false), + effectiveLintUseCacheForServe(config), + ) }() if lintErr != nil { @@ -338,6 +351,16 @@ func boolValue(value *bool, fallback bool) bool { return *value } +func effectiveLintUseCacheForServe(config *lint.Config) bool { + if config == nil { + return true + } + if boolValue(config.Lint.NoCache, false) { + return false + } + return boolValue(config.Cache.Enable, true) +} + func configureCacheForServe(config *lint.Config, projectDir string) { if config == nil { return diff --git a/serve/serve_config_test.go b/serve/serve_config_test.go new file mode 100644 index 0000000..33e2865 --- /dev/null +++ b/serve/serve_config_test.go @@ -0,0 +1,53 @@ +package serve + +import ( + "testing" + + "github.com/mxlint/mxlint-cli/lint" +) + +func TestEffectiveLintUseCacheForServe(t *testing.T) { + falseValue := false + trueValue := true + + tests := []struct { + name string + config *lint.Config + expected bool + }{ + { + name: "nil config defaults to cache enabled", + config: nil, + expected: true, + }, + { + name: "cache disabled by cache.enable", + config: &lint.Config{ + Cache: lint.ConfigCacheSpec{ + Enable: &falseValue, + }, + }, + expected: false, + }, + { + name: "cache disabled by lint.noCache", + config: &lint.Config{ + Cache: lint.ConfigCacheSpec{ + Enable: &trueValue, + }, + Lint: lint.ConfigLintSpec{ + NoCache: &trueValue, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := effectiveLintUseCacheForServe(tt.config); got != tt.expected { + t.Fatalf("expected %t, got %t", tt.expected, got) + } + }) + } +}