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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.steering/
input.md
bin/
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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/
84 changes: 84 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
33 changes: 33 additions & 0 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
@@ -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
}
67 changes: 67 additions & 0 deletions internal/analyzer/analyzer_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
10 changes: 10 additions & 0 deletions internal/formatter/formatter.go
Original file line number Diff line number Diff line change
@@ -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)
}
115 changes: 115 additions & 0 deletions internal/formatter/formatter_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading