From 352e72673949b45055e4824e901e1256bfdc34d4 Mon Sep 17 00:00:00 2001 From: hidetzu Date: Wed, 1 Apr 2026 08:55:22 +0900 Subject: [PATCH] Implement Step3: team customization with config, init command, and external tool input - Add .riskcheck.yaml config file support with YAML loading - Constructor injection for all signal config overrides (thresholds, weights, paths, test patterns) - riskcheck init command to generate config file - S-8: high_complexity signal via --complexity-file (external JSON) - S-9: low_coverage signal via --coverage-file (external JSON) - CLI flags: --config, --complexity-file, --coverage-file - Print errors to stderr before exit - Update specs, README with full feature documentation - 70+ unit tests across all packages Co-Authored-By: Claude Opus 4.6 (1M context) --- .riskcheck.yaml.example | 46 ++++++ CLAUDE.md | 24 ++-- README.md | 134 +++++++++++++++--- cmd/init.go | 84 +++++++++++ cmd/root.go | 68 ++++++--- go.mod | 1 + go.sum | 2 + internal/analyzer/analyzer_test.go | 16 +-- internal/config/config.go | 69 +++++++++ internal/config/config_test.go | 155 ++++++++++++++++++++ internal/signal/config_test.go | 179 ++++++++++++++++++++++++ internal/signal/core_module.go | 14 +- internal/signal/core_module_test.go | 2 +- internal/signal/high_complexity.go | 74 ++++++++++ internal/signal/high_complexity_test.go | 123 ++++++++++++++++ internal/signal/high_deletions.go | 14 +- internal/signal/high_insertions.go | 14 +- internal/signal/hotspot.go | 17 ++- internal/signal/hotspot_test.go | 2 +- internal/signal/large_change.go | 14 +- internal/signal/low_coverage.go | 74 ++++++++++ internal/signal/low_coverage_test.go | 121 ++++++++++++++++ internal/signal/no_test_change.go | 14 +- internal/signal/no_test_change_test.go | 2 +- internal/signal/security_module.go | 14 +- internal/signal/security_module_test.go | 2 +- internal/signal/signal_test.go | 6 +- main.go | 2 + specs/requirements.md | 5 +- specs/spec.md | 9 +- 30 files changed, 1219 insertions(+), 82 deletions(-) create mode 100644 .riskcheck.yaml.example create mode 100644 cmd/init.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/signal/config_test.go create mode 100644 internal/signal/high_complexity.go create mode 100644 internal/signal/high_complexity_test.go create mode 100644 internal/signal/low_coverage.go create mode 100644 internal/signal/low_coverage_test.go diff --git a/.riskcheck.yaml.example b/.riskcheck.yaml.example new file mode 100644 index 0000000..ec05b30 --- /dev/null +++ b/.riskcheck.yaml.example @@ -0,0 +1,46 @@ +# .riskcheck.yaml — riskcheck configuration +# Place this file at your project root. +# All fields are optional; defaults are used for unset values. + +signals: + large_change: + threshold: 10 # Number of files changed + weight: 10 + high_insertions: + threshold: 200 # Number of added lines + weight: 10 + high_deletions: + threshold: 200 # Number of deleted lines + weight: 5 + hotspot: + since: "90 days ago" + threshold: 5 # Number of changes in the period + weight: 10 + no_test_change: + weight: 15 + security_module: + weight: 20 + paths: + - auth/ + - security/ + - crypto/ + - permission/ + - acl/ + - token/ + - jwt/ + core_module: + weight: 20 + paths: + - config/ + - billing/ + - payment/ + - database/ + - migration/ + - infra/ + - api/ + +# Override test file patterns (replaces defaults if set) +# test_patterns: +# - "*_test.go" +# - "*.test.ts" +# - "*.spec.ts" diff --git a/CLAUDE.md b/CLAUDE.md index 936ca98..c31447d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,26 +50,22 @@ Temporary, task-scoped specs derived from the master specs. ## Current Focus -**Step2: Practical Level** (see `specs/roadmap.md`) +**Step3: Team Customization** (see `specs/roadmap.md`) ### Tasks -- [x] Git log integration (`git log --name-only`) -- [x] S-4: hotspot signal (90 days, 5+ changes) -- [x] S-5: no_test_change signal (aggregate) -- [x] S-6: core_module signal (file-scoped) -- [x] S-7: security_module signal (file-scoped, priority over core) -- [x] Analyzer priority dedup (security > core) -- [x] Scorer per-file risk (file-scoped weights / 40.0) -- [x] Formatter update (files[] in JSON/text) -- [x] CLI exit code (non-zero for medium+) +- [x] Config loading (`internal/config`, `.riskcheck.yaml`) +- [x] Apply config to existing signals (thresholds, weights, paths, test patterns) +- [x] S-8: high_complexity signal (`--complexity-file`) +- [x] S-9: low_coverage signal (`--coverage-file`) +- [x] CLI flags (`--config`, `--complexity-file`, `--coverage-file`) +- [x] Sample `.riskcheck.yaml.example` ### Exit Criteria -- Hotspot files detected from git history -- Missing test changes flagged -- JSON output includes `files[]` with per-file risk and signals -- Exit code reflects risk level +- `.riskcheck.yaml` overrides default behavior +- External tool output can feed into scoring +- All existing tests still pass ## Tech Stack diff --git a/README.md b/README.md index beeb199..9e38a2e 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,34 @@ riskcheck --base origin/main | claude -p "Review the high-risk areas" - Analyze `git diff` and calculate a risk score (0-100) for your changes - Explain **why** the change is risky with concrete reasons +- Per-file risk scoring — know exactly which files need attention - Output structured JSON — ready to pipe into AI tools or CI pipelines +- Customizable via `.riskcheck.yaml` for team-specific rules ## Quick Start ```bash go install github.com/hidetzu/riskcheck@latest +riskcheck init # Generate .riskcheck.yaml (optional) riskcheck --base origin/main ``` +## Signals + +riskcheck detects the following risk signals: + +| Signal | Description | Default Weight | +|--------|-------------|---------------| +| large_change | Too many files changed | +10 | +| high_insertions | Too many lines added | +10 | +| high_deletions | Too many lines deleted | +5 | +| hotspot | Frequently changed files (last 90 days) | +10 | +| no_test_change | Production code changed without test updates | +15 | +| security_module | Security-related paths modified (auth, crypto, etc.) | +20 | +| core_module | Core business logic paths modified (config, payment, etc.) | +20 | +| high_complexity | High cyclomatic complexity (external input) | +15 | +| low_coverage | Low test coverage (external input) | +10 | + ## Output Examples ### JSON (default) @@ -32,15 +51,34 @@ riskcheck --base origin/main ```bash $ riskcheck --base origin/main { - "score": 35, - "level": "low", + "score": 55, + "level": "medium", "summary": { - "files_changed": 3, - "insertions": 45, - "deletions": 12 + "files_changed": 5, + "insertions": 120, + "deletions": 30 }, "reasons": [ - "high number of insertions" + "security module modified (src/auth/login.go)", + "no test updates for changed files", + "hotspot file touched (src/auth/login.go changed 8 times in last 90 days ago)" + ], + "files": [ + { + "path": "src/auth/login.go", + "risk": 0.75, + "signals": ["hotspot", "security_module"] + }, + { + "path": "src/config/app.go", + "risk": 0.5, + "signals": ["core_module"] + }, + { + "path": "src/handler/home.go", + "risk": 0, + "signals": [] + } ] } ``` @@ -49,14 +87,20 @@ $ riskcheck --base origin/main ```bash $ riskcheck --base origin/main --format text -Risk Score: 35 / 100 (low) +Risk Score: 55 / 100 (medium) -Files changed: 3 -Insertions: 45 -Deletions: 12 +Files changed: 5 +Insertions: 120 +Deletions: 30 Reasons: - - high number of insertions + - security module modified (src/auth/login.go) + - no test updates for changed files + - hotspot file touched (src/auth/login.go changed 8 times in last 90 days ago) + +High-risk files: + 0.75 src/auth/login.go [hotspot, security_module] + 0.50 src/config/app.go [core_module] ``` ## Usage @@ -65,11 +109,69 @@ Reasons: riskcheck [flags] Flags: - --base Comparison base (default: "origin/main") - --target Comparison target (default: "." working tree) - --format Output format: json, text (default: "json") - -h, --help Help - -v, --version Version + --base Comparison base (default: "origin/main") + --target Comparison target (default: "." working tree) + --format Output format: json, text (default: "json") + --config Config file path (default: ".riskcheck.yaml") + --complexity-file Path to complexity JSON file (optional) + --coverage-file Path to coverage JSON file (optional) + -h, --help Help + -v, --version Version +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Low risk (score 0-39) | +| 1 | Medium or high risk (score 40-100) | +| 2 | Error | + +## Configuration + +Generate a config file with `riskcheck init`, then customize: + +```yaml +signals: + security_module: + weight: 25 + paths: + - auth/ + - secrets/ + - oauth/ + core_module: + paths: + - payments/ + - orders/ + hotspot: + since: "30 days ago" + threshold: 3 + +test_patterns: + - "*_test.go" + - "*_spec.rb" +``` + +## External Tool Integration + +Feed complexity or coverage data from external tools: + +```bash +# Complexity (e.g., from gocyclo, lizard) +riskcheck --base origin/main --complexity-file complexity.json + +# Coverage (e.g., from go test -coverprofile, JaCoCo) +riskcheck --base origin/main --coverage-file coverage.json +``` + +Input JSON format: + +```json +// complexity.json +[{"path": "src/main.go", "complexity": 15}] + +// coverage.json +[{"path": "src/main.go", "coverage": 45.2}] ``` ## AI Integration diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..8853f21 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/spf13/cobra" +) + +const defaultConfig = `# .riskcheck.yaml — riskcheck configuration +# All fields are optional; defaults are used for unset values. + +signals: + large_change: + threshold: 10 # Number of files changed + weight: 10 + high_insertions: + threshold: 200 # Number of added lines + weight: 10 + high_deletions: + threshold: 200 # Number of deleted lines + weight: 5 + hotspot: + since: "90 days ago" + threshold: 5 # Number of changes in the period + weight: 10 + no_test_change: + weight: 15 + security_module: + weight: 20 + paths: + - auth/ + - security/ + - crypto/ + - permission/ + - acl/ + - token/ + - jwt/ + core_module: + weight: 20 + paths: + - config/ + - billing/ + - payment/ + - database/ + - migration/ + - infra/ + - api/ + +# Override test file patterns (replaces defaults if set) +# test_patterns: +# - "*_test.go" +# - "*.test.ts" +# - "*.spec.ts" +` + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Generate a .riskcheck.yaml config file", + RunE: runInit, +} + +func init() { + rootCmd.AddCommand(initCmd) +} + +func runInit(cmd *cobra.Command, args []string) error { + const path = ".riskcheck.yaml" + + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("%s already exists", path) + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("check config file: %w", err) + } + + if err := os.WriteFile(path, []byte(defaultConfig), 0644); err != nil { + return fmt.Errorf("write config: %w", err) + } + + fmt.Fprintf(os.Stderr, "Created %s\n", path) + return nil +} diff --git a/cmd/root.go b/cmd/root.go index d0bfa6c..ccba1b2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "os" "github.com/hidetzu/riskcheck/internal/analyzer" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/formatter" gitpkg "github.com/hidetzu/riskcheck/internal/git" "github.com/hidetzu/riskcheck/internal/scorer" @@ -19,9 +20,12 @@ var ErrRiskThresholdExceeded = fmt.Errorf("risk threshold exceeded") var ( version = "dev" - flagBase string - flagTarget string - flagFormat string + flagBase string + flagTarget string + flagFormat string + flagConfig string + flagComplexityFile string + flagCoverageFile string ) var rootCmd = &cobra.Command{ @@ -36,6 +40,9 @@ func init() { rootCmd.Flags().StringVar(&flagBase, "base", "origin/main", "Comparison base") rootCmd.Flags().StringVar(&flagTarget, "target", ".", "Comparison target (working tree)") rootCmd.Flags().StringVar(&flagFormat, "format", "json", "Output format: json, text") + rootCmd.Flags().StringVar(&flagConfig, "config", ".riskcheck.yaml", "Config file path") + rootCmd.Flags().StringVar(&flagComplexityFile, "complexity-file", "", "Path to complexity JSON file") + rootCmd.Flags().StringVar(&flagCoverageFile, "coverage-file", "", "Path to coverage JSON file") rootCmd.SilenceErrors = true rootCmd.SilenceUsage = true } @@ -47,32 +54,57 @@ func Execute() error { func run(cmd *cobra.Command, args []string) error { ctx := context.Background() - // 1. Get diff + // 1. Load config + cfg, err := config.Load(flagConfig) + if err != nil { + return fmt.Errorf("config: %w", err) + } + + // 2. Get diff gc := gitpkg.NewClient() diff, err := gc.Diff(ctx, flagBase, flagTarget) if err != nil { return fmt.Errorf("git diff: %w", err) } - // 2. Analyze signals - a := analyzer.New( - signal.NewLargeChange(), - signal.NewHighInsertions(), - signal.NewHighDeletions(), - signal.NewHotspot(gc), - signal.NewNoTestChange(), - signal.NewSecurityModule(), - signal.NewCoreModule(), - ) - signals, err := a.Analyze(ctx, diff) + // 3. Build signals + signals := []signal.Signal{ + signal.NewLargeChange(cfg.Signals.LargeChange), + signal.NewHighInsertions(cfg.Signals.HighInsertions), + signal.NewHighDeletions(cfg.Signals.HighDeletions), + signal.NewHotspot(gc, cfg.Signals.Hotspot), + signal.NewNoTestChange(cfg.Signals.NoTestChange, cfg.TestPatterns), + signal.NewSecurityModule(cfg.Signals.SecurityModule), + signal.NewCoreModule(cfg.Signals.CoreModule), + } + + if flagComplexityFile != "" { + s, err := signal.NewHighComplexity(flagComplexityFile) + if err != nil { + return fmt.Errorf("complexity file: %w", err) + } + signals = append(signals, s) + } + + if flagCoverageFile != "" { + s, err := signal.NewLowCoverage(flagCoverageFile) + if err != nil { + return fmt.Errorf("coverage file: %w", err) + } + signals = append(signals, s) + } + + // 4. Analyze + a := analyzer.New(signals...) + detected, err := a.Analyze(ctx, diff) if err != nil { return fmt.Errorf("analyze: %w", err) } - // 3. Score - result := scorer.Score(signals, diff) + // 5. Score + result := scorer.Score(detected, diff) - // 4. Format and output + // 6. Format and output var f formatter.Formatter switch flagFormat { case "json": diff --git a/go.mod b/go.mod index d35d3e1..eef7918 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,5 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..ff4d6ec 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,5 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index 955732e..83ecf6b 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -23,9 +23,9 @@ func TestAnalyzer_NoSignals(t *testing.T) { func TestAnalyzer_MultipleSignals(t *testing.T) { a := New( - signal.NewLargeChange(), - signal.NewHighInsertions(), - signal.NewHighDeletions(), + signal.NewLargeChange(nil), + signal.NewHighInsertions(nil), + signal.NewHighDeletions(nil), ) files := make([]git.FileDiff, 15) @@ -45,9 +45,9 @@ func TestAnalyzer_MultipleSignals(t *testing.T) { func TestAnalyzer_NoDetection(t *testing.T) { a := New( - signal.NewLargeChange(), - signal.NewHighInsertions(), - signal.NewHighDeletions(), + signal.NewLargeChange(nil), + signal.NewHighInsertions(nil), + signal.NewHighDeletions(nil), ) diff := &git.DiffResult{ @@ -67,8 +67,8 @@ func TestAnalyzer_NoDetection(t *testing.T) { func TestAnalyzer_SecurityOverCore(t *testing.T) { a := New( - signal.NewSecurityModule(), - signal.NewCoreModule(), + signal.NewSecurityModule(nil), + signal.NewCoreModule(nil), ) tests := []struct { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..313576e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "errors" + "io/fs" + "os" + + "gopkg.in/yaml.v3" +) + +// Config holds the riskcheck configuration. +type Config struct { + Signals SignalConfigs `yaml:"signals"` + TestPatterns []string `yaml:"test_patterns"` +} + +// SignalConfigs holds per-signal configuration. +type SignalConfigs struct { + LargeChange *SignalThresholdConfig `yaml:"large_change"` + HighInsertions *SignalThresholdConfig `yaml:"high_insertions"` + HighDeletions *SignalThresholdConfig `yaml:"high_deletions"` + Hotspot *HotspotConfig `yaml:"hotspot"` + NoTestChange *SignalWeightConfig `yaml:"no_test_change"` + SecurityModule *ModuleConfig `yaml:"security_module"` + CoreModule *ModuleConfig `yaml:"core_module"` +} + +// SignalThresholdConfig configures a signal with threshold and weight. +type SignalThresholdConfig struct { + Threshold *int `yaml:"threshold"` + Weight *int `yaml:"weight"` +} + +// HotspotConfig configures the hotspot signal. +type HotspotConfig struct { + Since *string `yaml:"since"` + Threshold *int `yaml:"threshold"` + Weight *int `yaml:"weight"` +} + +// SignalWeightConfig configures a signal with only a weight. +type SignalWeightConfig struct { + Weight *int `yaml:"weight"` +} + +// ModuleConfig configures a module signal with weight and paths. +type ModuleConfig struct { + Weight *int `yaml:"weight"` + Paths []string `yaml:"paths"` +} + +// Load reads a config file and returns the parsed Config. +// If the file does not exist, returns an empty Config and no error. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return &Config{}, nil + } + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..96d9748 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,155 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_ValidConfig(t *testing.T) { + content := ` +signals: + large_change: + threshold: 20 + weight: 15 + hotspot: + since: "30 days ago" + threshold: 3 + weight: 20 + security_module: + weight: 25 + paths: + - auth/ + - secrets/ + core_module: + paths: + - payments/ +test_patterns: + - "*_test.go" + - "*.test.ts" +` + cfg := loadFromString(t, content) + + if cfg.Signals.LargeChange == nil { + t.Fatal("LargeChange config is nil") + } + if *cfg.Signals.LargeChange.Threshold != 20 { + t.Errorf("LargeChange.Threshold = %d, want 20", *cfg.Signals.LargeChange.Threshold) + } + if *cfg.Signals.LargeChange.Weight != 15 { + t.Errorf("LargeChange.Weight = %d, want 15", *cfg.Signals.LargeChange.Weight) + } + + if cfg.Signals.Hotspot == nil { + t.Fatal("Hotspot config is nil") + } + if *cfg.Signals.Hotspot.Since != "30 days ago" { + t.Errorf("Hotspot.Since = %q, want %q", *cfg.Signals.Hotspot.Since, "30 days ago") + } + if *cfg.Signals.Hotspot.Threshold != 3 { + t.Errorf("Hotspot.Threshold = %d, want 3", *cfg.Signals.Hotspot.Threshold) + } + + if cfg.Signals.SecurityModule == nil { + t.Fatal("SecurityModule config is nil") + } + if *cfg.Signals.SecurityModule.Weight != 25 { + t.Errorf("SecurityModule.Weight = %d, want 25", *cfg.Signals.SecurityModule.Weight) + } + if len(cfg.Signals.SecurityModule.Paths) != 2 { + t.Errorf("SecurityModule.Paths count = %d, want 2", len(cfg.Signals.SecurityModule.Paths)) + } + + if cfg.Signals.CoreModule == nil { + t.Fatal("CoreModule config is nil") + } + if cfg.Signals.CoreModule.Weight != nil { + t.Errorf("CoreModule.Weight should be nil, got %d", *cfg.Signals.CoreModule.Weight) + } + if len(cfg.Signals.CoreModule.Paths) != 1 { + t.Errorf("CoreModule.Paths count = %d, want 1", len(cfg.Signals.CoreModule.Paths)) + } + + if len(cfg.TestPatterns) != 2 { + t.Errorf("TestPatterns count = %d, want 2", len(cfg.TestPatterns)) + } +} + +func TestLoad_EmptyFile(t *testing.T) { + cfg := loadFromString(t, "") + + if cfg.Signals.LargeChange != nil { + t.Error("LargeChange should be nil for empty config") + } + if cfg.Signals.Hotspot != nil { + t.Error("Hotspot should be nil for empty config") + } + if len(cfg.TestPatterns) != 0 { + t.Error("TestPatterns should be empty for empty config") + } +} + +func TestLoad_FileNotFound(t *testing.T) { + cfg, err := Load("/nonexistent/path/.riskcheck.yaml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("config should not be nil") + } + if cfg.Signals.LargeChange != nil { + t.Error("LargeChange should be nil for missing file") + } +} + +func TestLoad_PartialConfig(t *testing.T) { + content := ` +signals: + large_change: + threshold: 5 +` + cfg := loadFromString(t, content) + + if cfg.Signals.LargeChange == nil { + t.Fatal("LargeChange should not be nil") + } + if *cfg.Signals.LargeChange.Threshold != 5 { + t.Errorf("Threshold = %d, want 5", *cfg.Signals.LargeChange.Threshold) + } + if cfg.Signals.LargeChange.Weight != nil { + t.Error("Weight should be nil when not set") + } + if cfg.Signals.Hotspot != nil { + t.Error("Hotspot should be nil when not set") + } + if cfg.Signals.SecurityModule != nil { + t.Error("SecurityModule should be nil when not set") + } +} + +func TestLoad_InvalidYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".riskcheck.yaml") + if err := os.WriteFile(path, []byte("{{invalid yaml"), 0644); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func loadFromString(t *testing.T, content string) *Config { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, ".riskcheck.yaml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return cfg +} diff --git a/internal/signal/config_test.go b/internal/signal/config_test.go new file mode 100644 index 0000000..8cfed7d --- /dev/null +++ b/internal/signal/config_test.go @@ -0,0 +1,179 @@ +package signal + +import ( + "context" + "testing" + + "github.com/hidetzu/riskcheck/internal/config" + "github.com/hidetzu/riskcheck/internal/git" +) + +func intPtr(v int) *int { return &v } +func strPtr(v string) *string { return &v } + +func TestLargeChange_WithConfig(t *testing.T) { + cfg := &config.SignalThresholdConfig{Threshold: intPtr(5), Weight: intPtr(20)} + s := NewLargeChange(cfg) + + if s.Threshold != 5 { + t.Errorf("Threshold = %d, want 5", s.Threshold) + } + if s.Weight != 20 { + t.Errorf("Weight = %d, want 20", s.Weight) + } +} + +func TestHighInsertions_WithConfig(t *testing.T) { + cfg := &config.SignalThresholdConfig{Threshold: intPtr(100)} + s := NewHighInsertions(cfg) + + if s.Threshold != 100 { + t.Errorf("Threshold = %d, want 100", s.Threshold) + } + if s.Weight != HighInsertionsDefaultWeight { + t.Errorf("Weight = %d, want default %d", s.Weight, HighInsertionsDefaultWeight) + } +} + +func TestHighDeletions_WithConfig(t *testing.T) { + cfg := &config.SignalThresholdConfig{Weight: intPtr(15)} + s := NewHighDeletions(cfg) + + if s.Threshold != HighDeletionsDefaultThreshold { + t.Errorf("Threshold = %d, want default %d", s.Threshold, HighDeletionsDefaultThreshold) + } + if s.Weight != 15 { + t.Errorf("Weight = %d, want 15", s.Weight) + } +} + +func TestHotspot_WithConfig(t *testing.T) { + cfg := &config.HotspotConfig{ + Since: strPtr("30 days ago"), + Threshold: intPtr(3), + Weight: intPtr(20), + } + s := NewHotspot(nil, cfg) + + if s.Since != "30 days ago" { + t.Errorf("Since = %q, want %q", s.Since, "30 days ago") + } + if s.Threshold != 3 { + t.Errorf("Threshold = %d, want 3", s.Threshold) + } + if s.Weight != 20 { + t.Errorf("Weight = %d, want 20", s.Weight) + } +} + +func TestNoTestChange_WithConfig(t *testing.T) { + weightCfg := &config.SignalWeightConfig{Weight: intPtr(25)} + patterns := []string{"*_test.go", "*.test.ts"} + s := NewNoTestChange(weightCfg, patterns) + + if s.Weight != 25 { + t.Errorf("Weight = %d, want 25", s.Weight) + } + if len(s.TestPatterns) != 2 { + t.Errorf("TestPatterns count = %d, want 2", len(s.TestPatterns)) + } +} + +func TestNoTestChange_CustomPatterns(t *testing.T) { + s := NewNoTestChange(nil, []string{"*_spec.rb"}) + + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "app/model.rb"}, + {Path: "spec/model_spec.rb"}, + }, + } + + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(signals) != 0 { + t.Error("should not detect when custom pattern matches test file") + } +} + +func TestSecurityModule_WithConfig(t *testing.T) { + cfg := &config.ModuleConfig{ + Weight: intPtr(30), + Paths: []string{"secrets/", "oauth/"}, + } + s := NewSecurityModule(cfg) + + if s.Weight != 30 { + t.Errorf("Weight = %d, want 30", s.Weight) + } + if len(s.Paths) != 2 { + t.Errorf("Paths count = %d, want 2", len(s.Paths)) + } + + diff := &git.DiffResult{ + Files: []git.FileDiff{{Path: "src/oauth/client.go"}}, + } + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(signals) != 1 { + t.Errorf("got %d signals, want 1", len(signals)) + } +} + +func TestCoreModule_WithConfig(t *testing.T) { + cfg := &config.ModuleConfig{ + Paths: []string{"payments/", "orders/"}, + } + s := NewCoreModule(cfg) + + if s.Weight != CoreModuleDefaultWeight { + t.Errorf("Weight = %d, want default %d", s.Weight, CoreModuleDefaultWeight) + } + + diff := &git.DiffResult{ + Files: []git.FileDiff{{Path: "src/payments/stripe.go"}}, + } + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(signals) != 1 { + t.Errorf("got %d signals, want 1", len(signals)) + } +} + +func TestNilConfig_PreservesDefaults(t *testing.T) { + lc := NewLargeChange(nil) + hi := NewHighInsertions(nil) + hd := NewHighDeletions(nil) + hs := NewHotspot(nil, nil) + nt := NewNoTestChange(nil, nil) + sm := NewSecurityModule(nil) + cm := NewCoreModule(nil) + + if lc.Threshold != LargeChangeDefaultThreshold || lc.Weight != LargeChangeDefaultWeight { + t.Error("LargeChange defaults not preserved") + } + if hi.Threshold != HighInsertionsDefaultThreshold || hi.Weight != HighInsertionsDefaultWeight { + t.Error("HighInsertions defaults not preserved") + } + if hd.Threshold != HighDeletionsDefaultThreshold || hd.Weight != HighDeletionsDefaultWeight { + t.Error("HighDeletions defaults not preserved") + } + if hs.Since != HotspotDefaultSince || hs.Threshold != HotspotDefaultThreshold || hs.Weight != HotspotDefaultWeight { + t.Error("Hotspot defaults not preserved") + } + if nt.Weight != NoTestChangeDefaultWeight || len(nt.TestPatterns) != len(DefaultTestPatterns) { + t.Error("NoTestChange defaults not preserved") + } + if sm.Weight != SecurityModuleDefaultWeight || len(sm.Paths) != len(DefaultSecurityPaths) { + t.Error("SecurityModule defaults not preserved") + } + if cm.Weight != CoreModuleDefaultWeight || len(cm.Paths) != len(DefaultCorePaths) { + t.Error("CoreModule defaults not preserved") + } +} diff --git a/internal/signal/core_module.go b/internal/signal/core_module.go index 275c757..e76b5d5 100644 --- a/internal/signal/core_module.go +++ b/internal/signal/core_module.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/git" ) @@ -29,11 +30,20 @@ type CoreModule struct { Paths []string } -func NewCoreModule() *CoreModule { - return &CoreModule{ +func NewCoreModule(cfg *config.ModuleConfig) *CoreModule { + s := &CoreModule{ Weight: CoreModuleDefaultWeight, Paths: DefaultCorePaths, } + if cfg != nil { + if cfg.Weight != nil { + s.Weight = *cfg.Weight + } + if len(cfg.Paths) > 0 { + s.Paths = cfg.Paths + } + } + return s } func (s *CoreModule) Name() string { diff --git a/internal/signal/core_module_test.go b/internal/signal/core_module_test.go index 669a1f9..da75d08 100644 --- a/internal/signal/core_module_test.go +++ b/internal/signal/core_module_test.go @@ -8,7 +8,7 @@ import ( ) func TestCoreModule(t *testing.T) { - s := NewCoreModule() + s := NewCoreModule(nil) if s.Name() != "core_module" { t.Errorf("Name() = %q, want %q", s.Name(), "core_module") diff --git a/internal/signal/high_complexity.go b/internal/signal/high_complexity.go new file mode 100644 index 0000000..b2a9892 --- /dev/null +++ b/internal/signal/high_complexity.go @@ -0,0 +1,74 @@ +package signal + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/hidetzu/riskcheck/internal/git" +) + +const ( + HighComplexityDefaultThreshold = 10 + HighComplexityDefaultWeight = 15 +) + +// ComplexityEntry represents a single file's complexity from an external tool. +type ComplexityEntry struct { + Path string `json:"path"` + Complexity int `json:"complexity"` +} + +// HighComplexity detects files with high cyclomatic complexity. +type HighComplexity struct { + Threshold int + Weight int + Entries []ComplexityEntry +} + +func NewHighComplexity(filePath string) (*HighComplexity, error) { + s := &HighComplexity{ + Threshold: HighComplexityDefaultThreshold, + Weight: HighComplexityDefaultWeight, + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read complexity file: %w", err) + } + + if err := json.Unmarshal(data, &s.Entries); err != nil { + return nil, fmt.Errorf("parse complexity file: %w", err) + } + + return s, nil +} + +func (s *HighComplexity) Name() string { + return "high_complexity" +} + +func (s *HighComplexity) Detect(_ context.Context, diff *git.DiffResult) ([]DetectedSignal, error) { + diffFiles := make(map[string]bool) + for _, f := range diff.Files { + diffFiles[f.Path] = true + } + + var signals []DetectedSignal + for _, e := range s.Entries { + if !diffFiles[e.Path] { + continue + } + if e.Complexity > s.Threshold { + signals = append(signals, DetectedSignal{ + SignalName: s.Name(), + FilePath: e.Path, + Weight: s.Weight, + Reason: fmt.Sprintf("high complexity (%s has complexity %d)", e.Path, e.Complexity), + }) + } + } + + return signals, nil +} diff --git a/internal/signal/high_complexity_test.go b/internal/signal/high_complexity_test.go new file mode 100644 index 0000000..dc1e28c --- /dev/null +++ b/internal/signal/high_complexity_test.go @@ -0,0 +1,123 @@ +package signal + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/hidetzu/riskcheck/internal/git" +) + +func TestHighComplexity(t *testing.T) { + jsonContent := `[ + {"path": "src/main.go", "complexity": 15}, + {"path": "src/auth/login.go", "complexity": 25}, + {"path": "src/util.go", "complexity": 5} + ]` + + s := loadComplexitySignal(t, jsonContent) + + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "src/main.go"}, + {Path: "src/auth/login.go"}, + {Path: "src/util.go"}, + {Path: "src/other.go"}, + }, + } + + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // main.go (15 > 10) and login.go (25 > 10) should be detected + // util.go (5 <= 10) should not + // other.go not in complexity data + if len(signals) != 2 { + t.Fatalf("got %d signals, want 2", len(signals)) + } + + if signals[0].FilePath != "src/main.go" { + t.Errorf("signals[0].FilePath = %q, want src/main.go", signals[0].FilePath) + } + if signals[0].Weight != HighComplexityDefaultWeight { + t.Errorf("Weight = %d, want %d", signals[0].Weight, HighComplexityDefaultWeight) + } +} + +func TestHighComplexity_OnlyDiffFiles(t *testing.T) { + jsonContent := `[ + {"path": "src/main.go", "complexity": 25}, + {"path": "src/other.go", "complexity": 30} + ]` + + s := loadComplexitySignal(t, jsonContent) + + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "src/main.go"}, + }, + } + + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Only main.go is in diff + if len(signals) != 1 { + t.Fatalf("got %d signals, want 1", len(signals)) + } +} + +func TestHighComplexity_EmptyFile(t *testing.T) { + s := loadComplexitySignal(t, "[]") + + diff := &git.DiffResult{ + Files: []git.FileDiff{{Path: "src/main.go"}}, + } + + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(signals) != 0 { + t.Errorf("got %d signals, want 0", len(signals)) + } +} + +func TestHighComplexity_MissingFile(t *testing.T) { + _, err := NewHighComplexity("/nonexistent/file.json") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestHighComplexity_InvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "complexity.json") + if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { + t.Fatal(err) + } + + _, err := NewHighComplexity(path) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func loadComplexitySignal(t *testing.T, content string) *HighComplexity { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "complexity.json") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + s, err := NewHighComplexity(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return s +} diff --git a/internal/signal/high_deletions.go b/internal/signal/high_deletions.go index 1c919fe..3832bbd 100644 --- a/internal/signal/high_deletions.go +++ b/internal/signal/high_deletions.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/git" ) @@ -18,11 +19,20 @@ type HighDeletions struct { Weight int } -func NewHighDeletions() *HighDeletions { - return &HighDeletions{ +func NewHighDeletions(cfg *config.SignalThresholdConfig) *HighDeletions { + s := &HighDeletions{ Threshold: HighDeletionsDefaultThreshold, Weight: HighDeletionsDefaultWeight, } + if cfg != nil { + if cfg.Threshold != nil { + s.Threshold = *cfg.Threshold + } + if cfg.Weight != nil { + s.Weight = *cfg.Weight + } + } + return s } func (s *HighDeletions) Name() string { diff --git a/internal/signal/high_insertions.go b/internal/signal/high_insertions.go index 2184264..6ab7d2d 100644 --- a/internal/signal/high_insertions.go +++ b/internal/signal/high_insertions.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/git" ) @@ -18,11 +19,20 @@ type HighInsertions struct { Weight int } -func NewHighInsertions() *HighInsertions { - return &HighInsertions{ +func NewHighInsertions(cfg *config.SignalThresholdConfig) *HighInsertions { + s := &HighInsertions{ Threshold: HighInsertionsDefaultThreshold, Weight: HighInsertionsDefaultWeight, } + if cfg != nil { + if cfg.Threshold != nil { + s.Threshold = *cfg.Threshold + } + if cfg.Weight != nil { + s.Weight = *cfg.Weight + } + } + return s } func (s *HighInsertions) Name() string { diff --git a/internal/signal/hotspot.go b/internal/signal/hotspot.go index 7042a09..26123de 100644 --- a/internal/signal/hotspot.go +++ b/internal/signal/hotspot.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/git" ) @@ -21,13 +22,25 @@ type Hotspot struct { Weight int } -func NewHotspot(gitClient *git.Client) *Hotspot { - return &Hotspot{ +func NewHotspot(gitClient *git.Client, cfg *config.HotspotConfig) *Hotspot { + s := &Hotspot{ GitClient: gitClient, Since: HotspotDefaultSince, Threshold: HotspotDefaultThreshold, Weight: HotspotDefaultWeight, } + if cfg != nil { + if cfg.Since != nil { + s.Since = *cfg.Since + } + if cfg.Threshold != nil { + s.Threshold = *cfg.Threshold + } + if cfg.Weight != nil { + s.Weight = *cfg.Weight + } + } + return s } func (s *Hotspot) Name() string { diff --git a/internal/signal/hotspot_test.go b/internal/signal/hotspot_test.go index d5e6560..651c0cb 100644 --- a/internal/signal/hotspot_test.go +++ b/internal/signal/hotspot_test.go @@ -96,7 +96,7 @@ func TestHotspot(t *testing.T) { func TestHotspot_Detect_Integration(t *testing.T) { // Test the public Detect() method with a real git client on this repo gc := git.NewClient() - s := NewHotspot(gc) + s := NewHotspot(gc, nil) diff := &git.DiffResult{ Files: []git.FileDiff{{Path: "nonexistent_file.go"}}, diff --git a/internal/signal/large_change.go b/internal/signal/large_change.go index 3f4f179..2977dac 100644 --- a/internal/signal/large_change.go +++ b/internal/signal/large_change.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/git" ) @@ -18,11 +19,20 @@ type LargeChange struct { Weight int } -func NewLargeChange() *LargeChange { - return &LargeChange{ +func NewLargeChange(cfg *config.SignalThresholdConfig) *LargeChange { + s := &LargeChange{ Threshold: LargeChangeDefaultThreshold, Weight: LargeChangeDefaultWeight, } + if cfg != nil { + if cfg.Threshold != nil { + s.Threshold = *cfg.Threshold + } + if cfg.Weight != nil { + s.Weight = *cfg.Weight + } + } + return s } func (s *LargeChange) Name() string { diff --git a/internal/signal/low_coverage.go b/internal/signal/low_coverage.go new file mode 100644 index 0000000..c03d210 --- /dev/null +++ b/internal/signal/low_coverage.go @@ -0,0 +1,74 @@ +package signal + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/hidetzu/riskcheck/internal/git" +) + +const ( + LowCoverageDefaultThreshold = 50.0 + LowCoverageDefaultWeight = 10 +) + +// CoverageEntry represents a single file's coverage from an external tool. +type CoverageEntry struct { + Path string `json:"path"` + Coverage float64 `json:"coverage"` +} + +// LowCoverage detects files with low test coverage. +type LowCoverage struct { + Threshold float64 + Weight int + Entries []CoverageEntry +} + +func NewLowCoverage(filePath string) (*LowCoverage, error) { + s := &LowCoverage{ + Threshold: LowCoverageDefaultThreshold, + Weight: LowCoverageDefaultWeight, + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read coverage file: %w", err) + } + + if err := json.Unmarshal(data, &s.Entries); err != nil { + return nil, fmt.Errorf("parse coverage file: %w", err) + } + + return s, nil +} + +func (s *LowCoverage) Name() string { + return "low_coverage" +} + +func (s *LowCoverage) Detect(_ context.Context, diff *git.DiffResult) ([]DetectedSignal, error) { + diffFiles := make(map[string]bool) + for _, f := range diff.Files { + diffFiles[f.Path] = true + } + + var signals []DetectedSignal + for _, e := range s.Entries { + if !diffFiles[e.Path] { + continue + } + if e.Coverage < s.Threshold { + signals = append(signals, DetectedSignal{ + SignalName: s.Name(), + FilePath: e.Path, + Weight: s.Weight, + Reason: fmt.Sprintf("low test coverage (%s has %.1f%% coverage)", e.Path, e.Coverage), + }) + } + } + + return signals, nil +} diff --git a/internal/signal/low_coverage_test.go b/internal/signal/low_coverage_test.go new file mode 100644 index 0000000..2b9c0ad --- /dev/null +++ b/internal/signal/low_coverage_test.go @@ -0,0 +1,121 @@ +package signal + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/hidetzu/riskcheck/internal/git" +) + +func TestLowCoverage(t *testing.T) { + jsonContent := `[ + {"path": "src/main.go", "coverage": 45.2}, + {"path": "src/auth/login.go", "coverage": 12.5}, + {"path": "src/util.go", "coverage": 85.0} + ]` + + s := loadCoverageSignal(t, jsonContent) + + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "src/main.go"}, + {Path: "src/auth/login.go"}, + {Path: "src/util.go"}, + {Path: "src/other.go"}, + }, + } + + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // main.go (45.2 < 50) and login.go (12.5 < 50) should be detected + // util.go (85.0 >= 50) should not + if len(signals) != 2 { + t.Fatalf("got %d signals, want 2", len(signals)) + } + + if signals[0].FilePath != "src/main.go" { + t.Errorf("signals[0].FilePath = %q, want src/main.go", signals[0].FilePath) + } + if signals[0].Weight != LowCoverageDefaultWeight { + t.Errorf("Weight = %d, want %d", signals[0].Weight, LowCoverageDefaultWeight) + } +} + +func TestLowCoverage_OnlyDiffFiles(t *testing.T) { + jsonContent := `[ + {"path": "src/main.go", "coverage": 10.0}, + {"path": "src/other.go", "coverage": 5.0} + ]` + + s := loadCoverageSignal(t, jsonContent) + + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "src/main.go"}, + }, + } + + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(signals) != 1 { + t.Fatalf("got %d signals, want 1", len(signals)) + } +} + +func TestLowCoverage_EmptyFile(t *testing.T) { + s := loadCoverageSignal(t, "[]") + + diff := &git.DiffResult{ + Files: []git.FileDiff{{Path: "src/main.go"}}, + } + + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(signals) != 0 { + t.Errorf("got %d signals, want 0", len(signals)) + } +} + +func TestLowCoverage_MissingFile(t *testing.T) { + _, err := NewLowCoverage("/nonexistent/file.json") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestLowCoverage_InvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "coverage.json") + if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { + t.Fatal(err) + } + + _, err := NewLowCoverage(path) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func loadCoverageSignal(t *testing.T, content string) *LowCoverage { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "coverage.json") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + s, err := NewLowCoverage(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return s +} diff --git a/internal/signal/no_test_change.go b/internal/signal/no_test_change.go index 5515e55..583576a 100644 --- a/internal/signal/no_test_change.go +++ b/internal/signal/no_test_change.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/git" ) @@ -50,12 +51,19 @@ type NoTestChange struct { TestDirs []string } -func NewNoTestChange() *NoTestChange { - return &NoTestChange{ +func NewNoTestChange(weightCfg *config.SignalWeightConfig, testPatterns []string) *NoTestChange { + s := &NoTestChange{ Weight: NoTestChangeDefaultWeight, TestPatterns: DefaultTestPatterns, TestDirs: DefaultTestDirPatterns, } + if weightCfg != nil && weightCfg.Weight != nil { + s.Weight = *weightCfg.Weight + } + if len(testPatterns) > 0 { + s.TestPatterns = testPatterns + } + return s } func (s *NoTestChange) Name() string { @@ -97,7 +105,7 @@ func (s *NoTestChange) isTestFile(path string) bool { for _, pattern := range s.TestPatterns { matched, err := filepath.Match(pattern, base) if err != nil { - continue // skip invalid patterns + continue } if matched { return true diff --git a/internal/signal/no_test_change_test.go b/internal/signal/no_test_change_test.go index 433bb71..4c90746 100644 --- a/internal/signal/no_test_change_test.go +++ b/internal/signal/no_test_change_test.go @@ -8,7 +8,7 @@ import ( ) func TestNoTestChange(t *testing.T) { - s := NewNoTestChange() + s := NewNoTestChange(nil, nil) if s.Name() != "no_test_change" { t.Errorf("Name() = %q, want %q", s.Name(), "no_test_change") diff --git a/internal/signal/security_module.go b/internal/signal/security_module.go index 34ff320..55cb6f1 100644 --- a/internal/signal/security_module.go +++ b/internal/signal/security_module.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/hidetzu/riskcheck/internal/config" "github.com/hidetzu/riskcheck/internal/git" ) @@ -29,11 +30,20 @@ type SecurityModule struct { Paths []string } -func NewSecurityModule() *SecurityModule { - return &SecurityModule{ +func NewSecurityModule(cfg *config.ModuleConfig) *SecurityModule { + s := &SecurityModule{ Weight: SecurityModuleDefaultWeight, Paths: DefaultSecurityPaths, } + if cfg != nil { + if cfg.Weight != nil { + s.Weight = *cfg.Weight + } + if len(cfg.Paths) > 0 { + s.Paths = cfg.Paths + } + } + return s } func (s *SecurityModule) Name() string { diff --git a/internal/signal/security_module_test.go b/internal/signal/security_module_test.go index 698302b..6cfa231 100644 --- a/internal/signal/security_module_test.go +++ b/internal/signal/security_module_test.go @@ -8,7 +8,7 @@ import ( ) func TestSecurityModule(t *testing.T) { - s := NewSecurityModule() + s := NewSecurityModule(nil) if s.Name() != "security_module" { t.Errorf("Name() = %q, want %q", s.Name(), "security_module") diff --git a/internal/signal/signal_test.go b/internal/signal/signal_test.go index 16fc794..5a8240b 100644 --- a/internal/signal/signal_test.go +++ b/internal/signal/signal_test.go @@ -8,7 +8,7 @@ import ( ) func TestLargeChange(t *testing.T) { - s := NewLargeChange() + s := NewLargeChange(nil) if s.Name() != "large_change" { t.Errorf("Name() = %q, want %q", s.Name(), "large_change") @@ -44,7 +44,7 @@ func TestLargeChange(t *testing.T) { } func TestHighInsertions(t *testing.T) { - s := NewHighInsertions() + s := NewHighInsertions(nil) if s.Name() != "high_insertions" { t.Errorf("Name() = %q, want %q", s.Name(), "high_insertions") @@ -79,7 +79,7 @@ func TestHighInsertions(t *testing.T) { } func TestHighDeletions(t *testing.T) { - s := NewHighDeletions() + s := NewHighDeletions(nil) if s.Name() != "high_deletions" { t.Errorf("Name() = %q, want %q", s.Name(), "high_deletions") diff --git a/main.go b/main.go index 3a5473c..3a64dec 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "os" "github.com/hidetzu/riskcheck/cmd" @@ -12,6 +13,7 @@ func main() { if errors.Is(err, cmd.ErrRiskThresholdExceeded) { os.Exit(1) } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(2) } } diff --git a/specs/requirements.md b/specs/requirements.md index 4c40b15..09ee072 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -86,10 +86,11 @@ Each signal is implemented as an independent module, allowing incremental additi - Return results within 5 seconds for typical PRs (~50 file changes) - Minimize git commands since git operations are the bottleneck -### NFR-2: Minimal Dependencies +### NFR-2: Minimal External Runtime Dependencies - Only external binary dependency is `git` (required) -- Step3 complexity/coverage features accept external tool output as optional input; no direct dependency on those tools +- Go library dependencies (e.g., yaml.v3) are acceptable +- Step3 complexity/coverage features accept external tool output as optional input; no direct execution of external tools ### NFR-3: Portability diff --git a/specs/spec.md b/specs/spec.md index ae7afbe..e95111c 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -47,7 +47,9 @@ riskcheck/ │ │ ├── hotspot.go # S-4: Change frequency (Step2) │ │ ├── no_test_change.go # S-5: No test changes (Step2) │ │ ├── core_module.go # S-6: Critical area (Step2) -│ │ └── security_module.go # S-7: Security area (Step2) +│ │ ├── security_module.go # S-7: Security area (Step2) +│ │ ├── high_complexity.go # S-8: External complexity input (Step3) +│ │ └── low_coverage.go # S-9: External coverage input (Step3) │ ├── scorer/ │ │ └── scorer.go # Score calculation and normalization │ ├── formatter/ @@ -56,7 +58,7 @@ riskcheck/ │ │ └── text.go # Text output │ └── config/ │ └── config.go # Config file loading (Step3) -├── .riskcheck.yaml # Sample config file +├── .riskcheck.yaml.example # Sample config file ├── specs/ ├── go.mod └── go.sum @@ -148,12 +150,15 @@ type Client interface { ``` riskcheck [flags] +riskcheck init # Generate .riskcheck.yaml config file Flags: --base string Comparison base (default: "origin/main") --target string Comparison target (default: "." i.e., working tree) --format string Output format: json, text (default: "json") --config string Config file path (default: ".riskcheck.yaml") + --complexity-file string Path to complexity JSON file (optional) + --coverage-file string Path to coverage JSON file (optional) -h, --help Help -v, --version Version ```