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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ diffguard \

**Refactoring mode (`--paths`):** Analyzes the full content of the specified files or directories, ignoring git diff entirely. Use this when iterating on an existing file's quality without a base to compare against.

**Delta gating (diff mode only):** Complexity and size findings are evaluated against the merge-base, not just the absolute threshold. A finding is only reported when the metric got measurably worse on this branch — touching a 4000-line legacy file or a complexity=91 function without making it bigger or more complex no longer fails CI. Brand-new files and functions absent at base still get gated by the absolute thresholds. Tolerances absorb small bumps:

- `--complexity-delta-tolerance` (default `3`) — cognitive complexity has a nesting penalty, so threading a parameter through one extra branch can nudge the score by 1-3 with nothing materially worse happening.
- `--function-size-delta-tolerance` (default `5`) — absorbs defensive guards / log lines.
- `--file-size-delta-tolerance-pct` (default `5`) + `--file-size-delta-tolerance-floor` (default `10`) — drop rule is `growth ≤ max(floor, base × pct%)`. The percentage scales with file size so +50 lines on a 5000-line file is forgiven (1%) while the same growth on a 500-line file flags (10%); the floor stops tiny additions to small files from collapsing below the noise level.

Findings on functions/files that existed at base render as `complexity=77 (+5 vs base)` / `function=80 lines (+10 vs base)` / `file=650 lines (+50 vs base)` so PR authors can distinguish "added new hot code" from "made existing hot code hotter".

**Generated-file skipping (`--skip-generated`):** Enabled by default. Files marked with a standard generated-code banner such as `Code generated ... DO NOT EDIT` are excluded before they reach any analyzer. Pass `--skip-generated=false` to include them.

### Rust notes
Expand Down Expand Up @@ -322,8 +330,20 @@ Flags:
--base string Base branch to diff against (default: auto-detect)
--paths string Comma-separated files/dirs to analyze in full (refactoring mode); skips git diff
--complexity-threshold int Maximum cognitive complexity per function (default 10)
--complexity-delta-tolerance int
In diff mode, ignore complexity regressions where head exceeds base by this much
or less; brand-new functions still gated by --complexity-threshold (default 3)
--function-size-threshold int Maximum lines per function (default 50)
--function-size-delta-tolerance int
In diff mode, ignore per-function size regressions where head grows by this many
lines or fewer (default 5)
--file-size-threshold int Maximum lines per file (default 500)
--file-size-delta-tolerance-pct int
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) (default 5)
--file-size-delta-tolerance-floor int
Minimum absolute line growth tolerated regardless of --file-size-delta-tolerance-pct,
so tiny absolute additions to small files don't fail (default 10)
--skip-mutation Skip mutation testing
--skip-deadcode Skip dead code (unused symbol) detection
--skip-generated Skip files marked as generated (for example `Code generated ... DO NOT EDIT`) (default true)
Expand Down
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