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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
45 changes: 43 additions & 2 deletions engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -147,18 +148,58 @@ 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)
}
}
}
}

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
Expand Down
112 changes: 112 additions & 0 deletions engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading