diff --git a/README.md b/README.md index 2bf0ca2..d4d9e4f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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) diff --git a/cmd/diffguard/main.go b/cmd/diffguard/main.go index c795d71..a6b319c 100644 --- a/cmd/diffguard/main.go +++ b/cmd/diffguard/main.go @@ -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)") @@ -68,6 +69,7 @@ type Config struct { FunctionSizeThreshold int FileSizeThreshold int SkipMutation bool + SkipGenerated bool MutationSampleRate float64 TestTimeout time.Duration TestPattern string @@ -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 } @@ -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, } } diff --git a/cmd/diffguard/main_test.go b/cmd/diffguard/main_test.go index dd48f07..f559f56 100644 --- a/cmd/diffguard/main_test.go +++ b/cmd/diffguard/main_test.go @@ -1,6 +1,7 @@ package main import ( + "flag" "os" "os/exec" "path/filepath" @@ -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. @@ -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 } @@ -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 diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 27391cb..a1c378d 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -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) diff --git a/internal/diff/generated.go b/internal/diff/generated.go new file mode 100644 index 0000000..70d211d --- /dev/null +++ b/internal/diff/generated.go @@ -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 +} diff --git a/internal/diff/generated_test.go b/internal/diff/generated_test.go new file mode 100644 index 0000000..cd0ba01 --- /dev/null +++ b/internal/diff/generated_test.go @@ -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) + } +}