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
25 changes: 14 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,26 @@ Temporary, task-scoped specs derived from the master specs.

## Current Focus

**Step1: Minimal Working Version** (see `specs/roadmap.md`)
**Step2: Practical Level** (see `specs/roadmap.md`)

### Tasks

- [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
- [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+)

### Exit Criteria

- `riskcheck --base origin/main` outputs valid JSON with score, level, summary, and reasons
- `riskcheck --base origin/main --format text` outputs readable summary
- 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

## Tech Stack

Expand Down
20 changes: 19 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
"github.com/spf13/cobra"
)

// ErrRiskThresholdExceeded is returned when the risk score is medium or above.
var ErrRiskThresholdExceeded = fmt.Errorf("risk threshold exceeded")

var (
version = "dev"

Expand All @@ -33,6 +36,8 @@ 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.SilenceErrors = true
rootCmd.SilenceUsage = true
}

func Execute() error {
Expand All @@ -54,6 +59,10 @@ func run(cmd *cobra.Command, args []string) error {
signal.NewLargeChange(),
signal.NewHighInsertions(),
signal.NewHighDeletions(),
signal.NewHotspot(gc),
signal.NewNoTestChange(),
signal.NewSecurityModule(),
signal.NewCoreModule(),
)
signals, err := a.Analyze(ctx, diff)
if err != nil {
Expand All @@ -80,5 +89,14 @@ func run(cmd *cobra.Command, args []string) error {
}

_, err = fmt.Fprintln(os.Stdout, string(out))
return err
if err != nil {
return err
}

// Non-zero exit for medium+ risk
if result.Score >= 40 {
return ErrRiskThresholdExceeded
}

return nil
}
26 changes: 25 additions & 1 deletion internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func New(signals ...signal.Signal) *Analyzer {
}

// Analyze runs all signals and collects detected signals.
// It applies priority rules: security_module > core_module (no double counting).
func (a *Analyzer) Analyze(ctx context.Context, diff *git.DiffResult) ([]signal.DetectedSignal, error) {
var result []signal.DetectedSignal

Expand All @@ -29,5 +30,28 @@ func (a *Analyzer) Analyze(ctx context.Context, diff *git.DiffResult) ([]signal.
result = append(result, detected...)
}

return result, nil
return dedup(result), nil
}

// dedup removes core_module signals for files that also have security_module signals.
func dedup(signals []signal.DetectedSignal) []signal.DetectedSignal {
securityFiles := make(map[string]bool)
for _, s := range signals {
if s.SignalName == "security_module" && s.FilePath != "" {
securityFiles[s.FilePath] = true
}
}

if len(securityFiles) == 0 {
return signals
}

var result []signal.DetectedSignal
for _, s := range signals {
if s.SignalName == "core_module" && securityFiles[s.FilePath] {
continue
}
result = append(result, s)
}
return result
}
84 changes: 83 additions & 1 deletion internal/analyzer/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ func TestAnalyzer_MultipleSignals(t *testing.T) {
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}
Expand Down Expand Up @@ -65,3 +64,86 @@ func TestAnalyzer_NoDetection(t *testing.T) {
t.Errorf("got %d signals, want 0", len(result))
}
}

func TestAnalyzer_SecurityOverCore(t *testing.T) {
a := New(
signal.NewSecurityModule(),
signal.NewCoreModule(),
)

tests := []struct {
name string
path string
wantSignals int
wantNames []string
}{
{
name: "auth matches security only (not core)",
path: "src/auth/login.go",
wantSignals: 1,
wantNames: []string{"security_module"},
},
{
name: "config matches core only",
path: "src/config/app.go",
wantSignals: 1,
wantNames: []string{"core_module"},
},
{
name: "unrelated matches neither",
path: "src/handler/home.go",
wantSignals: 0,
wantNames: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff := &git.DiffResult{
Files: []git.FileDiff{{Path: tt.path}},
}
result, err := a.Analyze(context.Background(), diff)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != tt.wantSignals {
t.Fatalf("got %d signals, want %d", len(result), tt.wantSignals)
}
for i, name := range tt.wantNames {
if result[i].SignalName != name {
t.Errorf("signal[%d] = %q, want %q", i, result[i].SignalName, name)
}
}
})
}
}

