diff --git a/.gitignore b/.gitignore index 8aed24f..d0c5b73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .steering/ input.md +bin/ diff --git a/CLAUDE.md b/CLAUDE.md index 4036153..15a2567 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,14 +54,14 @@ Temporary, task-scoped specs derived from the master specs. ### Tasks -- [ ] Project scaffold (`go mod init`, cobra CLI, main.go) -- [ ] Git diff parsing (`git diff --numstat`) -- [ ] S-1: large_change signal -- [ ] S-2: high_insertions signal -- [ ] S-3: high_deletions signal -- [ ] Scorer (sum weights, clip 0-100, assign level) -- [ ] JSON formatter -- [ ] Text formatter +- [x] Project scaffold (`go mod init`, cobra CLI, main.go) +- [x] Git diff parsing (`git diff --numstat`) +- [x] S-1: large_change signal +- [x] S-2: high_insertions signal +- [x] S-3: high_deletions signal +- [x] Scorer (sum weights, clip 0-100, assign level) +- [x] JSON formatter +- [x] Text formatter ### Exit Criteria diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9ba911a --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +BINARY := riskcheck +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +LDFLAGS := -ldflags "-X github.com/hidetzu/riskcheck/cmd.version=$(VERSION)" + +.PHONY: build test lint vet clean + +build: + go build $(LDFLAGS) -o bin/$(BINARY) . + +test: + go test ./... -v -race + +lint: + golangci-lint run + +vet: + go vet ./... + +clean: + rm -rf bin/ diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..1c584e0 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/hidetzu/riskcheck/internal/analyzer" + "github.com/hidetzu/riskcheck/internal/formatter" + gitpkg "github.com/hidetzu/riskcheck/internal/git" + "github.com/hidetzu/riskcheck/internal/scorer" + "github.com/hidetzu/riskcheck/internal/signal" + "github.com/spf13/cobra" +) + +var ( + version = "dev" + + flagBase string + flagTarget string + flagFormat string +) + +var rootCmd = &cobra.Command{ + Use: "riskcheck", + Short: "Quantify code change risk", + Long: "A CLI tool that analyzes git diff and calculates a risk score with explainable reasons.", + Version: version, + RunE: run, +} + +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") +} + +func Execute() error { + return rootCmd.Execute() +} + +func run(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // 1. 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(), + ) + signals, err := a.Analyze(ctx, diff) + if err != nil { + return fmt.Errorf("analyze: %w", err) + } + + // 3. Score + result := scorer.Score(signals, diff) + + // 4. Format and output + var f formatter.Formatter + switch flagFormat { + case "json": + f = formatter.NewJSON() + case "text": + f = formatter.NewText() + default: + return fmt.Errorf("unknown format: %s", flagFormat) + } + + out, err := f.Format(result) + if err != nil { + return fmt.Errorf("format: %w", err) + } + + _, err = fmt.Fprintln(os.Stdout, string(out)) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d35d3e1 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/hidetzu/riskcheck + +go 1.26.1 + +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +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= diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go new file mode 100644 index 0000000..4058ec5 --- /dev/null +++ b/internal/analyzer/analyzer.go @@ -0,0 +1,33 @@ +package analyzer + +import ( + "context" + + "github.com/hidetzu/riskcheck/internal/git" + "github.com/hidetzu/riskcheck/internal/signal" +) + +// Analyzer runs all registered signals against a diff result. +type Analyzer struct { + signals []signal.Signal +} + +// New creates an Analyzer with the given signals. +func New(signals ...signal.Signal) *Analyzer { + return &Analyzer{signals: signals} +} + +// Analyze runs all signals and collects detected signals. +func (a *Analyzer) Analyze(ctx context.Context, diff *git.DiffResult) ([]signal.DetectedSignal, error) { + var result []signal.DetectedSignal + + for _, s := range a.signals { + detected, err := s.Detect(ctx, diff) + if err != nil { + return nil, err + } + result = append(result, detected...) + } + + return result, nil +} diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go new file mode 100644 index 0000000..05b30ce --- /dev/null +++ b/internal/analyzer/analyzer_test.go @@ -0,0 +1,67 @@ +package analyzer + +import ( + "context" + "testing" + + "github.com/hidetzu/riskcheck/internal/git" + "github.com/hidetzu/riskcheck/internal/signal" +) + +func TestAnalyzer_NoSignals(t *testing.T) { + a := New() + diff := &git.DiffResult{} + + result, err := a.Analyze(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Errorf("got %d signals, want 0", len(result)) + } +} + +func TestAnalyzer_MultipleSignals(t *testing.T) { + a := New( + signal.NewLargeChange(), + signal.NewHighInsertions(), + signal.NewHighDeletions(), + ) + + // 15 files, 300 insertions each, 300 deletions each → all 3 signals fire + files := make([]git.FileDiff, 15) + for i := range files { + files[i] = git.FileDiff{Path: "f.go", Insertions: 20, Deletions: 20} + } + diff := &git.DiffResult{Files: files} + + result, err := a.Analyze(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 3 { + t.Errorf("got %d signals, want 3", len(result)) + } +} + +func TestAnalyzer_NoDetection(t *testing.T) { + a := New( + signal.NewLargeChange(), + signal.NewHighInsertions(), + signal.NewHighDeletions(), + ) + + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "a.go", Insertions: 5, Deletions: 3}, + }, + } + + result, err := a.Analyze(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Errorf("got %d signals, want 0", len(result)) + } +} diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go new file mode 100644 index 0000000..038cb4e --- /dev/null +++ b/internal/formatter/formatter.go @@ -0,0 +1,10 @@ +package formatter + +import ( + "github.com/hidetzu/riskcheck/internal/scorer" +) + +// Formatter formats the score result for output. +type Formatter interface { + Format(result *scorer.ScoreResult) ([]byte, error) +} diff --git a/internal/formatter/formatter_test.go b/internal/formatter/formatter_test.go new file mode 100644 index 0000000..263a038 --- /dev/null +++ b/internal/formatter/formatter_test.go @@ -0,0 +1,115 @@ +package formatter + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hidetzu/riskcheck/internal/scorer" +) + +func TestJSON_Format(t *testing.T) { + result := &scorer.ScoreResult{ + Score: 25, + Level: "low", + Summary: scorer.Summary{ + FilesChanged: 3, + Insertions: 45, + Deletions: 12, + }, + Reasons: []string{"high number of insertions (250 lines)"}, + } + + f := NewJSON() + out, err := f.Format(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify it's valid JSON + var parsed map[string]interface{} + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Verify required keys + for _, key := range []string{"score", "level", "summary", "reasons"} { + if _, ok := parsed[key]; !ok { + t.Errorf("missing key %q in JSON output", key) + } + } +} + +func TestJSON_EmptyReasons(t *testing.T) { + result := &scorer.ScoreResult{ + Score: 0, + Level: "low", + Summary: scorer.Summary{}, + Reasons: nil, + } + + f := NewJSON() + out, err := f.Format(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify reasons is [] not null + if !strings.Contains(string(out), `"reasons": []`) { + t.Errorf("expected reasons to be [], got: %s", out) + } +} + +func TestText_Format(t *testing.T) { + result := &scorer.ScoreResult{ + Score: 25, + Level: "low", + Summary: scorer.Summary{ + FilesChanged: 3, + Insertions: 45, + Deletions: 12, + }, + Reasons: []string{"high number of insertions"}, + } + + f := NewText() + out, err := f.Format(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := string(out) + + checks := []string{ + "Risk Score: 25 / 100 (low)", + "Files changed: 3", + "Insertions: 45", + "Deletions: 12", + "Reasons:", + "- high number of insertions", + } + + for _, c := range checks { + if !strings.Contains(text, c) { + t.Errorf("output missing %q\ngot:\n%s", c, text) + } + } +} + +func TestText_NoReasons(t *testing.T) { + result := &scorer.ScoreResult{ + Score: 0, + Level: "low", + Summary: scorer.Summary{}, + } + + f := NewText() + out, err := f.Format(result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(string(out), "Reasons:") { + t.Error("should not contain Reasons section when empty") + } +} diff --git a/internal/formatter/json.go b/internal/formatter/json.go new file mode 100644 index 0000000..2964b92 --- /dev/null +++ b/internal/formatter/json.go @@ -0,0 +1,22 @@ +package formatter + +import ( + "encoding/json" + + "github.com/hidetzu/riskcheck/internal/scorer" +) + +// JSON formats the score result as JSON. +type JSON struct{} + +func NewJSON() *JSON { + return &JSON{} +} + +func (f *JSON) Format(result *scorer.ScoreResult) ([]byte, error) { + // Ensure reasons is always an array, not null + if result.Reasons == nil { + result.Reasons = []string{} + } + return json.MarshalIndent(result, "", " ") +} diff --git a/internal/formatter/text.go b/internal/formatter/text.go new file mode 100644 index 0000000..39caa26 --- /dev/null +++ b/internal/formatter/text.go @@ -0,0 +1,35 @@ +package formatter + +import ( + "fmt" + "strings" + + "github.com/hidetzu/riskcheck/internal/scorer" +) + +// Text formats the score result as human-readable text. +type Text struct{} + +func NewText() *Text { + return &Text{} +} + +func (f *Text) Format(result *scorer.ScoreResult) ([]byte, error) { + var b strings.Builder + + fmt.Fprintf(&b, "Risk Score: %d / 100 (%s)\n", result.Score, result.Level) + fmt.Fprintln(&b) + fmt.Fprintf(&b, "Files changed: %d\n", result.Summary.FilesChanged) + fmt.Fprintf(&b, "Insertions: %d\n", result.Summary.Insertions) + fmt.Fprintf(&b, "Deletions: %d\n", result.Summary.Deletions) + + if len(result.Reasons) > 0 { + fmt.Fprintln(&b) + fmt.Fprintln(&b, "Reasons:") + for _, r := range result.Reasons { + fmt.Fprintf(&b, " - %s\n", r) + } + } + + return []byte(b.String()), nil +} diff --git a/internal/git/diff.go b/internal/git/diff.go new file mode 100644 index 0000000..b1bb6b0 --- /dev/null +++ b/internal/git/diff.go @@ -0,0 +1,61 @@ +package git + +import ( + "fmt" + "strconv" + "strings" +) + +// ParseNumstat parses the output of git diff --numstat. +func ParseNumstat(output string) (*DiffResult, error) { + if output == "" { + return &DiffResult{}, nil + } + + var files []FileDiff + for _, line := range strings.Split(output, "\n") { + if line == "" { + continue + } + + fd, err := parseNumstatLine(line) + if err != nil { + return nil, fmt.Errorf("parse numstat line %q: %w", line, err) + } + files = append(files, fd) + } + + return &DiffResult{Files: files}, nil +} + +func parseNumstatLine(line string) (FileDiff, error) { + fields := strings.SplitN(line, "\t", 3) + if len(fields) != 3 { + return FileDiff{}, fmt.Errorf("expected 3 tab-separated fields, got %d", len(fields)) + } + + // Binary files show "-" for insertions/deletions + if fields[0] == "-" && fields[1] == "-" { + return FileDiff{ + Path: fields[2], + Insertions: 0, + Deletions: 0, + }, nil + } + + ins, err := strconv.Atoi(fields[0]) + if err != nil { + return FileDiff{}, fmt.Errorf("parse insertions %q: %w", fields[0], err) + } + + del, err := strconv.Atoi(fields[1]) + if err != nil { + return FileDiff{}, fmt.Errorf("parse deletions %q: %w", fields[1], err) + } + + return FileDiff{ + Path: fields[2], + Insertions: ins, + Deletions: del, + }, nil +} diff --git a/internal/git/diff_test.go b/internal/git/diff_test.go new file mode 100644 index 0000000..c88d0d9 --- /dev/null +++ b/internal/git/diff_test.go @@ -0,0 +1,109 @@ +package git + +import ( + "testing" +) + +func TestParseNumstat(t *testing.T) { + tests := []struct { + name string + input string + want *DiffResult + wantErr bool + }{ + { + name: "empty input", + input: "", + want: &DiffResult{}, + }, + { + name: "single file", + input: "10\t5\tsrc/main.go", + want: &DiffResult{ + Files: []FileDiff{ + {Path: "src/main.go", Insertions: 10, Deletions: 5}, + }, + }, + }, + { + name: "multiple files", + input: "10\t5\tsrc/main.go\n20\t3\tsrc/util.go", + want: &DiffResult{ + Files: []FileDiff{ + {Path: "src/main.go", Insertions: 10, Deletions: 5}, + {Path: "src/util.go", Insertions: 20, Deletions: 3}, + }, + }, + }, + { + name: "binary file", + input: "-\t-\timage.png", + want: &DiffResult{ + Files: []FileDiff{ + {Path: "image.png", Insertions: 0, Deletions: 0}, + }, + }, + }, + { + name: "mixed text and binary", + input: "10\t5\tsrc/main.go\n-\t-\timage.png", + want: &DiffResult{ + Files: []FileDiff{ + {Path: "src/main.go", Insertions: 10, Deletions: 5}, + {Path: "image.png", Insertions: 0, Deletions: 0}, + }, + }, + }, + { + name: "invalid format", + input: "not a valid line", + wantErr: true, + }, + { + name: "trailing newline", + input: "10\t5\tsrc/main.go\n", + want: &DiffResult{ + Files: []FileDiff{ + {Path: "src/main.go", Insertions: 10, Deletions: 5}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseNumstat(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseNumstat() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if len(got.Files) != len(tt.want.Files) { + t.Fatalf("got %d files, want %d", len(got.Files), len(tt.want.Files)) + } + for i, f := range got.Files { + w := tt.want.Files[i] + if f.Path != w.Path || f.Insertions != w.Insertions || f.Deletions != w.Deletions { + t.Errorf("file[%d] = %+v, want %+v", i, f, w) + } + } + }) + } +} + +func TestDiffResultTotals(t *testing.T) { + d := &DiffResult{ + Files: []FileDiff{ + {Path: "a.go", Insertions: 10, Deletions: 5}, + {Path: "b.go", Insertions: 20, Deletions: 3}, + }, + } + + if got := d.TotalInsertions(); got != 30 { + t.Errorf("TotalInsertions() = %d, want 30", got) + } + if got := d.TotalDeletions(); got != 8 { + t.Errorf("TotalDeletions() = %d, want 8", got) + } +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..927ff17 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,61 @@ +package git + +import ( + "context" + "os/exec" + "strings" +) + +// FileDiff represents a single file change in a diff. +type FileDiff struct { + Path string + Insertions int + Deletions int +} + +// DiffResult holds the parsed result of a git diff. +type DiffResult struct { + Files []FileDiff +} + +// TotalInsertions returns the sum of all insertions. +func (d *DiffResult) TotalInsertions() int { + total := 0 + for _, f := range d.Files { + total += f.Insertions + } + return total +} + +// TotalDeletions returns the sum of all deletions. +func (d *DiffResult) TotalDeletions() int { + total := 0 + for _, f := range d.Files { + total += f.Deletions + } + return total +} + +// Client executes git commands. +type Client struct{} + +// NewClient creates a new git client. +func NewClient() *Client { + return &Client{} +} + +// Diff runs git diff --numstat and returns the parsed result. +func (c *Client) Diff(ctx context.Context, base, target string) (*DiffResult, error) { + args := []string{"diff", "--numstat", base} + if target != "." { + args = append(args, target) + } + + cmd := exec.CommandContext(ctx, "git", args...) + out, err := cmd.Output() + if err != nil { + return nil, err + } + + return ParseNumstat(strings.TrimSpace(string(out))) +} diff --git a/internal/scorer/scorer.go b/internal/scorer/scorer.go new file mode 100644 index 0000000..3df657d --- /dev/null +++ b/internal/scorer/scorer.go @@ -0,0 +1,66 @@ +package scorer + +import ( + "github.com/hidetzu/riskcheck/internal/git" + "github.com/hidetzu/riskcheck/internal/signal" +) + +// ScoreResult holds the final risk assessment. +type ScoreResult struct { + Score int `json:"score"` + Level string `json:"level"` + Summary Summary `json:"summary"` + Reasons []string `json:"reasons"` +} + +// Summary holds aggregate change statistics. +type Summary struct { + FilesChanged int `json:"files_changed"` + Insertions int `json:"insertions"` + Deletions int `json:"deletions"` +} + +// Score calculates the risk score from detected signals and diff data. +func Score(signals []signal.DetectedSignal, diff *git.DiffResult) *ScoreResult { + total := 0 + var reasons []string + + for _, s := range signals { + total += s.Weight + reasons = append(reasons, s.Reason) + } + + // Clip to 0-100 + if total > 100 { + total = 100 + } + if total < 0 { + total = 0 + } + + return &ScoreResult{ + Score: total, + Level: level(total), + Summary: makeSummary(diff), + Reasons: reasons, + } +} + +func level(score int) string { + switch { + case score >= 70: + return "high" + case score >= 40: + return "medium" + default: + return "low" + } +} + +func makeSummary(diff *git.DiffResult) Summary { + return Summary{ + FilesChanged: len(diff.Files), + Insertions: diff.TotalInsertions(), + Deletions: diff.TotalDeletions(), + } +} diff --git a/internal/scorer/scorer_test.go b/internal/scorer/scorer_test.go new file mode 100644 index 0000000..8c2a0a3 --- /dev/null +++ b/internal/scorer/scorer_test.go @@ -0,0 +1,110 @@ +package scorer + +import ( + "testing" + + "github.com/hidetzu/riskcheck/internal/git" + "github.com/hidetzu/riskcheck/internal/signal" +) + +func TestScore_NoSignals(t *testing.T) { + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "a.go", Insertions: 10, Deletions: 5}, + }, + } + + result := Score(nil, diff) + + if result.Score != 0 { + t.Errorf("Score = %d, want 0", result.Score) + } + if result.Level != "low" { + t.Errorf("Level = %q, want %q", result.Level, "low") + } + if len(result.Reasons) != 0 { + t.Errorf("Reasons = %v, want empty", result.Reasons) + } + if result.Summary.FilesChanged != 1 { + t.Errorf("FilesChanged = %d, want 1", result.Summary.FilesChanged) + } +} + +func TestScore_SingleSignal(t *testing.T) { + signals := []signal.DetectedSignal{ + {SignalName: "large_change", Weight: 10, Reason: "large number of files changed"}, + } + diff := &git.DiffResult{} + + result := Score(signals, diff) + + if result.Score != 10 { + t.Errorf("Score = %d, want 10", result.Score) + } + if result.Level != "low" { + t.Errorf("Level = %q, want %q", result.Level, "low") + } + if len(result.Reasons) != 1 { + t.Errorf("got %d reasons, want 1", len(result.Reasons)) + } +} + +func TestScore_MultipleSignals(t *testing.T) { + signals := []signal.DetectedSignal{ + {SignalName: "large_change", Weight: 10, Reason: "r1"}, + {SignalName: "high_insertions", Weight: 10, Reason: "r2"}, + {SignalName: "high_deletions", Weight: 5, Reason: "r3"}, + } + diff := &git.DiffResult{} + + result := Score(signals, diff) + + if result.Score != 25 { + t.Errorf("Score = %d, want 25", result.Score) + } + if len(result.Reasons) != 3 { + t.Errorf("got %d reasons, want 3", len(result.Reasons)) + } +} + +func TestScore_ClipAt100(t *testing.T) { + var signals []signal.DetectedSignal + for i := 0; i < 20; i++ { + signals = append(signals, signal.DetectedSignal{Weight: 10, Reason: "r"}) + } + diff := &git.DiffResult{} + + result := Score(signals, diff) + + if result.Score != 100 { + t.Errorf("Score = %d, want 100", result.Score) + } + if result.Level != "high" { + t.Errorf("Level = %q, want %q", result.Level, "high") + } +} + +func TestScore_LevelBoundaries(t *testing.T) { + tests := []struct { + score int + level string + }{ + {0, "low"}, + {39, "low"}, + {40, "medium"}, + {69, "medium"}, + {70, "high"}, + {100, "high"}, + } + + for _, tt := range tests { + diff := &git.DiffResult{} + signals := []signal.DetectedSignal{ + {Weight: tt.score, Reason: "test"}, + } + result := Score(signals, diff) + if result.Level != tt.level { + t.Errorf("score %d: Level = %q, want %q", tt.score, result.Level, tt.level) + } + } +} diff --git a/internal/signal/high_deletions.go b/internal/signal/high_deletions.go new file mode 100644 index 0000000..1c919fe --- /dev/null +++ b/internal/signal/high_deletions.go @@ -0,0 +1,45 @@ +package signal + +import ( + "context" + "fmt" + + "github.com/hidetzu/riskcheck/internal/git" +) + +const ( + HighDeletionsDefaultThreshold = 200 + HighDeletionsDefaultWeight = 5 +) + +// HighDeletions detects when the total number of deleted lines exceeds a threshold. +type HighDeletions struct { + Threshold int + Weight int +} + +func NewHighDeletions() *HighDeletions { + return &HighDeletions{ + Threshold: HighDeletionsDefaultThreshold, + Weight: HighDeletionsDefaultWeight, + } +} + +func (s *HighDeletions) Name() string { + return "high_deletions" +} + +func (s *HighDeletions) Detect(_ context.Context, diff *git.DiffResult) ([]DetectedSignal, error) { + total := diff.TotalDeletions() + if total <= s.Threshold { + return nil, nil + } + + return []DetectedSignal{ + { + SignalName: s.Name(), + Weight: s.Weight, + Reason: fmt.Sprintf("high number of deletions (%d lines)", total), + }, + }, nil +} diff --git a/internal/signal/high_insertions.go b/internal/signal/high_insertions.go new file mode 100644 index 0000000..2184264 --- /dev/null +++ b/internal/signal/high_insertions.go @@ -0,0 +1,45 @@ +package signal + +import ( + "context" + "fmt" + + "github.com/hidetzu/riskcheck/internal/git" +) + +const ( + HighInsertionsDefaultThreshold = 200 + HighInsertionsDefaultWeight = 10 +) + +// HighInsertions detects when the total number of added lines exceeds a threshold. +type HighInsertions struct { + Threshold int + Weight int +} + +func NewHighInsertions() *HighInsertions { + return &HighInsertions{ + Threshold: HighInsertionsDefaultThreshold, + Weight: HighInsertionsDefaultWeight, + } +} + +func (s *HighInsertions) Name() string { + return "high_insertions" +} + +func (s *HighInsertions) Detect(_ context.Context, diff *git.DiffResult) ([]DetectedSignal, error) { + total := diff.TotalInsertions() + if total <= s.Threshold { + return nil, nil + } + + return []DetectedSignal{ + { + SignalName: s.Name(), + Weight: s.Weight, + Reason: fmt.Sprintf("high number of insertions (%d lines)", total), + }, + }, nil +} diff --git a/internal/signal/large_change.go b/internal/signal/large_change.go new file mode 100644 index 0000000..3f4f179 --- /dev/null +++ b/internal/signal/large_change.go @@ -0,0 +1,45 @@ +package signal + +import ( + "context" + "fmt" + + "github.com/hidetzu/riskcheck/internal/git" +) + +const ( + LargeChangeDefaultThreshold = 10 + LargeChangeDefaultWeight = 10 +) + +// LargeChange detects when the number of changed files exceeds a threshold. +type LargeChange struct { + Threshold int + Weight int +} + +func NewLargeChange() *LargeChange { + return &LargeChange{ + Threshold: LargeChangeDefaultThreshold, + Weight: LargeChangeDefaultWeight, + } +} + +func (s *LargeChange) Name() string { + return "large_change" +} + +func (s *LargeChange) Detect(_ context.Context, diff *git.DiffResult) ([]DetectedSignal, error) { + count := len(diff.Files) + if count <= s.Threshold { + return nil, nil + } + + return []DetectedSignal{ + { + SignalName: s.Name(), + Weight: s.Weight, + Reason: fmt.Sprintf("large number of files changed (%d files)", count), + }, + }, nil +} diff --git a/internal/signal/signal.go b/internal/signal/signal.go new file mode 100644 index 0000000..b5e457c --- /dev/null +++ b/internal/signal/signal.go @@ -0,0 +1,21 @@ +package signal + +import ( + "context" + + "github.com/hidetzu/riskcheck/internal/git" +) + +// DetectedSignal represents a single risk signal detected from a diff. +type DetectedSignal struct { + SignalName string + FilePath string + Weight int + Reason string +} + +// Signal detects risk signals from diff data. +type Signal interface { + Name() string + Detect(ctx context.Context, diff *git.DiffResult) ([]DetectedSignal, error) +} diff --git a/internal/signal/signal_test.go b/internal/signal/signal_test.go new file mode 100644 index 0000000..16fc794 --- /dev/null +++ b/internal/signal/signal_test.go @@ -0,0 +1,131 @@ +package signal + +import ( + "context" + "testing" + + "github.com/hidetzu/riskcheck/internal/git" +) + +func TestLargeChange(t *testing.T) { + s := NewLargeChange() + + if s.Name() != "large_change" { + t.Errorf("Name() = %q, want %q", s.Name(), "large_change") + } + + tests := []struct { + name string + files int + detected bool + }{ + {"below threshold", 5, false}, + {"at threshold", 10, false}, + {"above threshold", 11, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff := makeDiff(tt.files, 1, 1) + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(signals) > 0; got != tt.detected { + t.Errorf("detected = %v, want %v", got, tt.detected) + } + if tt.detected { + if signals[0].Weight != LargeChangeDefaultWeight { + t.Errorf("Weight = %d, want %d", signals[0].Weight, LargeChangeDefaultWeight) + } + } + }) + } +} + +func TestHighInsertions(t *testing.T) { + s := NewHighInsertions() + + if s.Name() != "high_insertions" { + t.Errorf("Name() = %q, want %q", s.Name(), "high_insertions") + } + + tests := []struct { + name string + insertions int + detected bool + }{ + {"below threshold", 100, false}, + {"at threshold", 200, false}, + {"above threshold", 201, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "a.go", Insertions: tt.insertions, Deletions: 0}, + }, + } + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(signals) > 0; got != tt.detected { + t.Errorf("detected = %v, want %v", got, tt.detected) + } + }) + } +} + +func TestHighDeletions(t *testing.T) { + s := NewHighDeletions() + + if s.Name() != "high_deletions" { + t.Errorf("Name() = %q, want %q", s.Name(), "high_deletions") + } + + tests := []struct { + name string + deletes int + detected bool + }{ + {"below threshold", 100, false}, + {"at threshold", 200, false}, + {"above threshold", 201, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff := &git.DiffResult{ + Files: []git.FileDiff{ + {Path: "a.go", Insertions: 0, Deletions: tt.deletes}, + }, + } + signals, err := s.Detect(context.Background(), diff) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := len(signals) > 0; got != tt.detected { + t.Errorf("detected = %v, want %v", got, tt.detected) + } + if tt.detected { + if signals[0].Weight != HighDeletionsDefaultWeight { + t.Errorf("Weight = %d, want %d", signals[0].Weight, HighDeletionsDefaultWeight) + } + } + }) + } +} + +func makeDiff(fileCount, insertions, deletions int) *git.DiffResult { + files := make([]git.FileDiff, fileCount) + for i := range files { + files[i] = git.FileDiff{ + Path: "file.go", + Insertions: insertions, + Deletions: deletions, + } + } + return &git.DiffResult{Files: files} +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0458275 --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/hidetzu/riskcheck/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +}