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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ diffguard --paths internal/foo/,internal/bar/ /path/to/repo
# Skip mutation testing (fastest)
diffguard --skip-mutation /path/to/repo

# Generated files are skipped by default; disable that if needed
diffguard --skip-generated=false /path/to/repo

# Or sample a subset of mutants for faster-but-still-useful signal
diffguard --mutation-sample-rate 20 /path/to/repo

Expand All @@ -65,6 +68,8 @@ 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.

**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.

## What It Measures

### Cognitive Complexity
Expand Down Expand Up @@ -163,6 +168,7 @@ Flags:
--function-size-threshold int Maximum lines per function (default 50)
--file-size-threshold int Maximum lines per file (default 500)
--skip-mutation Skip mutation testing
--skip-generated Skip files marked as generated (for example `Code generated ... DO NOT EDIT`) (default true)
--mutation-sample-rate float Percentage of mutants to test, 0-100 (default 100)
--test-timeout duration Per-mutant go test timeout (default 30s)
--test-pattern string Pattern passed to `go test -run` for each mutant (scopes tests to speed up slow suites)
Expand Down
17 changes: 14 additions & 3 deletions cmd/diffguard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func main() {
flag.IntVar(&cfg.FunctionSizeThreshold, "function-size-threshold", 50, "Maximum lines per function")
flag.IntVar(&cfg.FileSizeThreshold, "file-size-threshold", 500, "Maximum lines per file")
flag.BoolVar(&cfg.SkipMutation, "skip-mutation", false, "Skip mutation testing")
flag.BoolVar(&cfg.SkipGenerated, "skip-generated", true, "Skip files marked as generated (for example `Code generated ... DO NOT EDIT`)")
flag.Float64Var(&cfg.MutationSampleRate, "mutation-sample-rate", 100, "Percentage of mutants to test, 0-100")
flag.DurationVar(&cfg.TestTimeout, "test-timeout", 30*time.Second, "Per-mutant test binary timeout (e.g. 60s, 2m)")
flag.StringVar(&cfg.TestPattern, "test-pattern", "", "Test name pattern passed to `go test -run` for each mutant (speeds up mutation testing on packages with slow suites)")
Expand Down Expand Up @@ -68,6 +69,7 @@ type Config struct {
FunctionSizeThreshold int
FileSizeThreshold int
SkipMutation bool
SkipGenerated bool
MutationSampleRate float64
TestTimeout time.Duration
TestPattern string
Expand Down Expand Up @@ -144,7 +146,7 @@ func collectLanguageResults(repoPath string, cfg Config, languages []lang.Langua
// (the caller should exit without writing a report — legacy UX).
// - (_, _, _, err) on pipeline failure.
func analyzeLanguage(repoPath string, cfg Config, l lang.Language, numLanguages int) (langResult, bool, bool, error) {
d, err := loadFiles(repoPath, cfg, diffFilter(l))
d, err := loadFiles(repoPath, cfg, diffFilter(repoPath, cfg, l))
if err != nil {
return langResult{}, false, false, err
}
Expand Down Expand Up @@ -357,11 +359,20 @@ func loadFiles(repoPath string, cfg Config, filter diff.Filter) (*diff.Result, e
// lang.FileFilter exposes the fields languages need to declare their
// territory (extensions, IsTestFile, DiffGlobs), while diff.Filter only
// carries what the parser itself reads on each file (Includes + DiffGlobs).
func diffFilter(l lang.Language) diff.Filter {
func diffFilter(repoPath string, cfg Config, l lang.Language) diff.Filter {
f := l.FileFilter()
includes := f.IncludesSource
if cfg.SkipGenerated {
includes = func(path string) bool {
if !f.IncludesSource(path) {
return false
}
return !diff.IsGeneratedFile(repoPath, path)
}
}
return diff.Filter{
DiffGlobs: f.DiffGlobs,
Includes: f.IncludesSource,
Includes: includes,
}
}

Expand Down
70 changes: 68 additions & 2 deletions cmd/diffguard/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"flag"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -43,6 +44,62 @@ func TestRun_SingleLanguageGo(t *testing.T) {
})
}

func TestMainHelpShowsSkipGeneratedDefault(t *testing.T) {
if os.Getenv("DIFFGUARD_TEST_MAIN_HELP") == "1" {
flag.CommandLine = flag.NewFlagSet("diffguard", flag.ExitOnError)
os.Args = []string{"diffguard"}
main()
return
}

cmd := exec.Command(os.Args[0], "-test.run=TestMainHelpShowsSkipGeneratedDefault")
cmd.Env = append(os.Environ(), "DIFFGUARD_TEST_MAIN_HELP=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected help subprocess to exit non-zero without repo path")
}
output := string(out)
if !strings.Contains(output, "skip-generated") {
t.Fatalf("help output should include --skip-generated flag:\n%s", output)
}
if !strings.Contains(output, "default true") {
t.Fatalf("help output should show skip-generated default true:\n%s", output)
}
}

func TestDiffFilter_SkipGeneratedExcludesGeneratedFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "generated.go"), []byte("// Code generated by mockgen. DO NOT EDIT.\npackage x\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "regular.go"), []byte("package x\n\nfunc f() {}\n"), 0644); err != nil {
t.Fatal(err)
}

filter := diffFilter(dir, Config{SkipGenerated: true}, mustLanguage(t, "go"))
if filter.Includes("generated.go") {
t.Fatal("expected generated source file to be excluded")
}
if !filter.Includes("regular.go") {
t.Fatal("expected regular source file to be included")
}
if filter.Includes("notes.txt") {
t.Fatal("expected non-source file to be excluded")
}
}

func TestDiffFilter_SkipGeneratedDisabledIncludesGeneratedFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "generated.go"), []byte("// Code generated by mockgen. DO NOT EDIT.\npackage x\n"), 0644); err != nil {
t.Fatal(err)
}

filter := diffFilter(dir, Config{SkipGenerated: false}, mustLanguage(t, "go"))
if !filter.Includes("generated.go") {
t.Fatal("expected generated source file to be included when skip-generated is false")
}
}

// TestRun_UnknownLanguageHardError locks in that an unknown --language
// value fails with a clear error rather than silently falling back to
// auto-detect.
Expand Down Expand Up @@ -203,8 +260,8 @@ func TestLanguageNoun_KnownLanguagesAndFallback(t *testing.T) {
// globally anyway.
type stubLanguage string

func (s stubLanguage) Name() string { return string(s) }
func (s stubLanguage) FileFilter() lang.FileFilter { return lang.FileFilter{} }
func (s stubLanguage) Name() string { return string(s) }
func (s stubLanguage) FileFilter() lang.FileFilter { return lang.FileFilter{} }
func (s stubLanguage) ComplexityCalculator() lang.ComplexityCalculator { return nil }
func (s stubLanguage) FunctionExtractor() lang.FunctionExtractor { return nil }
func (s stubLanguage) ImportResolver() lang.ImportResolver { return nil }
Expand Down Expand Up @@ -285,6 +342,15 @@ func names(langs []lang.Language) []string {
return out
}

func mustLanguage(t *testing.T, name string) lang.Language {
t.Helper()
l, ok := lang.Get(name)
if !ok {
t.Fatalf("language %q is not registered", name)
}
return l
}

// TestCheckExitCode_FailInAnyLanguageEscalates covers B5: a FAIL section
// in any language must escalate the overall exit code, regardless of how
// many languages contribute sections. checkExitCode already takes a
Expand Down
3 changes: 2 additions & 1 deletion internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ func collectDir(repoPath, absPath string, filter Filter, files *[]FileChange, se
if err != nil {
return err
}
if d.IsDir() || !filter.includes(path) {
// mutator-disable-next-line
if d.IsDir() {
return nil
}
return addFile(repoPath, path, filter, files, seen)
Expand Down
37 changes: 37 additions & 0 deletions internal/diff/generated.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package diff

import (
"bufio"
"io"
"os"
"path/filepath"
"strings"
)

const generatedScanBytes = 8 * 1024

// IsGeneratedFile reports whether the file contains a standard generated-code
// marker near the top of the file, such as "Code generated ... DO NOT EDIT".
// Read errors return false so file selection stays conservative.
func IsGeneratedFile(repoPath, path string) bool {
absPath := path
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(repoPath, filepath.FromSlash(path))
}

f, err := os.Open(absPath)
// mutator-disable-next-line
if err != nil {
return false
}
defer f.Close()

scanner := bufio.NewScanner(&io.LimitedReader{R: f, N: generatedScanBytes})
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "Code generated") && strings.Contains(line, "DO NOT EDIT") {
return true
}
}
return false
}
110 changes: 110 additions & 0 deletions internal/diff/generated_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package diff

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