func TestDedup_SecurityPriority(t *testing.T) {
signals := []signal.DetectedSignal{
{SignalName: "security_module", FilePath: "src/auth/login.go", Weight: 20},
{SignalName: "core_module", FilePath: "src/auth/login.go", Weight: 20},
{SignalName: "core_module", FilePath: "src/config/app.go", Weight: 20},
{SignalName: "large_change", FilePath: "", Weight: 10},
}

result := dedup(signals)

if len(result) != 3 {
t.Fatalf("got %d signals, want 3", len(result))
}

names := make(map[string]int)
for _, s := range result {
names[s.SignalName]++
}

if names["security_module"] != 1 {
t.Errorf("security_module count = %d, want 1", names["security_module"])
}
if names["core_module"] != 1 {
t.Errorf("core_module count = %d, want 1 (only config)", names["core_module"])
}
if names["large_change"] != 1 {
t.Errorf("large_change count = %d, want 1", names["large_change"])
}
}
69 changes: 58 additions & 11 deletions internal/formatter/formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@ import (

func TestJSON_Format(t *testing.T) {
result := &scorer.ScoreResult{
Score: 25,
Level: "low",
Score: 45,
Level: "medium",
Summary: scorer.Summary{
FilesChanged: 3,
Insertions: 45,
Deletions: 12,
},
Reasons: []string{"high number of insertions (250 lines)"},
Files: []scorer.FileRisk{
{Path: "src/auth/login.go", Risk: 0.75, Signals: []string{"hotspot", "security_module"}},
{Path: "src/main.go", Risk: 0, Signals: nil},
},
}

f := NewJSON()
Expand All @@ -26,18 +30,21 @@ func TestJSON_Format(t *testing.T) {
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"} {
for _, key := range []string{"score", "level", "summary", "reasons", "files"} {
if _, ok := parsed[key]; !ok {
t.Errorf("missing key %q in JSON output", key)
}
}

files := parsed["files"].([]interface{})
if len(files) != 2 {
t.Errorf("files count = %d, want 2", len(files))
}
}

func TestJSON_EmptyReasons(t *testing.T) {
Expand All @@ -46,6 +53,33 @@ func TestJSON_EmptyReasons(t *testing.T) {
Level: "low",
Summary: scorer.Summary{},
Reasons: nil,
Files: nil,
}

f := NewJSON()
out, err := f.Format(result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

s := string(out)
if !strings.Contains(s, `"reasons": []`) {
t.Errorf("expected reasons to be [], got: %s", s)
}
if !strings.Contains(s, `"files": []`) {
t.Errorf("expected files to be [], got: %s", s)
}
}

func TestJSON_NilSignalsInFile(t *testing.T) {
result := &scorer.ScoreResult{
Score: 0,
Level: "low",
Summary: scorer.Summary{},
Reasons: []string{},
Files: []scorer.FileRisk{
{Path: "a.go", Risk: 0, Signals: nil},
},
}

f := NewJSON()
Expand All @@ -54,22 +88,25 @@ func TestJSON_EmptyReasons(t *testing.T) {
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)
if strings.Contains(string(out), `"signals": null`) {
t.Error("signals should be [] not null")
}
}

func TestText_Format(t *testing.T) {
result := &scorer.ScoreResult{
Score: 25,
Level: "low",
Score: 45,
Level: "medium",
Summary: scorer.Summary{
FilesChanged: 3,
Insertions: 45,
Deletions: 12,
},
Reasons: []string{"high number of insertions"},
Files: []scorer.FileRisk{
{Path: "src/auth/login.go", Risk: 0.75, Signals: []string{"hotspot", "security_module"}},
{Path: "src/main.go", Risk: 0, Signals: nil},
},
}

f := NewText()
Expand All @@ -81,19 +118,26 @@ func TestText_Format(t *testing.T) {
text := string(out)

checks := []string{
"Risk Score: 25 / 100 (low)",
"Risk Score: 45 / 100 (medium)",
"Files changed: 3",
"Insertions: 45",
"Deletions: 12",
"Reasons:",
"- high number of insertions",
"High-risk files:",
"0.75 src/auth/login.go [hotspot, security_module]",
}

for _, c := range checks {
if !strings.Contains(text, c) {
t.Errorf("output missing %q\ngot:\n%s", c, text)
}
}

// src/main.go should NOT appear (risk = 0)
if strings.Contains(text, "src/main.go") {
t.Error("should not show files with zero risk")
}
}

func TestText_NoReasons(t *testing.T) {
Expand All @@ -112,4 +156,7 @@ func TestText_NoReasons(t *testing.T) {
if strings.Contains(string(out), "Reasons:") {
t.Error("should not contain Reasons section when empty")
}
if strings.Contains(string(out), "High-risk files:") {
t.Error("should not contain High-risk files section when empty")
}
}
10 changes: 9 additions & 1 deletion internal/formatter/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@ func NewJSON() *JSON {
}

func (f *JSON) Format(result *scorer.ScoreResult) ([]byte, error) {
// Ensure reasons is always an array, not null
// Ensure slices are always arrays, not null
if result.Reasons == nil {
result.Reasons = []string{}
}
if result.Files == nil {
result.Files = []scorer.FileRisk{}
}
for i := range result.Files {
if result.Files[i].Signals == nil {
result.Files[i].Signals = []string{}
}
}
return json.MarshalIndent(result, "", " ")
}
Loading
Loading