diff --git a/README.md b/README.md index 63588c4..ac29ae5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Relational Operator Replacement (ROR) — mutating `>`, `>=`, `<`, `<=`, `==`, ` - **Fast** — One build per package, all mutants activated at runtime. No per-mutant recompilation - **Parallel execution** — Worker pool bounded by CPU cores - **`go test`-compatible** — `--- KILLED:` / `--- SURVIVED:` output; `-v`, `-run`, `-timeout` work as expected -- **`//mutest:skip`** — Exclude functions or lines from mutation +- **`//mutest:skip`** — Exclude functions, blocks (`if`/`for`/`switch`/`select`), or individual lines from mutation - **`-diff`** — Only mutate lines changed relative to a git ref (e.g., `-diff origin/main`) - **`-threshold`** — CI quality gate (e.g., `-threshold 80` fails if score < 80%) - **`-json`** — Machine-readable output for CI pipelines (`-json -v` for NDJSON streaming) @@ -283,7 +283,22 @@ func legacyCompare(a, b int) bool { } ``` -**Line-level skip** — add `//mutest:skip` as an inline comment to skip that line only: +**Block-level skip** — add `//mutest:skip` as an inline comment on an `if`, `for`, `switch`, or `select` statement to skip the entire block: + +```go +func fetch(url string) (*Response, error) { + resp, err := http.Get(url) + if err != nil { //mutest:skip + if errors.Is(err, context.Canceled) { + return nil, ErrCanceled + } + return nil, fmt.Errorf("fetch: %w", err) + } + return resp, nil +} +``` + +**Line-level skip** — add `//mutest:skip` as an inline comment on any other line to skip that line only: ```go func compare(a, b int) int { @@ -297,6 +312,8 @@ func compare(a, b int) int { } ``` +> **Note:** When `//mutest:skip` is placed on a block statement (`if`/`for`/`switch`/`select`), it skips the entire block including nested statements. On any other line, it skips only that line. + ### Dry-Run Mode The `-dry-run` flag lists discovered mutation points without executing tests. Useful for previewing scope or counting mutations. diff --git a/engine/engine.go b/engine/engine.go index b595e5f..622c4a5 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -125,6 +125,7 @@ type lineRange struct { // buildSkipInfo scans the AST's comments for //mutest:skip directives. // A directive on a function's doc comment skips the entire function body. +// A directive on a block statement (if/for/switch/select) skips the entire block. // A directive on any other line skips mutations on that specific line. func buildSkipInfo(fset *token.FileSet, file *ast.File) *skipInfo { si := &skipInfo{ @@ -147,11 +148,19 @@ func buildSkipInfo(fset *token.FileSet, file *ast.File) *skipInfo { } } - // Line-level: all comments with mutest:skip + // Build line → block range map for block-scope skip. + blockRanges := buildBlockRanges(fset, file) + + // Line-level and block-level: all comments with mutest:skip for _, cg := range file.Comments { for _, c := range cg.List { if strings.Contains(c.Text, "mutest:skip") { - si.lines[fset.Position(c.Pos()).Line] = true + line := fset.Position(c.Pos()).Line + si.lines[line] = true + // If this line is the start of a block statement, skip the whole block. + if r, ok := blockRanges[line]; ok { + si.ranges = append(si.ranges, r) + } } } } @@ -159,6 +168,38 @@ func buildSkipInfo(fset *token.FileSet, file *ast.File) *skipInfo { return si } +// buildBlockRanges walks the AST and returns a map from the starting line +// of each block statement (if/for/switch/select) to its full line range. +func buildBlockRanges(fset *token.FileSet, file *ast.File) map[int]lineRange { + ranges := make(map[int]lineRange) + ast.Inspect(file, func(n ast.Node) bool { + if n == nil { + return false + } + var start, end token.Pos + switch n := n.(type) { + case *ast.IfStmt: + start, end = n.Pos(), n.End() + case *ast.ForStmt: + start, end = n.Pos(), n.End() + case *ast.RangeStmt: + start, end = n.Pos(), n.End() + case *ast.SwitchStmt: + start, end = n.Pos(), n.End() + case *ast.TypeSwitchStmt: + start, end = n.Pos(), n.End() + case *ast.SelectStmt: + start, end = n.Pos(), n.End() + default: + return true + } + line := fset.Position(start).Line + ranges[line] = lineRange{line, fset.Position(end).Line} + return true + }) + return ranges +} + func (si *skipInfo) shouldSkip(line int) bool { if si.lines[line] { return true diff --git a/engine/engine_test.go b/engine/engine_test.go index 39bcb31..a525957 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -199,3 +199,115 @@ func Skipped(a, b int) bool { t.Errorf("expected 0 points (function with space variant skipped), got %d", len(points)) } } + +func TestDiscoverAll_SkipDirective_Block(t *testing.T) { + tmpDir := t.TempDir() + for name, content := range map[string]string{ + "go.mod": "module example.com/skip\n\ngo 1.21\n", + "skip.go": `package skip + +func Block(a, b, c int) bool { + if a > b { //mutest:skip + if b > c { + return true + } + return a > c + } + return a < b +} +`, + } { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + chdir(t, tmpDir) + + eng := New([]string{"./..."}, &mutator.ComparisonMutator{}) + points, err := eng.DiscoverAll() + if err != nil { + t.Fatal(err) + } + // Only "a < b" on the last line should remain; the if block (a > b, b > c, a > c) is skipped. + if len(points) != 1 { + t.Errorf("expected 1 point (block-skipped), got %d", len(points)) + for _, p := range points { + t.Logf(" %s:%d %s", p.File, p.Line, p.Desc) + } + } + if len(points) > 0 && points[0].Desc != "< to <=" { + t.Errorf("expected remaining point to be '<', got %q", points[0].Desc) + } +} + +func TestDiscoverAll_SkipDirective_ForBlock(t *testing.T) { + tmpDir := t.TempDir() + for name, content := range map[string]string{ + "go.mod": "module example.com/skip\n\ngo 1.21\n", + "skip.go": `package skip + +func ForBlock(items []int) bool { + for i := 0; i < len(items); i++ { //mutest:skip + if items[i] > 0 { + return true + } + } + return len(items) > 1 +} +`, + } { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + chdir(t, tmpDir) + + eng := New([]string{"./..."}, &mutator.ComparisonMutator{}) + points, err := eng.DiscoverAll() + if err != nil { + t.Fatal(err) + } + // Only "len(items) > 1" should remain; the for block (i < len, items[i] > 0) is skipped. + if len(points) != 1 { + t.Errorf("expected 1 point (for-block skipped), got %d", len(points)) + for _, p := range points { + t.Logf(" %s:%d %s", p.File, p.Line, p.Desc) + } + } +} + +func TestDiscoverAll_SkipDirective_IfElseBlock(t *testing.T) { + tmpDir := t.TempDir() + for name, content := range map[string]string{ + "go.mod": "module example.com/skip\n\ngo 1.21\n", + "skip.go": `package skip + +func IfElse(a, b int) int { + if a > b { //mutest:skip + return a + } else if a < b { + return b + } + return 0 +} +`, + } { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + chdir(t, tmpDir) + + eng := New([]string{"./..."}, &mutator.ComparisonMutator{}) + points, err := eng.DiscoverAll() + if err != nil { + t.Fatal(err) + } + // The entire if/else if chain is one ast.IfStmt — all should be skipped. + if len(points) != 0 { + t.Errorf("expected 0 points (if-else block skipped), got %d", len(points)) + for _, p := range points { + t.Logf(" %s:%d %s", p.File, p.Line, p.Desc) + } + } +} diff --git a/main.go b/main.go index 9dcb601..242335f 100644 --- a/main.go +++ b/main.go @@ -153,7 +153,7 @@ func run2(ctx context.Context, cfg config, stdout, stderr io.Writer) int { if cfg.Diff != "" { //mutest:skip cl, err := diff.ParseGitDiff(cfg.Diff) - if err != nil { //mutest:skip + if err != nil { fmt.Fprintf(stderr, "mutest: %v\n", err) return 2 }