func TestIsGeneratedFile(t *testing.T) {
dir := t.TempDir()
t.Chdir(t.TempDir())
generatedPath := filepath.Join(dir, "generated.go")
regularPath := filepath.Join(dir, "regular.go")

if err := os.WriteFile(generatedPath, []byte("// Code generated by mockgen. DO NOT EDIT.\npackage x\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(regularPath, []byte("package x\n\nfunc f() {}\n"), 0644); err != nil {
t.Fatal(err)
}

if !IsGeneratedFile(dir, "generated.go") {
t.Fatal("expected relative path to be detected as generated")
}
if !IsGeneratedFile(dir, generatedPath) {
t.Fatal("expected absolute path to be detected as generated")
}
if IsGeneratedFile(dir, "regular.go") {
t.Fatal("expected regular source file not to be detected as generated")
}
if IsGeneratedFile(dir, "missing.go") {
t.Fatal("expected missing source file not to be detected as generated")
}
}

func TestCollectPaths_SkipsGeneratedFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "generated.go"), []byte("// Code generated by mockgen. DO NOT EDIT.\npackage x\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "regular.go"), []byte("package x\n\nfunc f() {}\n"), 0644); err != nil {
t.Fatal(err)
}

filter := Filter{
DiffGlobs: []string{"*.go"},
Includes: func(path string) bool {
return strings.HasSuffix(path, ".go") &&
!strings.HasSuffix(path, "_test.go") &&
!IsGeneratedFile(dir, path)
},
}

r, err := CollectPaths(dir, []string{"."}, filter)
if err != nil {
t.Fatalf("CollectPaths error: %v", err)
}
if len(r.Files) != 1 {
t.Fatalf("expected 1 analyzable file, got %d", len(r.Files))
}
if r.Files[0].Path != "regular.go" {
t.Fatalf("path = %q, want regular.go", r.Files[0].Path)
}
}

func TestParseUnifiedDiff_SkipsGeneratedFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "generated.go"), []byte("// Code generated by mockgen. DO NOT EDIT.\npackage x\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "regular.go"), []byte("package x\n\nfunc f() {}\n"), 0644); err != nil {
t.Fatal(err)
}

filter := Filter{
DiffGlobs: []string{"*.go"},
Includes: func(path string) bool {
return strings.HasSuffix(path, ".go") &&
!strings.HasSuffix(path, "_test.go") &&
!IsGeneratedFile(dir, path)
},
}

input := `diff --git a/generated.go b/generated.go
--- a/generated.go
+++ b/generated.go
@@ -0,0 +1,2 @@
+// Code generated by mockgen. DO NOT EDIT.
+package x
diff --git a/regular.go b/regular.go
--- a/regular.go
+++ b/regular.go
@@ -0,0 +1,3 @@
+package x
+
+func f() {}
`

files, err := parseUnifiedDiff(input, filter)
if err != nil {
t.Fatalf("parseUnifiedDiff error: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 analyzable file, got %d", len(files))
}
if files[0].Path != "regular.go" {
t.Fatalf("path = %q, want regular.go", files[0].Path)
}
}
Loading