Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions lint/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (
"io"
"os"
"path/filepath"
"slices"
"strings"
"sync"
)

const cacheVersion = "v1"
const cacheVersion = "v2"

var cacheDirConfig = struct {
mu sync.RWMutex
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
81 changes: 81 additions & 0 deletions lint/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

30 changes: 29 additions & 1 deletion lint/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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...)
}

Expand Down
28 changes: 28 additions & 0 deletions lint/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
21 changes: 21 additions & 0 deletions lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
}
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions lint/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 11 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading