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
13 changes: 13 additions & 0 deletions cmd/mxcli/cmd_lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ Examples:
}
}

// Load lint config file and apply (excludedModules, rule severity/enabled overrides).
// Config ExcludeModules merges with --exclude flag values.
configPath := linter.FindConfigFile(projectDir)
if cfg, err := linter.LoadConfig(configPath); err == nil {
if len(cfg.ExcludeModules) > 0 {
merged := append(excludeModules, cfg.ExcludeModules...)
ctx.SetExcludedModules(merged)
}
cfg.ApplyConfig(lint)
} else {
fmt.Fprintf(os.Stderr, "Warning: failed to load lint config: %v\n", err)
}

// If --rules is specified, disable every rule not in the allowlist.
if len(onlyRules) > 0 {
allowed := make(map[string]bool, len(onlyRules))
Expand Down
208 changes: 208 additions & 0 deletions mdl/linter/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// SPDX-License-Identifier: Apache-2.0

package linter

import (
"os"
"path/filepath"
"testing"
)

func writeYAML(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "lint-config.yaml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write YAML: %v", err)
}
return path
}

func TestLoadConfig_ExcludedModules(t *testing.T) {
path := writeYAML(t, `
excludeModules:
- Administration
- Anonymous
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if len(cfg.ExcludeModules) != 2 {
t.Fatalf("want 2 excluded modules, got %d", len(cfg.ExcludeModules))
}
if cfg.ExcludeModules[0] != "Administration" || cfg.ExcludeModules[1] != "Anonymous" {
t.Errorf("unexpected modules: %v", cfg.ExcludeModules)
}
}

func TestLoadConfig_RuleEnabled(t *testing.T) {
path := writeYAML(t, `
rules:
MPR001:
enabled: false
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
rule, ok := cfg.Rules["MPR001"]
if !ok {
t.Fatal("MPR001 rule not found")
}
if rule.Enabled == nil || *rule.Enabled != false {
t.Errorf("expected enabled=false, got %v", rule.Enabled)
}
}

func TestLoadConfig_RuleSeverity(t *testing.T) {
path := writeYAML(t, `
rules:
PH009:
severity: hint
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Rules["PH009"].Severity != "hint" {
t.Errorf("expected severity hint, got %q", cfg.Rules["PH009"].Severity)
}
}

func TestLoadConfig_RuleOptions(t *testing.T) {
path := writeYAML(t, `
rules:
MPR003:
options:
max_entities: 20
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
opts := cfg.Rules["MPR003"].Options
if opts == nil {
t.Fatal("expected options map, got nil")
}
// YAML numbers unmarshal as int when they fit.
v, ok := opts["max_entities"]
if !ok {
t.Fatal("max_entities not found in options")
}
switch n := v.(type) {
case int:
if n != 20 {
t.Errorf("max_entities = %d, want 20", n)
}
case float64:
if n != 20 {
t.Errorf("max_entities = %v, want 20", n)
}
default:
t.Errorf("unexpected type %T for max_entities", v)
}
}

func TestLoadConfig_MissingFileReturnsDefault(t *testing.T) {
cfg, err := LoadConfig("/nonexistent/lint-config.yaml")
if err != nil {
t.Fatalf("expected no error for missing file, got %v", err)
}
if len(cfg.ExcludeModules) != 0 || len(cfg.Rules) != 0 {
t.Errorf("expected empty default config, got %+v", cfg)
}
}

func TestApplyConfig_DisablesRule(t *testing.T) {
path := writeYAML(t, `
rules:
MPR001:
enabled: false
`)
cfg, _ := LoadConfig(path)

lint := New(nil)
lint.AddRule(&stubRule{"MPR001"})
lint.AddRule(&stubRule{"MPR002"})
cfg.ApplyConfig(lint)

configs := lint.configs
if c, ok := configs["MPR001"]; !ok || c.Enabled {
t.Errorf("MPR001 should be disabled, got %+v", c)
}
if c, ok := configs["MPR002"]; ok && !c.Enabled {
t.Errorf("MPR002 should not be disabled, got %+v", c)
}
}

func TestApplyConfig_OverridesSeverity(t *testing.T) {
path := writeYAML(t, `
rules:
MPR001:
severity: error
`)
cfg, _ := LoadConfig(path)

lint := New(nil)
lint.AddRule(&stubRule{"MPR001"})
cfg.ApplyConfig(lint)

if c := lint.configs["MPR001"]; c.Severity != SeverityError {
t.Errorf("expected SeverityError, got %v", c.Severity)
}
}

func TestApplyConfig_PassesOptions(t *testing.T) {
path := writeYAML(t, `
rules:
MPR003:
options:
max_entities: 25
`)
cfg, _ := LoadConfig(path)

lint := New(nil)
lint.AddRule(&stubRule{"MPR003"})
cfg.ApplyConfig(lint)

opts := lint.configs["MPR003"].Options
if opts == nil {
t.Fatal("expected options in RuleConfig, got nil")
}
}

func TestFindConfigFile_LocatesRootFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lint-config.yaml")
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got := FindConfigFile(dir)
if got != path {
t.Errorf("FindConfigFile = %q, want %q", got, path)
}
}

func TestFindConfigFile_LocatesDotClaudeFile(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, ".claude")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(sub, "lint-config.yaml")
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got := FindConfigFile(dir)
if got != path {
t.Errorf("FindConfigFile = %q, want %q", got, path)
}
}

func TestFindConfigFile_ReturnsEmptyWhenAbsent(t *testing.T) {
dir := t.TempDir()
if got := FindConfigFile(dir); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
6 changes: 6 additions & 0 deletions mdl/linter/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,9 @@ func (ctx *LintContext) RoleMappings() iter.Seq[RoleMappingInfo] {
if err := rows.Scan(&rm.UserRoleName, &rm.ModuleRoleName, &rm.ModuleName); err != nil {
continue
}
if ctx.IsExcluded(rm.ModuleName) {
continue
}
if !yield(rm) {
return
}
Expand Down Expand Up @@ -412,6 +415,9 @@ func (ctx *LintContext) ModuleRoles() iter.Seq[ModuleRoleInfo] {
if err := rows.Scan(&mr.Name, &mr.ModuleName); err != nil {
continue
}
if ctx.IsExcluded(mr.ModuleName) {
continue
}
if !yield(mr) {
return
}
Expand Down
15 changes: 15 additions & 0 deletions mdl/linter/linter.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ type Rule interface {
Check(ctx *LintContext) []Violation
}

// Configurable is an optional interface for rules that accept options from the lint config file.
// Rules implementing this interface receive their options map before Check is called.
type Configurable interface {
Configure(options map[string]any)
}

// RuleConfig holds configuration for a specific rule.
type RuleConfig struct {
Enabled bool
Expand Down Expand Up @@ -149,6 +155,15 @@ func (l *Linter) Run(ctx context.Context) ([]Violation, error) {
default:
}

// Pass options to rules that support configuration.
if config, ok := l.configs[rule.ID()]; ok {
if len(config.Options) > 0 {
if c, ok := rule.(Configurable); ok {
c.Configure(config.Options)
}
}
}

// Run the rule
violations := rule.Check(l.ctx)

Expand Down
47 changes: 47 additions & 0 deletions mdl/linter/linter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ func (r *stubRule) Check(_ *LintContext) []Violation {
return []Violation{{RuleID: r.id, Severity: SeverityWarning, Message: "hit"}}
}

// configurableRule is a stubRule that also implements Configurable.
type configurableRule struct {
stubRule
configuredOptions map[string]any
}

func (r *configurableRule) Configure(options map[string]any) {
r.configuredOptions = options
}

func TestRuleFilter_AllowlistRestrictsExecution(t *testing.T) {
lint := New(nil)
lint.AddRule(&stubRule{"MPR001"})
Expand Down Expand Up @@ -62,6 +72,43 @@ func TestRuleFilter_EmptyAllowlistRunsAll(t *testing.T) {
}
}

func TestConfigurable_OptionsDeliveredBeforeCheck(t *testing.T) {
rule := &configurableRule{stubRule: stubRule{"MPR003"}}
lint := New(nil)
lint.AddRule(rule)
lint.ConfigureRule("MPR003", RuleConfig{
Enabled: true,
Severity: SeverityWarning,
Options: map[string]any{"max_entities": 20},
})

_, err := lint.Run(context.Background())
if err != nil {
t.Fatalf("Run: %v", err)
}
if rule.configuredOptions == nil {
t.Fatal("Configure was never called")
}
if rule.configuredOptions["max_entities"] != 20 {
t.Errorf("max_entities = %v, want 20", rule.configuredOptions["max_entities"])
}
}

func TestConfigurable_NotCalledWhenNoOptions(t *testing.T) {
rule := &configurableRule{stubRule: stubRule{"MPR003"}}
lint := New(nil)
lint.AddRule(rule)
// Config exists but Options is empty — Configure should not be called.
lint.ConfigureRule("MPR003", RuleConfig{Enabled: true})

if _, err := lint.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
if rule.configuredOptions != nil {
t.Errorf("Configure should not be called when options are empty")
}
}

func TestRuleFilter_MultipleRulesAllowed(t *testing.T) {
lint := New(nil)
lint.AddRule(&stubRule{"MPR001"})
Expand Down
13 changes: 13 additions & 0 deletions mdl/linter/rules/domain_size.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ func (r *DomainModelSizeRule) Description() string {
return fmt.Sprintf("Checks that modules have no more than %d persistent entities", r.MaxPersistentEntities)
}

// Configure applies options from the lint config file.
// Supported option: max_entities (int) — override DefaultMaxPersistentEntities.
func (r *DomainModelSizeRule) Configure(options map[string]any) {
if v, ok := options["max_entities"]; ok {
switch n := v.(type) {
case int:
r.MaxPersistentEntities = n
case float64:
r.MaxPersistentEntities = int(n)
}
}
}

// Check counts persistent entities per module and flags those exceeding the limit.
func (r *DomainModelSizeRule) Check(ctx *linter.LintContext) []linter.Violation {
// Count persistent entities per module
Expand Down
Loading