diff --git a/docs/docs/usage/configuration.md b/docs/docs/usage/configuration.md index bbf4cbd6..af989fa5 100644 --- a/docs/docs/usage/configuration.md +++ b/docs/docs/usage/configuration.md @@ -90,7 +90,10 @@ mutants: 2. By default `0`, which means that no test process CPU will be enforced. 3. By default `0`, which means a default coefficient of `5` will be enforced. 4. Thresholds are set by default to `0`, which means they are not enforced. -5. Excluded files are set by default to empty list, which means no files skipped except tests. +5. Excluded files are set by default to empty list, which means no files + skipped except tests. To suppress mutations at a finer granularity + (single statement, block, or file) without editing the configuration, + see the [`//nomutant` directive](nomutant-directive.md). For further information check the specific command documentation. diff --git a/docs/docs/usage/nomutant-directive.md b/docs/docs/usage/nomutant-directive.md new file mode 100644 index 00000000..07dae0f8 --- /dev/null +++ b/docs/docs/usage/nomutant-directive.md @@ -0,0 +1,133 @@ +--- +title: Nomutant directive +--- + +# Nomutant directive + +The `//nomutant` comment directive lets you suppress mutations on individual +lines, blocks, or whole files directly from the source code. It is a +finer-grained alternative to the `exclude-files` configuration option, and is +useful when only a few mutations in a file are noisy or known to be unkillable +(for example, defensive checks for unreachable code). + +A suppressed mutation is reported with the `SKIPPED` status, the same status +used by diff-mode for unchanged code, so you can still audit which directives +took effect by reading the report. + +## Forms + +There are four scope variants, all written as `//`-style line comments. + +### 1. End-of-line + +The directive shares a line with a statement and applies to that statement +only. + +```go +a := b + c //nomutant +``` + +Every applicable mutator on that line is suppressed. + +### 2. End-of-line, typed filter + +When the directive is followed by `:` and a comma-separated list of mutator +type names, only the listed types are suppressed; other mutators on that line +still produce mutants. + +```go +a := b + c //nomutant:arithmetic-base,invert-bitwise +``` + +The names are the same as the configuration keys for each mutator type — see +[Configuration](configuration.md) for the full list (e.g. `arithmetic-base`, +`conditionals-boundary`, `invert-bwassign`). + +### 3. Block scope + +A `//nomutant` on its own line, immediately above a function declaration or +a single statement, suppresses every mutation inside that AST node. + +```go +//nomutant +func myFunc() { + a := b + c + return a * d +} +``` + +The typed filter is supported here too: + +```go +//nomutant:arithmetic-base +func myFunc() { + // only arithmetic-base mutators inside myFunc are suppressed +} +``` + +Block scope also works above a single statement: + +```go +func myFunc() { + //nomutant + a := b + c // suppressed + d := e * f // not suppressed +} +``` + +### 4. File scope + +A `//nomutant` placed immediately before the package clause suppresses every +mutation in the file. It is equivalent to adding the file to the +`unleash.exclude-files` configuration list, but lives next to the code. + +```go +//nomutant +package apackage +``` + +The typed filter applies at file scope as well: + +```go +//nomutant:arithmetic-base +package apackage +``` + +## Nesting + +Directives compose **additively**: a mutation is suppressed if any +enclosing scope (file, block, or end-of-line) suppresses its type. An +inner directive adds to outer ones rather than replacing them. + +```go +//nomutant:invert-bitwise +func F() { + //nomutant:arithmetic-base + a := 1 + 2 // BOTH arithmetic-base AND invert-bitwise are suppressed + b := 3 * 4 // only invert-bitwise is suppressed (outer scope still applies) +} +``` + +This means an untyped outer directive (`//nomutant`, suppressing every +type) cannot be narrowed by an inner typed directive — the outer one +already covers everything. To opt back in to a specific mutator inside +a broadly-suppressed region, restructure the code rather than relying +on directive composition. + +## Malformed directives + +A directive whose typed filter is empty (`//nomutant:`) or names only unknown +mutator types (`//nomutant:bogus-type`) is treated as a no-op and a warning +is logged. This makes it safe to add or rename directives without breaking +your build. + +## Interaction with other settings + +- A directive-suppressed mutant is reported with status `SKIPPED`. The mutant + is still emitted to the report so you can confirm the directive took + effect; it is not silently dropped. +- File-level `exclude-files` rules and `//nomutant` directives are + independent. Either one is sufficient to suppress a mutation. +- Disabling a mutator type via configuration takes effect before the + directive is evaluated; if a mutator type is disabled globally, the + directive has nothing to suppress. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index f5b31980..af05c8bf 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -49,6 +49,7 @@ nav: - usage/commands/unleash/index.md - usage/commands/unleash/workers.md - usage/configuration.md + - usage/nomutant-directive.md - Mutations: - usage/mutations/index.md - usage/mutations/arithmetic_base.md diff --git a/internal/engine/directives.go b/internal/engine/directives.go new file mode 100644 index 00000000..de8edd82 --- /dev/null +++ b/internal/engine/directives.go @@ -0,0 +1,278 @@ +/* + * Copyright 2026 The Gremlins Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package engine + +import ( + "go/ast" + "go/token" + "strings" + + "github.com/go-gremlins/gremlins/internal/log" + "github.com/go-gremlins/gremlins/internal/mutator" +) + +const directivePrefix = "//nomutant" + +// directiveScope describes the set of mutator types a //nomutant directive +// suppresses. all=true means "every type"; otherwise types is the explicit +// allow-list parsed from the ":type1,type2,..." suffix. +type directiveScope struct { + types map[mutator.Type]struct{} + all bool +} + +func (s directiveScope) matches(mt mutator.Type) bool { + if s.all { + return true + } + _, ok := s.types[mt] + + return ok +} + +// blockDirective binds a directiveScope to a byte-offset range +// [startOffset, endOffset]. Smaller ranges win over larger when they +// overlap. We store byte offsets (not token.Pos) so containment checks +// in isSuppressed need only the token.Position the caller already has. +type blockDirective struct { + scope directiveScope + startOffset int + endOffset int +} + +// directiveIndex resolves whether a (position, mutator type) pair is +// suppressed by an inline //nomutant directive. It is built once per file +// before the AST walk. +type directiveIndex struct { + fileScope *directiveScope + byLine map[int]directiveScope + blocks []blockDirective +} + +func (idx *directiveIndex) isSuppressed(pos token.Position, mt mutator.Type) bool { + if idx == nil { + return false + } + if idx.fileScope != nil && idx.fileScope.matches(mt) { + return true + } + // Block scopes compose additively: if any enclosing block suppresses + // the type, the mutant is suppressed. This lets an inner directive + // add to (rather than replace) an outer one, which is the natural + // reading of nested //nomutant comments. + for _, b := range idx.blocks { + if pos.Offset >= b.startOffset && pos.Offset <= b.endOffset && b.scope.matches(mt) { + return true + } + } + if s, ok := idx.byLine[pos.Line]; ok && s.matches(mt) { + return true + } + + return false +} + +// buildDirectiveIndex scans file.Comments and decl-attached doc comments +// for //nomutant directives and classifies each by scope (file / block / +// end-of-line). Malformed directives are logged and ignored. +func buildDirectiveIndex(set *token.FileSet, file *ast.File) *directiveIndex { + idx := &directiveIndex{byLine: map[int]directiveScope{}} + + tokenLines := collectTokenLines(set, file) + packageLine := set.Position(file.Package).Line + + // All comment groups: the parser attaches some to file.Doc / decl.Doc + // rather than leaving them in file.Comments, so we need both sources. + groups := collectAllCommentGroups(file) + + for _, cg := range groups { + for _, c := range cg.List { + scope, ok := parseDirective(set, c) + if !ok { + continue + } + line := set.Position(c.Pos()).Line + + // File-scope: directive is on, or above, the package clause. + // We treat any directive at or before the package line as file-scope. + if line <= packageLine { + fs := scope + idx.fileScope = &fs + + continue + } + + // End-of-line: directive shares its line with a non-comment token. + if tokenLines[line] { + idx.byLine[line] = scope + + continue + } + + // Block-scope: directive is on its own line; bind it to the + // smallest AST node starting on line+1. + if node, found := largestNodeStartingAtLine(set, file, line+1); found { + idx.blocks = append(idx.blocks, blockDirective{ + scope: scope, + startOffset: set.Position(node.Pos()).Offset, + endOffset: set.Position(node.End()).Offset, + }) + + continue + } + // Otherwise: no-op directive (no following AST node, no token on line). + } + } + + return idx +} + +// parseDirective recognizes "//nomutant" and "//nomutant:t1,t2,..." comments. +// It returns ok=false if the comment is not a directive at all, and logs + +// returns ok=false if the directive is malformed (empty type list or all +// types unknown). +func parseDirective(set *token.FileSet, c *ast.Comment) (directiveScope, bool) { + text := strings.TrimSpace(c.Text) + if text == directivePrefix { + return directiveScope{all: true}, true + } + if !strings.HasPrefix(text, directivePrefix+":") { + return directiveScope{}, false + } + rest := strings.TrimPrefix(text, directivePrefix+":") + rest = strings.TrimSpace(rest) + pos := set.Position(c.Pos()) + if rest == "" { + log.Errorf("ignoring malformed //nomutant directive at %s: empty type list\n", pos) + + return directiveScope{}, false + } + types := map[mutator.Type]struct{}{} + for _, name := range strings.Split(rest, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + mt, ok := mutatorTypeByConfigKey(name) + if !ok { + log.Errorf("ignoring unknown mutator type %q in //nomutant directive at %s\n", name, pos) + + continue + } + types[mt] = struct{}{} + } + if len(types) == 0 { + return directiveScope{}, false + } + + return directiveScope{types: types}, true +} + +// mutatorTypeByConfigKey maps a config-style mutator key (e.g. +// "arithmetic-base") to its mutator.Type. It uses the same derivation as +// configuration.MutantTypeEnabledKey: lowercase Type.String() with "_"→"-". +func mutatorTypeByConfigKey(name string) (mutator.Type, bool) { + for _, mt := range mutator.Types { + if configKeyForType(mt) == name { + return mt, true + } + } + + return 0, false +} + +func configKeyForType(mt mutator.Type) string { + s := strings.ReplaceAll(mt.String(), "_", "-") + + return strings.ToLower(s) +} + +// collectTokenLines returns the set of source lines that contain at least +// one non-comment AST node, used to distinguish end-of-line directives from +// own-line ones. Returning false on *ast.CommentGroup stops the walk before +// it reaches the *ast.Comment children, so no Comment guard is needed. +func collectTokenLines(set *token.FileSet, file *ast.File) map[int]bool { + lines := map[int]bool{} + ast.Inspect(file, func(n ast.Node) bool { + if n == nil { + return false + } + if _, isCG := n.(*ast.CommentGroup); isCG { + return false + } + lines[set.Position(n.Pos()).Line] = true + + return true + }) + + return lines +} + +// collectAllCommentGroups gathers every *ast.CommentGroup reachable from +// the file: free-floating ones in file.Comments, plus those attached as +// Doc fields on declarations and the file itself. +func collectAllCommentGroups(file *ast.File) []*ast.CommentGroup { + groups := append([]*ast.CommentGroup(nil), file.Comments...) + if file.Doc != nil { + groups = append(groups, file.Doc) + } + // file.Comments already includes all comment groups in the file in + // position order — including those the parser also referenced as + // .Doc on decls. Dedup by pointer. + seen := map[*ast.CommentGroup]bool{} + out := groups[:0] + for _, g := range groups { + if g == nil || seen[g] { + continue + } + seen[g] = true + out = append(out, g) + } + + return out +} + +// largestNodeStartingAtLine finds the AST node with the widest range +// (Pos..End) whose start position is on the given line. The directive +// attaches to "the AST node and everything inside it," so we want the +// enclosing decl/stmt (e.g. FuncDecl), not a child Ident at the same line. +func largestNodeStartingAtLine(set *token.FileSet, file *ast.File, line int) (ast.Node, bool) { + var ( + best ast.Node + bestSpan token.Pos + ) + ast.Inspect(file, func(n ast.Node) bool { + if n == nil { + return false + } + if _, isCG := n.(*ast.CommentGroup); isCG { + return false + } + if set.Position(n.Pos()).Line != line { + return true + } + span := n.End() - n.Pos() + if best == nil || span > bestSpan { + best = n + bestSpan = span + } + + return true + }) + + return best, best != nil +} diff --git a/internal/engine/directives_internal_test.go b/internal/engine/directives_internal_test.go new file mode 100644 index 00000000..a19611be --- /dev/null +++ b/internal/engine/directives_internal_test.go @@ -0,0 +1,384 @@ +/* + * Copyright 2026 The Gremlins Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package engine + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/go-gremlins/gremlins/internal/mutator" +) + +func parseSrc(t *testing.T, src string) (*token.FileSet, *ast.File) { + t.Helper() + set := token.NewFileSet() + file, err := parser.ParseFile(set, "src.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + + return set, file +} + +func TestBuildDirectiveIndex_EndOfLine_Untyped(t *testing.T) { + t.Parallel() + + src := `package p + +func F() int { + a := 1 + 2 //nomutant + return a +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + pos := positionOf(t, set, src, "+") + if !idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("expected ArithmeticBase on line %d to be suppressed", pos.Line) + } + if !idx.isSuppressed(pos, mutator.InvertBitwise) { + t.Errorf("expected InvertBitwise on line %d to be suppressed (untyped directive applies to all)", pos.Line) + } + + posReturn := positionOf(t, set, src, "return") + if idx.isSuppressed(posReturn, mutator.ArithmeticBase) { + t.Errorf("did not expect line %d to be suppressed", posReturn.Line) + } +} + +func TestBuildDirectiveIndex_EndOfLine_TypedFilter(t *testing.T) { + t.Parallel() + + src := `package p + +func F() int { + a := 1 + 2 //nomutant:arithmetic-base + return a +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + pos := positionOf(t, set, src, "+") + if !idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("expected ArithmeticBase to be suppressed by typed filter") + } + if idx.isSuppressed(pos, mutator.InvertBitwise) { + t.Errorf("did NOT expect InvertBitwise to be suppressed (not in typed filter)") + } +} + +func TestBuildDirectiveIndex_BlockScope_Function(t *testing.T) { + t.Parallel() + + src := `package p + +//nomutant +func F() int { + a := 1 + 2 + b := a * 3 + return b +} + +func G() int { + return 1 + 2 +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + for _, tok := range []string{"+", "*"} { + pos := positionOf(t, set, src, tok) + if !idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("expected token %q at line %d to be suppressed (inside block-scoped F)", tok, pos.Line) + } + } + + posG := positionOfNth(t, set, src, "+", 2) + if idx.isSuppressed(posG, mutator.ArithmeticBase) { + t.Errorf("did NOT expect token at line %d (inside G) to be suppressed", posG.Line) + } +} + +func TestBuildDirectiveIndex_BlockScope_SingleStatement(t *testing.T) { + t.Parallel() + + src := `package p + +func F() int { + //nomutant + a := 1 + 2 + b := 3 * 4 + return a + b +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + posPlus := positionOfNth(t, set, src, "+", 1) + if !idx.isSuppressed(posPlus, mutator.ArithmeticBase) { + t.Errorf("expected first '+' at line %d to be suppressed by single-stmt block scope", posPlus.Line) + } + + posMul := positionOf(t, set, src, "*") + if idx.isSuppressed(posMul, mutator.ArithmeticBase) { + t.Errorf("did NOT expect '*' at line %d to be suppressed (block scope is single stmt)", posMul.Line) + } +} + +func TestBuildDirectiveIndex_FileScope(t *testing.T) { + t.Parallel() + + src := `//nomutant +package p + +func F() int { + a := 1 + 2 + return a * 3 +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + for _, tok := range []string{"+", "*"} { + pos := positionOf(t, set, src, tok) + for _, mt := range mutator.Types { + if !idx.isSuppressed(pos, mt) { + t.Errorf("expected %s at line %d to be suppressed by file-scope directive", mt, pos.Line) + } + } + } +} + +func TestBuildDirectiveIndex_FileScope_Typed(t *testing.T) { + t.Parallel() + + src := `//nomutant:arithmetic-base +package p + +func F() int { + return 1 + 2 +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + pos := positionOf(t, set, src, "+") + if !idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("expected ArithmeticBase to be suppressed by typed file-scope directive") + } + if idx.isSuppressed(pos, mutator.InvertBitwise) { + t.Errorf("did NOT expect InvertBitwise to be suppressed (not listed in typed file-scope directive)") + } +} + +func TestBuildDirectiveIndex_NoOpOnEmptyLine(t *testing.T) { + t.Parallel() + + src := `package p + +func F() int { + a := 1 + 2 + + //nomutant + + return a +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + pos := positionOf(t, set, src, "+") + if idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("did NOT expect '+' to be suppressed (directive is on a blank-context line)") + } +} + +func TestBuildDirectiveIndex_Malformed(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "empty_typed_filter": `package p + +func F() int { + return 1 + 2 //nomutant: +} +`, + "unknown_type": `package p + +func F() int { + return 1 + 2 //nomutant:bogus-type +} +`, + } + + for name, src := range cases { + src := src + t.Run(name, func(t *testing.T) { + t.Parallel() + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) // must not panic + + pos := positionOf(t, set, src, "+") + if idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("did NOT expect malformed directive to suppress mutants") + } + }) + } +} + +func TestDirectiveIndex_NilReceiverIsSafe(t *testing.T) { + t.Parallel() + + // A nil index must answer "not suppressed" without panicking. The + // engine relies on this so it can call isSuppressed unconditionally + // even when no index has been built (currently never happens, but + // the guard is cheap and protects future call sites). + var idx *directiveIndex + if idx.isSuppressed(token.Position{Line: 1}, mutator.ArithmeticBase) { + t.Errorf("nil directiveIndex must return false from isSuppressed") + } +} + +func TestBuildDirectiveIndex_TypedFilterWithEmptyEntries(t *testing.T) { + t.Parallel() + + // Doubled commas / leading commas leave empty strings in the comma- + // split type list. Each empty entry should be skipped silently and + // the surrounding valid types still parsed. + src := `package p + +func F() int { + return 1 + 2 //nomutant:arithmetic-base,,invert-bitwise +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + pos := positionOf(t, set, src, "+") + if !idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("expected ArithmeticBase to be suppressed (valid type before doubled comma)") + } + if !idx.isSuppressed(pos, mutator.InvertBitwise) { + t.Errorf("expected InvertBitwise to be suppressed (valid type after doubled comma)") + } + if idx.isSuppressed(pos, mutator.ConditionalsBoundary) { + t.Errorf("did NOT expect ConditionalsBoundary to be suppressed (not in filter)") + } +} + +func TestBuildDirectiveIndex_NestedBlocks_Additive(t *testing.T) { + t.Parallel() + + // Outer block-scope on the func suppresses InvertBitwise everywhere + // inside F. Inner block-scope on the assignment additionally suppresses + // ArithmeticBase on that statement only. + src := `package p + +//nomutant:invert-bitwise +func F() int { + //nomutant:arithmetic-base + a := 1 + 2 + b := 3 + 4 + return a + b +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + // Position of the first '+' (inside the inner block-scope). Both the + // outer and the inner directive cover this position. + posInner := positionOfNth(t, set, src, "+", 1) + if !idx.isSuppressed(posInner, mutator.ArithmeticBase) { + t.Errorf("inner block must suppress ArithmeticBase on its own statement") + } + if !idx.isSuppressed(posInner, mutator.InvertBitwise) { + t.Errorf("outer block must STILL suppress InvertBitwise inside the inner block (additive)") + } + + // Position of the second '+' (outside the inner block, still inside outer). + posOuter := positionOfNth(t, set, src, "+", 2) + if idx.isSuppressed(posOuter, mutator.ArithmeticBase) { + t.Errorf("inner block should NOT suppress ArithmeticBase outside its range") + } + if !idx.isSuppressed(posOuter, mutator.InvertBitwise) { + t.Errorf("outer block must suppress InvertBitwise outside inner block too") + } +} + +func TestBuildDirectiveIndex_TypedFilterNonApplicableType(t *testing.T) { + t.Parallel() + + src := `package p + +func F() int { + return 1 + 2 //nomutant:invert-bitwise +} +` + set, file := parseSrc(t, src) + idx := buildDirectiveIndex(set, file) + + pos := positionOf(t, set, src, "+") + if !idx.isSuppressed(pos, mutator.InvertBitwise) { + t.Errorf("expected InvertBitwise to be suppressed (it is in the typed filter, even if not applicable to '+')") + } + if idx.isSuppressed(pos, mutator.ArithmeticBase) { + t.Errorf("did NOT expect ArithmeticBase to be suppressed (not in typed filter)") + } +} + +// positionOf returns the token.Position of the first occurrence of needle in src. +func positionOf(t *testing.T, set *token.FileSet, src, needle string) token.Position { + t.Helper() + + return positionOfNth(t, set, src, needle, 1) +} + +// positionOfNth returns the position of the n-th (1-based) occurrence of needle +// in src, mapping a byte offset back to a token.Position via the FileSet's +// single registered file. +func positionOfNth(t *testing.T, set *token.FileSet, src, needle string, n int) token.Position { + t.Helper() + if n < 1 { + t.Fatalf("n must be >= 1, got %d", n) + } + idx := -1 + rest := src + consumed := 0 + for i := 0; i < n; i++ { + off := strings.Index(rest, needle) + if off < 0 { + t.Fatalf("could not find occurrence %d of %q in source", n, needle) + } + idx = consumed + off + consumed = idx + len(needle) + rest = src[consumed:] + } + + var pos token.Position + set.Iterate(func(f *token.File) bool { + pos = f.Position(f.Pos(f.Base() + idx)) + + return false + }) + + return pos +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index df452f48..fb63046f 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -126,18 +126,20 @@ func (mu *Engine) runOnFile(fileName string) { file, _ := parser.ParseFile(set, fileName, src, parser.ParseComments) _ = src.Close() + directives := buildDirectiveIndex(set, file) + ast.Inspect(file, func(node ast.Node) bool { n, ok := NewTokenNode(node) if !ok { return true } - mu.findMutations(fileName, set, file, n) + mu.findMutations(fileName, set, file, n, directives) return true }) } -func (mu *Engine) findMutations(fileName string, set *token.FileSet, file *ast.File, node *NodeToken) { +func (mu *Engine) findMutations(fileName string, set *token.FileSet, file *ast.File, node *NodeToken, directives *directiveIndex) { mutantTypes, ok := TokenMutantType[node.Tok()] if !ok { return @@ -151,7 +153,12 @@ func (mu *Engine) findMutations(fileName string, set *token.FileSet, file *ast.F mutantType := mt tm := NewTokenMutant(pkg, set, file, node) tm.SetType(mutantType) - tm.SetStatus(mu.mutationStatus(set.Position(node.TokPos))) + pos := set.Position(node.TokPos) + if directives.isSuppressed(pos, mutantType) { + tm.SetStatus(mutator.Skipped) + } else { + tm.SetStatus(mu.mutationStatus(pos)) + } mu.mutantStream <- tm } diff --git a/internal/engine/nomutant_test.go b/internal/engine/nomutant_test.go new file mode 100644 index 00000000..873804a2 --- /dev/null +++ b/internal/engine/nomutant_test.go @@ -0,0 +1,413 @@ +/* + * Copyright 2026 The Gremlins Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package engine_test + +import ( + "context" + "testing" + + "github.com/go-gremlins/gremlins/internal/configuration" + "github.com/go-gremlins/gremlins/internal/coverage" + "github.com/go-gremlins/gremlins/internal/diff" + "github.com/go-gremlins/gremlins/internal/engine" + "github.com/go-gremlins/gremlins/internal/mutator" +) + +// fullyCovered builds a coverage profile that marks every line in the +// fixture file as covered, so a mutant's status is Runnable unless an +// inline directive moves it to Skipped. Distinguishing Runnable from +// Skipped is the whole point of the integration test. +func fullyCovered(fixture string) coverage.Result { + fn := filenameFromFixture(fixture) + p := coverage.Profile{fn: {{StartLine: 1, EndLine: 1000, StartCol: 1, EndCol: 1000}}} + + return coverage.Result{Profile: p, Elapsed: 10} +} + +func TestNomutantDirective(t *testing.T) { + t.Parallel() + + // expect describes one mutant the test wants to find in the result set. + type expect struct { + line int + mType mutator.Type + status mutator.Status + } + + cases := []struct { + name string + fixture string + expects []expect + }{ + { + name: "end-of-line untyped suppresses every mutator on that line", + fixture: "testdata/fixtures/nomutant_eol_go", + expects: []expect{ + // Line 4 (`a := 1 + 2 //nomutant`): suppressed. + {line: 4, mType: mutator.ArithmeticBase, status: mutator.Skipped}, + // Line 5 (`b := 3 + 4`): not suppressed. + {line: 5, mType: mutator.ArithmeticBase, status: mutator.Runnable}, + }, + }, + { + name: "end-of-line typed filter only suppresses listed types", + fixture: "testdata/fixtures/nomutant_eol_typed_go", + expects: []expect{ + // Line 4 has `//nomutant:invert-bitwise`. ArithmeticBase + // (which actually applies to `+`) must still be Runnable + // because it isn't in the filter. + {line: 4, mType: mutator.ArithmeticBase, status: mutator.Runnable}, + }, + }, + { + name: "block-scope above a func suppresses every mutant inside", + fixture: "testdata/fixtures/nomutant_block_func_go", + expects: []expect{ + // Line 5 is inside the block-scoped `suppressed()`. + {line: 5, mType: mutator.ArithmeticBase, status: mutator.Skipped}, + // Line 10 is inside `notSuppressed()`. + {line: 10, mType: mutator.ArithmeticBase, status: mutator.Runnable}, + }, + }, + { + name: "file-scope suppresses every mutant in the file", + fixture: "testdata/fixtures/nomutant_file_go", + expects: []expect{ + {line: 5, mType: mutator.ArithmeticBase, status: mutator.Skipped}, + {line: 6, mType: mutator.ArithmeticBase, status: mutator.Skipped}, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + viperSet(map[string]any{configuration.UnleashDryRunKey: true}) + defer viperReset() + + mapFS, mod, c := loadFixture(tc.fixture, ".") + defer c() + + cov := fullyCovered(tc.fixture) + mut := engine.New(mod, engine.CodeData{Cov: cov.Profile}, newJobDealerStub(t), engine.WithDirFs(mapFS)) + res := mut.Run(context.Background()) + + for _, want := range tc.expects { + found := false + for _, m := range res.Mutants { + if m.Position().Line == want.line && m.Type() == want.mType { + found = true + if m.Status() != want.status { + t.Errorf("line %d %s: got status %s, want %s", + want.line, want.mType, m.Status(), want.status) + } + + break + } + } + if !found { + t.Errorf("expected to find a %s mutant on line %d; got mutants: %v", + want.mType, want.line, summarize(res.Mutants)) + } + } + }) + } +} + +// TestNomutantDirective_PartialTypedFilterOnSharedToken verifies that a +// typed filter naming only one of a token's mutator types suppresses +// just that type, leaving the others Runnable. The '<' token produces +// both ConditionalsBoundary and ConditionalsNegation mutants from a +// single position; the directive must split them by type. +func TestNomutantDirective_PartialTypedFilterOnSharedToken(t *testing.T) { + t.Parallel() + viperSet(map[string]any{configuration.UnleashDryRunKey: true}) + defer viperReset() + + mapFS, mod, c := loadFixture("testdata/fixtures/nomutant_partial_typed_go", ".") + defer c() + + cov := fullyCovered("testdata/fixtures/nomutant_partial_typed_go") + mut := engine.New(mod, engine.CodeData{Cov: cov.Profile}, newJobDealerStub(t), engine.WithDirFs(mapFS)) + res := mut.Run(context.Background()) + + var ( + boundaryStatus, negationStatus mutator.Status + foundBoundary, foundNegation bool + ) + for _, m := range res.Mutants { + if m.Position().Line != 6 { + continue + } + switch m.Type() { //nolint:exhaustive // only the two types '<' produces are relevant here + case mutator.ConditionalsBoundary: + foundBoundary = true + boundaryStatus = m.Status() + case mutator.ConditionalsNegation: + foundNegation = true + negationStatus = m.Status() + } + } + if !foundBoundary || !foundNegation { + t.Fatalf("expected both ConditionalsBoundary and ConditionalsNegation mutants on line 6; got %v", summarize(res.Mutants)) + } + if boundaryStatus != mutator.Skipped { + t.Errorf("ConditionalsBoundary on line 6: got %s, want SKIPPED (listed in typed filter)", boundaryStatus) + } + if negationStatus != mutator.Runnable { + t.Errorf("ConditionalsNegation on line 6: got %s, want RUNNABLE (NOT listed in typed filter)", negationStatus) + } +} + +// TestNomutantDirective_BlockScopeAboveIf verifies that block-scope +// attachment works for control-flow statements where many AST nodes +// share the directive's target line. The largest-span attachment rule +// must pick the IfStmt (covering its body), not a sub-expression like +// the BinaryExpr or an Ident. +func TestNomutantDirective_BlockScopeAboveIf(t *testing.T) { + t.Parallel() + viperSet(map[string]any{configuration.UnleashDryRunKey: true}) + defer viperReset() + + mapFS, mod, c := loadFixture("testdata/fixtures/nomutant_block_if_go", ".") + defer c() + + cov := fullyCovered("testdata/fixtures/nomutant_block_if_go") + mut := engine.New(mod, engine.CodeData{Cov: cov.Profile}, newJobDealerStub(t), engine.WithDirFs(mapFS)) + res := mut.Run(context.Background()) + + // Lines 7-9 are inside the block-scoped if; line 11 is after it. + type key struct { + line int + mType mutator.Type + } + got := map[key]mutator.Status{} + for _, m := range res.Mutants { + got[key{m.Position().Line, m.Type()}] = m.Status() + } + + checks := []struct { + line int + mType mutator.Type + want mutator.Status + why string + }{ + {7, mutator.ConditionalsBoundary, mutator.Skipped, "'>' inside if-body must be suppressed by block-scope on IfStmt"}, + {7, mutator.ConditionalsNegation, mutator.Skipped, "'>' inside if-body must be suppressed by block-scope on IfStmt"}, + {8, mutator.ArithmeticBase, mutator.Skipped, "'+' inside if-body must be suppressed (block scope covers body)"}, + {11, mutator.ArithmeticBase, mutator.Runnable, "'+' AFTER the if-stmt must NOT be suppressed (outside block)"}, + } + for _, c := range checks { + s, ok := got[key{c.line, c.mType}] + if !ok { + t.Errorf("missing %s mutant on line %d (%s); got %v", c.mType, c.line, c.why, summarize(res.Mutants)) + + continue + } + if s != c.want { + t.Errorf("line %d %s: got %s, want %s — %s", c.line, c.mType, s, c.want, c.why) + } + } +} + +// TestNomutantDirective_EndOfLineMultipleTokens verifies that an untyped +// end-of-line directive suppresses every applicable mutator on the line, +// even when those mutators come from different tokens at different +// columns. The byLine lookup is line-keyed, not column-keyed; this +// pins down that we don't accidentally narrow it. +func TestNomutantDirective_EndOfLineMultipleTokens(t *testing.T) { + t.Parallel() + viperSet(map[string]any{configuration.UnleashDryRunKey: true}) + defer viperReset() + + mapFS, mod, c := loadFixture("testdata/fixtures/nomutant_eol_multi_token_go", ".") + defer c() + + cov := fullyCovered("testdata/fixtures/nomutant_eol_multi_token_go") + mut := engine.New(mod, engine.CodeData{Cov: cov.Profile}, newJobDealerStub(t), engine.WithDirFs(mapFS)) + res := mut.Run(context.Background()) + + // Line 4 ('1 + 2 * 3 //nomutant') has both '+' and '*' — both must be Skipped. + // Line 5 ('4 + 5') has '+' — must be Runnable. + var line4Mutants, line5Mutants []mutator.Mutator + for _, m := range res.Mutants { + if m.Type() != mutator.ArithmeticBase { + continue + } + switch m.Position().Line { + case 4: + line4Mutants = append(line4Mutants, m) + case 5: + line5Mutants = append(line5Mutants, m) + } + } + if len(line4Mutants) != 2 { + t.Errorf("expected 2 ArithmeticBase mutants on line 4 (one per operator); got %d: %v", + len(line4Mutants), summarize(line4Mutants)) + } + for _, m := range line4Mutants { + if m.Status() != mutator.Skipped { + t.Errorf("line 4 col %d: got %s, want SKIPPED (untyped EOL directive suppresses every operator on the line)", + m.Position().Column, m.Status()) + } + } + if len(line5Mutants) != 1 { + t.Errorf("expected 1 ArithmeticBase mutant on line 5; got %d", len(line5Mutants)) + } + for _, m := range line5Mutants { + if m.Status() != mutator.Runnable { + t.Errorf("line 5 ArithmeticBase: got %s, want RUNNABLE (no directive on this line)", m.Status()) + } + } +} + +// TestNomutantDirective_OverridesNotCovered verifies that a //nomutant +// directive on an uncovered line still produces a Skipped mutant. The +// directive is evaluated before the coverage-derived status, so an +// explicit "do not test this" wins over an implicit "we couldn't test +// this anyway." Either status would prevent execution, but we choose +// Skipped so the user can audit which suppressions actually fired. +func TestNomutantDirective_OverridesNotCovered(t *testing.T) { + t.Parallel() + viperSet(map[string]any{configuration.UnleashDryRunKey: true}) + defer viperReset() + + mapFS, mod, c := loadFixture("testdata/fixtures/nomutant_eol_go", ".") + defer c() + + // Empty coverage profile → every position is "not covered". + emptyCov := coverage.Result{Profile: coverage.Profile{}} + mut := engine.New(mod, engine.CodeData{Cov: emptyCov.Profile}, newJobDealerStub(t), engine.WithDirFs(mapFS)) + res := mut.Run(context.Background()) + + var ( + foundSuppressed, foundUnsuppressed bool + ) + for _, m := range res.Mutants { + if m.Type() != mutator.ArithmeticBase { + continue + } + switch m.Position().Line { + case 4: + foundSuppressed = true + if m.Status() != mutator.Skipped { + t.Errorf("line 4 (directive on uncovered line): got %s, want SKIPPED (directive must win over coverage)", m.Status()) + } + case 5: + foundUnsuppressed = true + if m.Status() != mutator.NotCovered { + t.Errorf("line 5 (no directive, no coverage): got %s, want NOT COVERED", m.Status()) + } + } + } + if !foundSuppressed || !foundUnsuppressed { + t.Errorf("expected to find both line-4 and line-5 ArithmeticBase mutants; got %v", summarize(res.Mutants)) + } +} + +// TestNomutantDirective_WithDiffMode verifies that the directive and +// diff-mode coexist without panicking. Both end up assigning Skipped to +// the same mutants, so the assertion is mostly that nothing blows up +// and both code paths still emit mutants. +func TestNomutantDirective_WithDiffMode(t *testing.T) { + t.Parallel() + viperSet(map[string]any{configuration.UnleashDryRunKey: true}) + defer viperReset() + + mapFS, mod, c := loadFixture("testdata/fixtures/nomutant_eol_go", ".") + defer c() + + // Non-empty diff that lacks an entry for our file → IsChanged returns + // false for every position in this file, so mutationStatus would + // already assign Skipped. The directive on line 4 also says Skipped. + // Both code paths agree; the test pins down that they coexist. + // (An empty diff.Diff{} means "diff-mode off" — IsChanged returns + // true for everything in that case, which would defeat the test.) + codeData := engine.CodeData{ + Cov: fullyCovered("testdata/fixtures/nomutant_eol_go").Profile, + Diff: diff.Diff{"unrelated.go": nil}, + } + mut := engine.New(mod, codeData, newJobDealerStub(t), engine.WithDirFs(mapFS)) + res := mut.Run(context.Background()) + + if len(res.Mutants) == 0 { + t.Fatalf("expected mutants to be emitted, got none") + } + for _, m := range res.Mutants { + if m.Status() != mutator.Skipped { + t.Errorf("with empty diff every mutant should be Skipped; got %s at line %d", + m.Status(), m.Position().Line) + } + } +} + +// TestNomutantDirective_AdjacentTypedLines verifies that two end-of-line +// directives with different typed filters on adjacent lines do not bleed +// into each other. +func TestNomutantDirective_AdjacentTypedLines(t *testing.T) { + t.Parallel() + viperSet(map[string]any{configuration.UnleashDryRunKey: true}) + defer viperReset() + + mapFS, mod, c := loadFixture("testdata/fixtures/nomutant_adjacent_typed_go", ".") + defer c() + + cov := fullyCovered("testdata/fixtures/nomutant_adjacent_typed_go") + mut := engine.New(mod, engine.CodeData{Cov: cov.Profile}, newJobDealerStub(t), engine.WithDirFs(mapFS)) + res := mut.Run(context.Background()) + + // Line 4 has //nomutant:arithmetic-base — only ArithmeticBase suppressed. + // Line 5 has //nomutant:invert-bitwise — InvertBitwise listed but does + // not apply to '+'. ArithmeticBase still applies to '+' on line 5 and + // must NOT be suppressed (would indicate cross-line bleed). + var ( + line4Status, line5Status mutator.Status + foundLine4, foundLine5 bool + ) + for _, m := range res.Mutants { + if m.Type() != mutator.ArithmeticBase { + continue + } + switch m.Position().Line { + case 4: + foundLine4 = true + line4Status = m.Status() + case 5: + foundLine5 = true + line5Status = m.Status() + } + } + if !foundLine4 || !foundLine5 { + t.Fatalf("expected ArithmeticBase mutants on both line 4 and line 5; got %v", summarize(res.Mutants)) + } + if line4Status != mutator.Skipped { + t.Errorf("line 4 ArithmeticBase: got %s, want SKIPPED (typed filter includes arithmetic-base)", line4Status) + } + if line5Status != mutator.Runnable { + t.Errorf("line 5 ArithmeticBase: got %s, want RUNNABLE (line 5's filter targets invert-bitwise only — directive must NOT bleed from line 4)", line5Status) + } +} + +func summarize(ms []mutator.Mutator) []string { + out := make([]string, 0, len(ms)) + for _, m := range ms { + out = append(out, m.Position().String()+" "+m.Type().String()+" "+m.Status().String()) + } + + return out +} diff --git a/internal/engine/testdata/fixtures/nomutant_adjacent_typed_go b/internal/engine/testdata/fixtures/nomutant_adjacent_typed_go new file mode 100644 index 00000000..faf7322a --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_adjacent_typed_go @@ -0,0 +1,8 @@ +package main + +func main() { + a := 1 + 2 //nomutant:arithmetic-base + b := 3 + 4 //nomutant:invert-bitwise + _ = a + _ = b +} diff --git a/internal/engine/testdata/fixtures/nomutant_block_func_go b/internal/engine/testdata/fixtures/nomutant_block_func_go new file mode 100644 index 00000000..1803c90c --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_block_func_go @@ -0,0 +1,11 @@ +package main + +//nomutant +func suppressed() int { + a := 1 + 2 + return a +} + +func notSuppressed() int { + return 3 + 4 +} diff --git a/internal/engine/testdata/fixtures/nomutant_block_if_go b/internal/engine/testdata/fixtures/nomutant_block_if_go new file mode 100644 index 00000000..f99acd3d --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_block_if_go @@ -0,0 +1,13 @@ +package main + +func main() { + a := 1 + b := 2 + //nomutant + if a > b { + c := 3 + 4 + _ = c + } + d := 5 + 6 + _ = d +} diff --git a/internal/engine/testdata/fixtures/nomutant_eol_go b/internal/engine/testdata/fixtures/nomutant_eol_go new file mode 100644 index 00000000..e17b60b8 --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_eol_go @@ -0,0 +1,8 @@ +package main + +func main() { + a := 1 + 2 //nomutant + b := 3 + 4 + _ = a + _ = b +} diff --git a/internal/engine/testdata/fixtures/nomutant_eol_multi_token_go b/internal/engine/testdata/fixtures/nomutant_eol_multi_token_go new file mode 100644 index 00000000..72afbfb2 --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_eol_multi_token_go @@ -0,0 +1,8 @@ +package main + +func main() { + a := 1 + 2 * 3 //nomutant + b := 4 + 5 + _ = a + _ = b +} diff --git a/internal/engine/testdata/fixtures/nomutant_eol_typed_go b/internal/engine/testdata/fixtures/nomutant_eol_typed_go new file mode 100644 index 00000000..fb7de393 --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_eol_typed_go @@ -0,0 +1,6 @@ +package main + +func main() { + a := 1 + 2 //nomutant:invert-bitwise + _ = a +} diff --git a/internal/engine/testdata/fixtures/nomutant_file_go b/internal/engine/testdata/fixtures/nomutant_file_go new file mode 100644 index 00000000..eebe4e72 --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_file_go @@ -0,0 +1,9 @@ +//nomutant +package main + +func main() { + a := 1 + 2 + b := 3 * 4 + _ = a + _ = b +} diff --git a/internal/engine/testdata/fixtures/nomutant_partial_typed_go b/internal/engine/testdata/fixtures/nomutant_partial_typed_go new file mode 100644 index 00000000..38bc168d --- /dev/null +++ b/internal/engine/testdata/fixtures/nomutant_partial_typed_go @@ -0,0 +1,9 @@ +package main + +func main() { + a := 1 + b := 2 + if a < b { //nomutant:conditionals-boundary + _ = 1 + } +}