Skip to content
Open
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
22 changes: 17 additions & 5 deletions cmd/diffguard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ import (
func main() {
var cfg Config
flag.IntVar(&cfg.ComplexityThreshold, "complexity-threshold", 10, "Maximum cognitive complexity per function")
flag.IntVar(&cfg.ComplexityDeltaTolerance, "complexity-delta-tolerance", 3, "In diff mode, ignore complexity regressions where head exceeds base by this much or less; brand-new functions still gated by --complexity-threshold")
flag.IntVar(&cfg.FunctionSizeThreshold, "function-size-threshold", 50, "Maximum lines per function")
flag.IntVar(&cfg.FunctionSizeDeltaTolerance, "function-size-delta-tolerance", 5, "In diff mode, ignore per-function size regressions where head grows by this many lines or fewer")
flag.IntVar(&cfg.FileSizeThreshold, "file-size-threshold", 500, "Maximum lines per file")
flag.IntVar(&cfg.FileSizeDeltaTolerancePct, "file-size-delta-tolerance-pct", 5, "In diff mode, ignore per-file size regressions where head grows by no more than this % of base lines (subject to --file-size-delta-tolerance-floor)")
flag.IntVar(&cfg.FileSizeDeltaToleranceFloor, "file-size-delta-tolerance-floor", 10, "Minimum absolute line growth tolerated regardless of --file-size-delta-tolerance-pct, so tiny absolute additions to small files don't fail")
flag.BoolVar(&cfg.SkipMutation, "skip-mutation", false, "Skip mutation testing")
flag.BoolVar(&cfg.SkipDeadCode, "skip-deadcode", false, "Skip dead code (unused symbol) detection")
flag.BoolVar(&cfg.SkipGenerated, "skip-generated", true, "Skip files marked as generated (for example `Code generated ... DO NOT EDIT`)")
Expand Down Expand Up @@ -69,9 +73,13 @@ func main() {

// Config holds CLI configuration.
type Config struct {
ComplexityThreshold int
FunctionSizeThreshold int
FileSizeThreshold int
ComplexityThreshold int
ComplexityDeltaTolerance int
FunctionSizeThreshold int
FunctionSizeDeltaTolerance int
FileSizeThreshold int
FileSizeDeltaTolerancePct int
FileSizeDeltaToleranceFloor int
SkipMutation bool
SkipDeadCode bool
SkipGenerated bool
Expand Down Expand Up @@ -272,13 +280,17 @@ func announceRun(d *diff.Result, cfg Config, l lang.Language, numLanguages int)
func runAnalyses(repoPath string, d *diff.Result, cfg Config, l lang.Language) ([]report.Section, error) {
var sections []report.Section

complexitySection, err := complexity.Analyze(repoPath, d, cfg.ComplexityThreshold, l.ComplexityCalculator())
complexitySection, err := complexity.Analyze(repoPath, d, cfg.ComplexityThreshold, cfg.ComplexityDeltaTolerance, l.ComplexityCalculator())
if err != nil {
return nil, fmt.Errorf("complexity analysis: %w", err)
}
sections = append(sections, complexitySection)

sizesSection, err := sizes.Analyze(repoPath, d, cfg.FunctionSizeThreshold, cfg.FileSizeThreshold, l.FunctionExtractor())
sizesSection, err := sizes.Analyze(repoPath, d, cfg.FunctionSizeThreshold, cfg.FileSizeThreshold, sizes.DeltaTolerances{
FuncLines: cfg.FunctionSizeDeltaTolerance,
FilePct: cfg.FileSizeDeltaTolerancePct,
FileFloorLines: cfg.FileSizeDeltaToleranceFloor,
}, l.FunctionExtractor())
if err != nil {
return nil, fmt.Errorf("size analysis: %w", err)
}
Expand Down
57 changes: 57 additions & 0 deletions internal/baseline/baseline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Package baseline provides helpers for "delta gating": running an analyzer
// against the pre-change version of a file (via `git show <merge-base>:<path>`)
// so that callers can drop findings whose underlying metric did not get worse
// in the diff.
//
// The package keeps language analyzers stateless and unaware of base refs:
// the file content is fetched, written to a temp file preserving the original
// extension, and the existing AnalyzeFile / ExtractFunctions methods run on
// it with a synthetic full-coverage FileChange.
package baseline

import (
"fmt"
"math"
"os"
"path/filepath"

"github.com/0xPolygon/diffguard/internal/diff"
)

// FullCoverage returns a FileChange whose single region spans the entire file,
// so per-language overlap filters include every function.
func FullCoverage(repoRelPath string) diff.FileChange {
return diff.FileChange{
Path: repoRelPath,
Regions: []diff.ChangedRegion{{StartLine: 1, EndLine: math.MaxInt32}},
}
}

// FetchToTemp fetches repoRelPath at ref and writes it to a temp file whose
// name preserves the original extension (some analyzers branch on path
// extension). Returns ("", nil) if the file did not exist at the base ref.
// Caller is responsible for os.Remove(path).
func FetchToTemp(repoPath, ref, repoRelPath string) (string, error) {
content, err := diff.ShowAtRef(repoPath, ref, repoRelPath)
if err != nil {
return "", err
}
if content == nil {
return "", nil
}
ext := filepath.Ext(repoRelPath)
tmp, err := os.CreateTemp("", "diffguard-base-*"+ext)
if err != nil {
return "", fmt.Errorf("temp file: %w", err)
}
if _, err := tmp.Write(content); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return "", fmt.Errorf("write base content: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmp.Name())
return "", err
}
return tmp.Name(), nil
}
122 changes: 122 additions & 0 deletions internal/baseline/baseline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package baseline

import (
"math"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

func initRepoWith(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
runGit(t, dir, "init", "-q", "--initial-branch=main")
runGit(t, dir, "config", "user.email", "test@example.com")
runGit(t, dir, "config", "user.name", "Test")
runGit(t, dir, "config", "commit.gpgsign", "false")
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-q", "-m", "init")
return dir
}

func TestFullCoverage(t *testing.T) {
fc := FullCoverage("foo/bar.go")
if fc.Path != "foo/bar.go" {
t.Errorf("Path = %q, want foo/bar.go", fc.Path)
}
if len(fc.Regions) != 1 {
t.Fatalf("Regions = %d, want 1", len(fc.Regions))
}
r := fc.Regions[0]
if r.StartLine != 1 {
t.Errorf("StartLine = %d, want 1", r.StartLine)
}
if r.EndLine != math.MaxInt32 {
t.Errorf("EndLine = %d, want math.MaxInt32 (so OverlapsRange always matches)", r.EndLine)
}
}

func TestFetchToTemp_HappyPath(t *testing.T) {
dir := initRepoWith(t, map[string]string{"a.go": "package x\nfunc F() {}\n"})

tmp, err := FetchToTemp(dir, "HEAD", "a.go")
if err != nil {
t.Fatalf("FetchToTemp: %v", err)
}
if tmp == "" {
t.Fatal("tmp = \"\", want a path for an existing file")
}
defer os.Remove(tmp)

// Extension preservation matters: some analyzers branch on path ext.
if filepath.Ext(tmp) != ".go" {
t.Errorf("temp ext = %q, want .go (must be preserved)", filepath.Ext(tmp))
}

// Bytes round-trip.
got, err := os.ReadFile(tmp)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), "func F()") {
t.Errorf("temp content missing original; got %q", got)
}
}

func TestFetchToTemp_AbsentReturnsEmptyPath(t *testing.T) {
dir := initRepoWith(t, map[string]string{"a.go": "package x\n"})

tmp, err := FetchToTemp(dir, "HEAD", "missing.go")
if err != nil {
t.Errorf("err = %v, want nil for absent path", err)
}
if tmp != "" {
os.Remove(tmp)
t.Errorf("tmp = %q, want \"\" for absent path", tmp)
}
}

func TestFetchToTemp_BadRefSurfacesError(t *testing.T) {
dir := initRepoWith(t, map[string]string{"a.go": "package x\n"})

_, err := FetchToTemp(dir, "no-such-ref", "a.go")
if err == nil {
t.Fatal("expected error for unknown ref")
}
}

func TestFetchToTemp_PreservesExtension(t *testing.T) {
dir := initRepoWith(t, map[string]string{
"a.ts": "export const x = 1\n",
"b.rs": "fn main() {}\n",
"c.txt": "hi\n",
})

for _, name := range []string{"a.ts", "b.rs", "c.txt"} {
tmp, err := FetchToTemp(dir, "HEAD", name)
if err != nil {
t.Fatalf("FetchToTemp(%s): %v", name, err)
}
want := filepath.Ext(name)
if got := filepath.Ext(tmp); got != want {
t.Errorf("%s: tmp ext = %q, want %q", name, got, want)
}
os.Remove(tmp)
}
}
Loading
Loading