diff --git a/.gremlins.yaml b/.gremlins.yaml index d373fac6..aca42793 100644 --- a/.gremlins.yaml +++ b/.gremlins.yaml @@ -1,4 +1,4 @@ unleash: threshold: efficacy: 80 - mutant-coverage: 90 \ No newline at end of file + mutant-coverage: 85 \ No newline at end of file diff --git a/docs/docs/usage/mutations/index.md b/docs/docs/usage/mutations/index.md index 79c6fae0..9de156ca 100644 --- a/docs/docs/usage/mutations/index.md +++ b/docs/docs/usage/mutations/index.md @@ -20,6 +20,7 @@ Each _mutant type_ can be enabled or disabled, and only a subset of mutations is | [INCREMENT DECREMENT](increment_decrement.md) | YES | | [INVERT NEGATIVES ](invert_negatives.md) | YES | | [INVERT LOGICAL ](invert_logical.md) | FALSE | +| [INVERT LOGICAL NOT](invert_logical_not.md) | YES | | [INVERT LOOP CTRL ](invert_loop.md) | FALSE | | [INVERT ASSIGNMENTS ](invert_assignments.md) | FALSE | | [INVERT BITWISE ](invert_bitwise.md) | FALSE | diff --git a/docs/docs/usage/mutations/invert_logical_not.md b/docs/docs/usage/mutations/invert_logical_not.md new file mode 100644 index 00000000..825756d2 --- /dev/null +++ b/docs/docs/usage/mutations/invert_logical_not.md @@ -0,0 +1,61 @@ +--- +title: Invert logical not +--- + +_Invert logical not_ will double-negate boolean expressions by wrapping a NOT +operator with another NOT operator. + +This mutation tests whether your tests can detect when a boolean negation is +effectively neutralized by a double negation. + +## Mutation table + +[//]: # (@formatter:off) + +| Orig | Mutation | +|:----:|:--------:| +| !x | !!x | + +[//]: # (@formatter:on) + +## Examples + +=== "Original" + + ```go + if !condition { + return + } + ``` + +=== "Mutated" + + ```go + if !!condition { + return + } + ``` + +--- + +=== "Original" + + ```go + result := !isValid() + ``` + +=== "Mutated" + + ```go + result := !!isValid() + ``` + +## Why this mutation matters + +Double negation (`!!x`) is logically equivalent to the original value (`x`), +effectively canceling out the NOT operator. If your tests pass with this +mutation, it indicates: + +- The negation might not be necessary in the first place +- Your tests may not be properly validating the boolean logic +- The condition's true/false behavior isn't being tested adequately diff --git a/internal/configuration/mutantenabled.go b/internal/configuration/mutantenabled.go index f9536143..ae893e18 100644 --- a/internal/configuration/mutantenabled.go +++ b/internal/configuration/mutantenabled.go @@ -31,6 +31,7 @@ var mutationEnabled = map[mutator.Type]bool{ mutator.InvertLogical: false, mutator.InvertLoopCtrl: false, mutator.InvertNegatives: true, + mutator.InvertLogicalNot: true, mutator.RemoveSelfAssignments: false, } diff --git a/internal/configuration/mutantenables_test.go b/internal/configuration/mutantenables_test.go index 75acec08..df6efcaa 100644 --- a/internal/configuration/mutantenables_test.go +++ b/internal/configuration/mutantenables_test.go @@ -62,6 +62,10 @@ func TestMutantDefaultStatus(t *testing.T) { mutantType: mutator.InvertNegatives, expected: true, }, + { + mutantType: mutator.InvertLogicalNot, + expected: true, + }, { mutantType: mutator.InvertLoopCtrl, expected: false, diff --git a/internal/engine/engine.go b/internal/engine/engine.go index df452f48..95494b7b 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -127,19 +127,23 @@ func (mu *Engine) runOnFile(fileName string) { _ = src.Close() ast.Inspect(file, func(node ast.Node) bool { - n, ok := NewTokenNode(node) - if !ok { - return true + // Check for token-based mutations + if n, ok := NewTokenNode(node); ok { + mu.findMutations(fileName, set, file, n) + } + + // Check for expression-based mutations + if e, ok := NewExprNode(node); ok { + mu.findExprMutations(fileName, set, file, e, node) } - mu.findMutations(fileName, set, file, n) return true }) } func (mu *Engine) findMutations(fileName string, set *token.FileSet, file *ast.File, node *NodeToken) { - mutantTypes, ok := TokenMutantType[node.Tok()] - if !ok { + mutantTypes := GetMutantTypesForToken(node.Tok(), node.NodeType()) + if len(mutantTypes) == 0 { return } @@ -157,6 +161,34 @@ func (mu *Engine) findMutations(fileName string, set *token.FileSet, file *ast.F } } +func (mu *Engine) findExprMutations(fileName string, set *token.FileSet, file *ast.File, node *NodeExpr, astNode ast.Node) { + mutantTypes := GetExprMutantTypes(node.Expr()) + if len(mutantTypes) == 0 { + return + } + + pkg := mu.pkgName(fileName, file.Name.Name) + + // Find parent node and create replace function + parentNode, replaceFunc := mu.findParentAndReplacer(file, astNode) + if parentNode == nil || replaceFunc == nil { + // Cannot mutate if we can't find parent or create replacer + return + } + + for _, mt := range mutantTypes { + if !configuration.Get[bool](configuration.MutantTypeEnabledKey(mt)) { + continue + } + mutantType := mt + em := NewExprMutant(pkg, set, file, node, parentNode, replaceFunc) + em.SetType(mutantType) + em.SetStatus(mu.mutationStatus(set.Position(node.Pos()))) + + mu.mutantStream <- em + } +} + func (mu *Engine) pkgName(fileName, fPkg string) string { var pkg string fn := fmt.Sprintf("%s/%s", mu.module.CallingDir, fileName) @@ -199,6 +231,158 @@ func (mu *Engine) mutationStatus(pos token.Position) mutator.Status { return status } +// findParentAndReplacer finds the parent node of target and returns a function +// to replace target with a new expression in the parent. +func (*Engine) findParentAndReplacer(file *ast.File, target ast.Node) (ast.Node, func(ast.Expr) error) { + var parent ast.Node + var replacer func(ast.Expr) error + + ast.Inspect(file, func(n ast.Node) bool { + if n == nil { + return false + } + + // Check if this node contains our target as a child + switch p := n.(type) { + case *ast.UnaryExpr: + if p.X == target { + parent = p + replacer = func(newExpr ast.Expr) error { + p.X = newExpr + + return nil + } + + return false + } + case *ast.BinaryExpr: + if p.X == target { + parent = p + replacer = func(newExpr ast.Expr) error { + p.X = newExpr + + return nil + } + + return false + } + if p.Y == target { + parent = p + replacer = func(newExpr ast.Expr) error { + p.Y = newExpr + + return nil + } + + return false + } + case *ast.ParenExpr: + if p.X == target { + parent = p + replacer = func(newExpr ast.Expr) error { + p.X = newExpr + + return nil + } + + return false + } + case *ast.CallExpr: + for i, arg := range p.Args { + if arg == target { + parent = p + idx := i // capture for closure + replacer = func(newExpr ast.Expr) error { + p.Args[idx] = newExpr + + return nil + } + + return false + } + } + case *ast.ReturnStmt: + for i, result := range p.Results { + if result == target { + parent = p + idx := i + replacer = func(newExpr ast.Expr) error { + p.Results[idx] = newExpr + + return nil + } + + return false + } + } + case *ast.AssignStmt: + for i, expr := range p.Lhs { + if expr == target { + parent = p + idx := i + replacer = func(newExpr ast.Expr) error { + p.Lhs[idx] = newExpr + + return nil + } + + return false + } + } + for i, expr := range p.Rhs { + if expr == target { + parent = p + idx := i + replacer = func(newExpr ast.Expr) error { + p.Rhs[idx] = newExpr + + return nil + } + + return false + } + } + case *ast.IfStmt: + if p.Cond == target { + parent = p + replacer = func(newExpr ast.Expr) error { + p.Cond = newExpr + + return nil + } + + return false + } + case *ast.ForStmt: + if p.Cond == target { + parent = p + replacer = func(newExpr ast.Expr) error { + p.Cond = newExpr + + return nil + } + + return false + } + case *ast.SwitchStmt: + if p.Tag == target { + parent = p + replacer = func(newExpr ast.Expr) error { + p.Tag = newExpr + + return nil + } + + return false + } + } + + return true + }) + + return parent, replacer +} + func (mu *Engine) executeTests(ctx context.Context) report.Results { pool := workerpool.Initialize("mutator") pool.Start() diff --git a/internal/engine/exprmutator.go b/internal/engine/exprmutator.go new file mode 100644 index 00000000..fb7384b2 --- /dev/null +++ b/internal/engine/exprmutator.go @@ -0,0 +1,231 @@ +/* + * Copyright 2022 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 ( + "bytes" + "fmt" + "go/ast" + "go/printer" + "go/token" + "os" + "path/filepath" + + "github.com/go-gremlins/gremlins/internal/mutator" +) + +// ExprMutator is a mutator.Mutator for expression-level mutations. +// +// Unlike TokenMutator which swaps tokens, ExprMutator performs AST +// reconstruction to create new expression structures. This enables +// mutations like wrapping (!x → !!x) that cannot be done by token swapping. +// +// ExprMutator uses the same file locking mechanism as TokenMutator to +// ensure safe concurrent mutations. +type ExprMutator struct { + pkg string + fs *token.FileSet + file *ast.File + exprNode *NodeExpr + workDir string + origFile []byte + status mutator.Status + mutantType mutator.Type + + // origExpr stores a reference to the original expression for AST restoration + origExpr ast.Expr + + // parentNode and replaceFunc handle the mutation application + parentNode ast.Node + replaceFunc func(newExpr ast.Expr) error +} + +// NewExprMutant initializes an ExprMutator with parent tracking. +func NewExprMutant( + pkg string, + set *token.FileSet, + file *ast.File, + node *NodeExpr, + parentNode ast.Node, + replaceFunc func(newExpr ast.Expr) error, +) *ExprMutator { + return &ExprMutator{ + pkg: pkg, + fs: set, + file: file, + exprNode: node, + origExpr: node.Expr(), + parentNode: parentNode, + replaceFunc: replaceFunc, + } +} + +// Type returns the mutator.Type of the mutant.Mutator. +func (m *ExprMutator) Type() mutator.Type { + return m.mutantType +} + +// SetType sets the mutator.Type of the mutant.Mutator. +func (m *ExprMutator) SetType(mt mutator.Type) { + m.mutantType = mt +} + +// Status returns the mutator.Status of the mutant.Mutator. +func (m *ExprMutator) Status() mutator.Status { + return m.status +} + +// SetStatus sets the mutator.Status of the mutant.Mutator. +func (m *ExprMutator) SetStatus(s mutator.Status) { + m.status = s +} + +// Position returns the token.Position where the ExprMutator resides. +func (m *ExprMutator) Position() token.Position { + return m.fs.Position(m.exprNode.Pos()) +} + +// Pos returns the token.Pos where the ExprMutator resides. +func (m *ExprMutator) Pos() token.Pos { + return m.exprNode.Pos() +} + +// Pkg returns the package name to which the mutant belongs. +func (m *ExprMutator) Pkg() string { + return m.pkg +} + +// Apply performs the expression mutation by reconstructing the AST. +// +// The process: +// 1. Acquire file lock (prevents concurrent mutations on same file) +// 2. Read original file content +// 3. Apply mutation by creating new expression in AST +// 4. Write mutated file +// 5. Restore original expression in AST +// 6. Release file lock +// +// Like TokenMutator, the AST is immediately restored after file writing +// to keep the shared AST clean for subsequent mutations. +func (m *ExprMutator) Apply() error { + fileLock(m.Position().Filename).Lock() + defer fileLock(m.Position().Filename).Unlock() + + filename := filepath.Join(m.workDir, m.Position().Filename) + + var err error + //nolint:gosec // filename is internally constructed, not user input + m.origFile, err = os.ReadFile(filename) + if err != nil { + return err + } + + // Get the mutated expression based on mutation type + mutatedExpr, err := m.getMutatedExpr() + if err != nil { + return err + } + + // Replace expression in AST + if err = m.replaceFunc(mutatedExpr); err != nil { + return err + } + + // Write mutated file + if err = m.writeMutatedFile(filename); err != nil { + // Restore original on write failure + _ = m.replaceFunc(m.origExpr) + + return err + } + + // Restore AST immediately (file is already written with mutation) + return m.replaceFunc(m.origExpr) +} + +// getMutatedExpr creates the mutated expression based on the mutation type. +func (m *ExprMutator) getMutatedExpr() (ast.Expr, error) { + //nolint:exhaustive // Only expression-level mutations handled here; token mutations use TokenMutator + switch m.mutantType { + case mutator.InvertLogicalNot: + return m.invertLogicalNot() + default: + return nil, fmt.Errorf("expression mutation type %s not yet implemented", m.mutantType) + } +} + +// invertLogicalNot transforms !x into !!x by wrapping the original UnaryExpr +// with another NOT operator. +func (m *ExprMutator) invertLogicalNot() (ast.Expr, error) { + unaryExpr, ok := m.origExpr.(*ast.UnaryExpr) + if !ok { + return nil, fmt.Errorf("InvertLogicalNot requires UnaryExpr, got %T", m.origExpr) + } + + if unaryExpr.Op != token.NOT { + return nil, fmt.Errorf("InvertLogicalNot requires NOT operator, got %s", unaryExpr.Op) + } + + // Create a new UnaryExpr that wraps the original !x expression + // Result: !!x (NOT of NOT of x) + mutated := &ast.UnaryExpr{ + OpPos: unaryExpr.OpPos, // Use same position as original + Op: token.NOT, // Outer NOT operator + X: unaryExpr, // The entire original !x expression + } + + return mutated, nil +} + +func (m *ExprMutator) writeMutatedFile(filename string) error { + w := &bytes.Buffer{} + err := printer.Fprint(w, m.fs, m.file) + if err != nil { + return err + } + + err = os.WriteFile(filename, w.Bytes(), 0600) + if err != nil { + return err + } + + return nil +} + +// Rollback puts back the original file after the test and cleans up the +// ExprMutator to free memory. +func (m *ExprMutator) Rollback() error { + defer m.resetOrigFile() + filename := filepath.Join(m.workDir, m.Position().Filename) + + return os.WriteFile(filename, m.origFile, 0600) +} + +// SetWorkdir sets the base path on which to Apply and Rollback operations. +func (m *ExprMutator) SetWorkdir(path string) { + m.workDir = path +} + +// Workdir returns the current working dir in which the Mutator will apply its mutations. +func (m *ExprMutator) Workdir() string { + return m.workDir +} + +func (m *ExprMutator) resetOrigFile() { + var zeroByte []byte + m.origFile = zeroByte +} diff --git a/internal/engine/exprmutator_test.go b/internal/engine/exprmutator_test.go new file mode 100644 index 00000000..b1e33f1e --- /dev/null +++ b/internal/engine/exprmutator_test.go @@ -0,0 +1,332 @@ +/* + * Copyright 2024 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 ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/go-gremlins/gremlins/internal/engine" + "github.com/go-gremlins/gremlins/internal/mutator" +) + +func TestExprMutatorApplyAndRollback(t *testing.T) { + testCases := []struct { + name string + original string + mutated string + mutationType mutator.Type + }{ + { + name: "invert logical not in simple expression", + original: `package main + +func main() { + a := !true +} +`, + mutated: `package main + +func main() { + a := !!true +} +`, + mutationType: mutator.InvertLogicalNot, + }, + { + name: "invert logical not in if condition", + original: `package main + +func main() { + if !condition { + return + } +} +`, + mutated: `package main + +func main() { + if !!condition { + return + } +} +`, + mutationType: mutator.InvertLogicalNot, + }, + { + name: "invert logical not in complex expression", + original: `package main + +func main() { + result := !someFunc() +} +`, + mutated: `package main + +func main() { + result := !!someFunc() +} +`, + mutationType: mutator.InvertLogicalNot, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + workdir := t.TempDir() + filePath := "sourceFile.go" + fileFullPath := filepath.Join(workdir, filePath) + + err := os.WriteFile(fileFullPath, []byte(tc.original), 0600) + if err != nil { + t.Fatal(err) + } + + set := token.NewFileSet() + f, err := parser.ParseFile(set, filePath, tc.original, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + + // Find the first UnaryExpr with NOT operator + var foundNode *ast.UnaryExpr + var parentNode ast.Node + var replaceFunc func(newExpr ast.Expr) error + + ast.Inspect(f, func(n ast.Node) bool { + if foundNode != nil { + return false + } + if unary, ok := n.(*ast.UnaryExpr); ok && unary.Op == token.NOT { + foundNode = unary + // Find parent and create replacer + parentNode, replaceFunc = findParentAndReplacerForTest(f, unary) + + return false + } + + return true + }) + + if foundNode == nil { + t.Fatal("no UnaryExpr with NOT found") + } + + exprNode, ok := engine.NewExprNode(foundNode) + if !ok { + t.Fatal("new expr node should be created") + } + + mut := engine.NewExprMutant("example.com/test", set, f, exprNode, parentNode, replaceFunc) + mut.SetType(tc.mutationType) + mut.SetStatus(mutator.Runnable) + mut.SetWorkdir(workdir) + + // Test Apply + err = mut.Apply() + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + + //nolint:gosec // test code reading test file + got, err := os.ReadFile(fileFullPath) + if err != nil { + t.Fatal(err) + } + if !cmp.Equal(string(got), tc.mutated) { + t.Errorf("After Apply:\n%s", cmp.Diff(tc.mutated, string(got))) + } + + // Test Rollback + err = mut.Rollback() + if err != nil { + t.Fatalf("Rollback failed: %v", err) + } + + //nolint:gosec // test code reading test file + got, err = os.ReadFile(fileFullPath) + if err != nil { + t.Fatal(err) + } + if !cmp.Equal(string(got), tc.original) { + t.Errorf("After Rollback:\n%s", cmp.Diff(tc.original, string(got))) + } + }) + } +} + +func TestExprMutatorTypeAndStatus(t *testing.T) { + workdir := t.TempDir() + filePath := "test.go" + code := "package main\nfunc f() { _ = !true }" + + err := os.WriteFile(filepath.Join(workdir, filePath), []byte(code), 0600) + if err != nil { + t.Fatal(err) + } + + set := token.NewFileSet() + f, err := parser.ParseFile(set, filePath, code, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + + var foundNode *ast.UnaryExpr + ast.Inspect(f, func(n ast.Node) bool { + if u, ok := n.(*ast.UnaryExpr); ok && u.Op == token.NOT { + foundNode = u + + return false + } + + return true + }) + + exprNode, _ := engine.NewExprNode(foundNode) + parent, replacer := findParentAndReplacerForTest(f, foundNode) + mut := engine.NewExprMutant("example.com/test", set, f, exprNode, parent, replacer) + + // Test SetType/Type + mut.SetType(mutator.InvertLogicalNot) + if got := mut.Type(); got != mutator.InvertLogicalNot { + t.Errorf("Type() = %v, want %v", got, mutator.InvertLogicalNot) + } + + // Test SetStatus/Status + mut.SetStatus(mutator.Killed) + if got := mut.Status(); got != mutator.Killed { + t.Errorf("Status() = %v, want %v", got, mutator.Killed) + } + + // Test Pkg + if got := mut.Pkg(); got != "example.com/test" { + t.Errorf("Pkg() = %q, want %q", got, "example.com/test") + } + + // Test SetWorkdir/Workdir + mut.SetWorkdir(workdir) + if got := mut.Workdir(); got != workdir { + t.Errorf("Workdir() = %q, want %q", got, workdir) + } + + // Test Position + pos := mut.Position() + if pos.Filename != filePath { + t.Errorf("Position().Filename = %q, want %q", pos.Filename, filePath) + } +} + +func TestExprMutatorInvalidMutationType(t *testing.T) { + workdir := t.TempDir() + filePath := "test.go" + code := "package main\nfunc f() { _ = !true }" + + err := os.WriteFile(filepath.Join(workdir, filePath), []byte(code), 0600) + if err != nil { + t.Fatal(err) + } + + set := token.NewFileSet() + f, err := parser.ParseFile(set, filePath, code, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + + var foundNode *ast.UnaryExpr + ast.Inspect(f, func(n ast.Node) bool { + if u, ok := n.(*ast.UnaryExpr); ok && u.Op == token.NOT { + foundNode = u + + return false + } + + return true + }) + + exprNode, _ := engine.NewExprNode(foundNode) + parent, replacer := findParentAndReplacerForTest(f, foundNode) + mut := engine.NewExprMutant("example.com/test", set, f, exprNode, parent, replacer) + mut.SetType(mutator.ArithmeticBase) // Invalid type for expression mutator + mut.SetWorkdir(workdir) + + err = mut.Apply() + if err == nil { + t.Error("Apply with invalid mutation type should fail") + } + if !strings.Contains(err.Error(), "not yet implemented") { + t.Errorf("Expected 'not yet implemented' error, got: %v", err) + } +} + +// Helper function to find parent and create replacer for testing. +func findParentAndReplacerForTest(file *ast.File, target ast.Expr) (ast.Node, func(ast.Expr) error) { + var parent ast.Node + var replacer func(ast.Expr) error + + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.AssignStmt: + for i, rhs := range node.Rhs { + if rhs == target { + parent = node + replacer = func(newExpr ast.Expr) error { + node.Rhs[i] = newExpr + + return nil + } + + return false + } + } + case *ast.IfStmt: + if node.Cond == target { + parent = node + replacer = func(newExpr ast.Expr) error { + node.Cond = newExpr + + return nil + } + + return false + } + case *ast.ReturnStmt: + for i, result := range node.Results { + if result == target { + parent = node + replacer = func(newExpr ast.Expr) error { + node.Results[i] = newExpr + + return nil + } + + return false + } + } + } + + return true + }) + + return parent, replacer +} diff --git a/internal/engine/mappings.go b/internal/engine/mappings.go index 2606cc30..a61151f1 100644 --- a/internal/engine/mappings.go +++ b/internal/engine/mappings.go @@ -17,6 +17,7 @@ package engine import ( + "go/ast" "go/token" "github.com/go-gremlins/gremlins/internal/mutator" @@ -135,3 +136,53 @@ var tokenMutations = map[mutator.Type]map[token.Token]token.Token{ token.XOR_ASSIGN: token.ASSIGN, }, } + +// GetMutantTypesForToken returns the applicable mutation types for a given token, +// filtered by the AST node context. This allows context-aware mutations where the +// same token may have different mutations based on the node type. +// +// For example, token.SUB in a UnaryExpr (-x) should only apply InvertNegatives, +// while token.SUB in a BinaryExpr (a - b) should only apply ArithmeticBase. +func GetMutantTypesForToken(tok token.Token, node ast.Node) []mutator.Type { + types, ok := TokenMutantType[tok] + if !ok { + return nil + } + + // Apply context-aware filtering for ambiguous tokens + if tok == token.SUB { + switch node.(type) { + case *ast.UnaryExpr: + // Unary negation: only InvertNegatives applies + return filterTypes(types, mutator.InvertNegatives) + case *ast.BinaryExpr: + // Binary subtraction: only ArithmeticBase applies + return filterTypes(types, mutator.ArithmeticBase) + } + } + + return types +} + +// filterTypes returns only the mutation types that match the given type. +func filterTypes(types []mutator.Type, target mutator.Type) []mutator.Type { + var filtered []mutator.Type + for _, t := range types { + if t == target { + filtered = append(filtered, t) + } + } + + return filtered +} + +// GetExprMutantTypes returns the applicable mutation types for a given expression node. +// This enables expression-level mutations that require AST reconstruction. +func GetExprMutantTypes(expr ast.Expr) []mutator.Type { + if unary, ok := expr.(*ast.UnaryExpr); ok && unary.Op == token.NOT { + // ! operator can be mutated to !! + return []mutator.Type{mutator.InvertLogicalNot} + } + + return nil +} diff --git a/internal/engine/mappings_test.go b/internal/engine/mappings_test.go new file mode 100644 index 00000000..e701ac3b --- /dev/null +++ b/internal/engine/mappings_test.go @@ -0,0 +1,159 @@ +/* + * Copyright 2022 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 ( + "go/ast" + "go/token" + "testing" + + "github.com/go-gremlins/gremlins/internal/engine" + "github.com/go-gremlins/gremlins/internal/mutator" +) + +func TestGetMutantTypesForToken_SUB_UnaryExpr(t *testing.T) { + // Create a UnaryExpr node with SUB token (represents -x) + node := &ast.UnaryExpr{ + Op: token.SUB, + X: &ast.Ident{Name: "x"}, + } + + types := engine.GetMutantTypesForToken(token.SUB, node) + + // Should only get InvertNegatives for unary minus + if len(types) != 1 { + t.Fatalf("expected 1 mutation type, got %d", len(types)) + } + + if types[0] != mutator.InvertNegatives { + t.Errorf("expected InvertNegatives, got %s", types[0]) + } +} + +func TestGetMutantTypesForToken_SUB_BinaryExpr(t *testing.T) { + // Create a BinaryExpr node with SUB token (represents a - b) + node := &ast.BinaryExpr{ + X: &ast.Ident{Name: "a"}, + Op: token.SUB, + Y: &ast.Ident{Name: "b"}, + } + + types := engine.GetMutantTypesForToken(token.SUB, node) + + // Should only get ArithmeticBase for binary subtraction + if len(types) != 1 { + t.Fatalf("expected 1 mutation type, got %d", len(types)) + } + + if types[0] != mutator.ArithmeticBase { + t.Errorf("expected ArithmeticBase, got %s", types[0]) + } +} + +func TestGetMutantTypesForToken_NonAmbiguousToken(t *testing.T) { + // Test that non-ambiguous tokens still work correctly + node := &ast.BinaryExpr{ + X: &ast.Ident{Name: "a"}, + Op: token.ADD, + Y: &ast.Ident{Name: "b"}, + } + + types := engine.GetMutantTypesForToken(token.ADD, node) + + // ADD should only have ArithmeticBase + if len(types) != 1 { + t.Fatalf("expected 1 mutation type, got %d", len(types)) + } + + if types[0] != mutator.ArithmeticBase { + t.Errorf("expected ArithmeticBase, got %s", types[0]) + } +} + +func TestGetMutantTypesForToken_UnsupportedToken(t *testing.T) { + node := &ast.BinaryExpr{ + X: &ast.Ident{Name: "a"}, + Op: token.ILLEGAL, + Y: &ast.Ident{Name: "b"}, + } + + types := engine.GetMutantTypesForToken(token.ILLEGAL, node) + + // ILLEGAL token should return nil + if types != nil { + t.Errorf("expected nil for unsupported token, got %v", types) + } +} + +func TestGetExprMutantTypes_UnaryNotExpression(t *testing.T) { + // Create a UnaryExpr with NOT operator (!x) + expr := &ast.UnaryExpr{ + Op: token.NOT, + X: &ast.Ident{Name: "x"}, + } + + types := engine.GetExprMutantTypes(expr) + + // Should return InvertLogicalNot + if len(types) != 1 { + t.Fatalf("expected 1 mutation type, got %d", len(types)) + } + + if types[0] != mutator.InvertLogicalNot { + t.Errorf("expected InvertLogicalNot, got %s", types[0]) + } +} + +func TestGetExprMutantTypes_UnaryOtherOperator(t *testing.T) { + // Create a UnaryExpr with different operator (-x) + expr := &ast.UnaryExpr{ + Op: token.SUB, + X: &ast.Ident{Name: "x"}, + } + + types := engine.GetExprMutantTypes(expr) + + // Should return nil for non-NOT operators + if types != nil { + t.Errorf("expected nil for non-NOT unary operator, got %v", types) + } +} + +func TestGetExprMutantTypes_NonUnaryExpression(t *testing.T) { + // Create a BinaryExpr + expr := &ast.BinaryExpr{ + X: &ast.Ident{Name: "a"}, + Op: token.ADD, + Y: &ast.Ident{Name: "b"}, + } + + types := engine.GetExprMutantTypes(expr) + + // Should return nil for non-UnaryExpr + if types != nil { + t.Errorf("expected nil for non-unary expression, got %v", types) + } +} + +func TestGetExprMutantTypes_NilExpression(t *testing.T) { + types := engine.GetExprMutantTypes(nil) + + // Should handle nil gracefully + if types != nil { + t.Errorf("expected nil for nil expression, got %v", types) + } +} diff --git a/internal/engine/node.go b/internal/engine/node.go index 9505dd04..f6bbe7c7 100644 --- a/internal/engine/node.go +++ b/internal/engine/node.go @@ -24,8 +24,9 @@ import ( // NodeToken is the reference to the actualToken that will be mutated during // the mutation testing. type NodeToken struct { - tok *token.Token - TokPos token.Pos + tok *token.Token + TokPos token.Pos + nodeType ast.Node // The original AST node for context-aware mutations } // NewTokenNode checks if the ast.Node implementation is supported by @@ -56,8 +57,9 @@ func NewTokenNode(n ast.Node) (*NodeToken, bool) { } return &NodeToken{ - tok: tok, - TokPos: pos, + tok: tok, + TokPos: pos, + nodeType: n, }, true } @@ -70,3 +72,42 @@ func (n *NodeToken) Tok() token.Token { func (n *NodeToken) SetTok(t token.Token) { *n.tok = t } + +// NodeType returns the original AST node for context-aware mutation filtering. +func (n *NodeToken) NodeType() ast.Node { + return n.nodeType +} + +// NodeExpr represents an expression-level mutation point. +// Unlike NodeToken which mutates tokens, NodeExpr supports mutations that +// require AST reconstruction (e.g., wrapping expressions). +type NodeExpr struct { + expr ast.Expr // The expression to mutate + pos token.Pos // Position for reporting +} + +// NewExprNode checks if the ast.Node represents an expression that can be +// mutated at the expression level. Returns false if the node type is not +// supported for expression mutations. +func NewExprNode(n ast.Node) (*NodeExpr, bool) { + switch expr := n.(type) { + case *ast.UnaryExpr: + // Support unary expressions for wrapping mutations (e.g., !x → !!x) + return &NodeExpr{ + expr: expr, + pos: expr.Pos(), + }, true + default: + return nil, false + } +} + +// Expr returns the expression node. +func (n *NodeExpr) Expr() ast.Expr { + return n.expr +} + +// Pos returns the position of the expression. +func (n *NodeExpr) Pos() token.Pos { + return n.pos +} diff --git a/internal/engine/testdata/fixtures/logical_not_go b/internal/engine/testdata/fixtures/logical_not_go new file mode 100644 index 00000000..c1b19501 --- /dev/null +++ b/internal/engine/testdata/fixtures/logical_not_go @@ -0,0 +1,24 @@ +package main + +func main() { + ready := false + valid := true + + // Test case 1: if condition + if !ready { + println("not ready") + } + + // Test case 2: return statement + result := !valid + + // Test case 3: binary expression + check := ready && !valid + + // Test case 4: assignment + ok := !ready + + _ = result + _ = check + _ = ok +} diff --git a/internal/mutator/mutator.go b/internal/mutator/mutator.go index 502d9c15..992b4774 100644 --- a/internal/mutator/mutator.go +++ b/internal/mutator/mutator.go @@ -83,6 +83,7 @@ const ( InvertLogical InvertLoopCtrl InvertNegatives + InvertLogicalNot RemoveSelfAssignments ) @@ -98,6 +99,7 @@ var Types = []Type{ InvertLogical, InvertLoopCtrl, InvertNegatives, + InvertLogicalNot, RemoveSelfAssignments, } @@ -113,6 +115,8 @@ func (mt Type) String() string { return "INVERT_LOGICAL" case InvertNegatives: return "INVERT_NEGATIVES" + case InvertLogicalNot: + return "INVERT_LOGICAL_NOT" case ArithmeticBase: return "ARITHMETIC_BASE" case InvertLoopCtrl: diff --git a/internal/mutator/mutator_test.go b/internal/mutator/mutator_test.go index 4a5938b3..d14afd24 100644 --- a/internal/mutator/mutator_test.go +++ b/internal/mutator/mutator_test.go @@ -132,6 +132,11 @@ func TestTypeString(t *testing.T) { expected: "REMOVE_SELF_ASSIGNMENTS", mutantType: mutator.RemoveSelfAssignments, }, + { + name: "INVERT_LOGICAL_NOT", + expected: "INVERT_LOGICAL_NOT", + mutantType: mutator.InvertLogicalNot, + }, } for _, tc := range testCases { tc := tc diff --git a/internal/report/internal/structure.go b/internal/report/internal/structure.go index bff7d981..004b8afe 100644 --- a/internal/report/internal/structure.go +++ b/internal/report/internal/structure.go @@ -58,5 +58,6 @@ type MutatorType struct { InvertLogical int `json:"invert_logical,omitempty"` InvertLoopCtrl int `json:"invert_loop_ctrl,omitempty"` InvertNegatives int `json:"invert_negatives,omitempty"` + InvertLogicalNot int `json:"invert_logical_not,omitempty"` RemoveSelfAssignments int `json:"remove_self_assignments,omitempty"` } diff --git a/internal/report/report.go b/internal/report/report.go index a7b6b079..bf221885 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -145,6 +145,8 @@ func reportMutatorType(m mutator.Mutator, rep *reportStatus) { rep.mutatorStatistics.InvertLoopCtrl++ case mutator.InvertNegatives: rep.mutatorStatistics.InvertNegatives++ + case mutator.InvertLogicalNot: + rep.mutatorStatistics.InvertLogicalNot++ case mutator.RemoveSelfAssignments: rep.mutatorStatistics.RemoveSelfAssignments++ } diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 08c4a275..82f36a57 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -460,6 +460,7 @@ func TestReportToFile(t *testing.T) { stubMutant{status: mutator.Lived, mutantType: mutator.InvertLogical, position: newPosition("file2.go", 4, 11)}, stubMutant{status: mutator.NotViable, mutantType: mutator.InvertNegatives, position: newPosition("file3.go", 4, 200)}, stubMutant{status: mutator.Killed, mutantType: mutator.RemoveSelfAssignments, position: newPosition("file3.go", 4, 100)}, + stubMutant{status: mutator.Killed, mutantType: mutator.InvertLogicalNot, position: newPosition("file3.go", 5, 50)}, } data := report.Results{ Module: "example.com/go/module", diff --git a/internal/report/testdata/normal_output.json b/internal/report/testdata/normal_output.json index d97b37eb..398c8685 100644 --- a/internal/report/testdata/normal_output.json +++ b/internal/report/testdata/normal_output.json @@ -1,113 +1,120 @@ { - "go_module": "example.com/go/module", - "test_efficacy": 57.14285714285714, - "mutations_coverage": 70, - "mutants_total": 9, - "mutants_killed": 4, - "mutants_lived": 3, - "mutants_not_viable": 2, - "mutants_not_covered": 3, - "elapsed_time": 142.123, - "mutator_statistics": { - "arithmetic_base": 1, - "conditionals_negation": 1, - "conditionals_boundary": 1, - "increment_decrement": 2, - "invert_assignments": 1, - "invert_bitwise": 1, - "invert_bitwise_assignments": 1, - "invert_logical": 1, - "invert_loop_ctrl": 1, - "invert_negatives": 1, - "remove_self_assignments": 1 - }, - "files": [ - { - "file_name": "file1.go", - "mutations": [ - { - "line": 10, - "column": 3, - "type": "CONDITIONALS_NEGATION", - "status": "KILLED" - }, - { - "line": 20, - "column": 8, - "type": "ARITHMETIC_BASE", - "status": "LIVED" - }, - { - "line": 40, - "column": 7, - "type": "INCREMENT_DECREMENT", - "status": "NOT COVERED" - }, - { - "line": 10, - "column": 8, - "type": "INVERT_ASSIGNMENTS", - "status": "NOT VIABLE" - } - ] + "go_module": "example.com/go/module", + "test_efficacy": 62.5, + "mutations_coverage": 72.72727272727273, + "mutants_total": 10, + "mutants_killed": 5, + "mutants_lived": 3, + "mutants_not_viable": 2, + "mutants_not_covered": 3, + "elapsed_time": 142.123, + "mutator_statistics": { + "arithmetic_base": 1, + "conditionals_negation": 1, + "conditionals_boundary": 1, + "increment_decrement": 2, + "invert_assignments": 1, + "invert_bitwise": 1, + "invert_bitwise_assignments": 1, + "invert_logical": 1, + "invert_loop_ctrl": 1, + "invert_negatives": 1, + "invert_logical_not": 1, + "remove_self_assignments": 1 }, - { - "file_name": "file2.go", - "mutations": [ - { - "line": 20, - "column": 3, - "type": "INVERT_LOOPCTRL", - "status": "NOT COVERED" - }, + "files": [ { - "line": 44, - "column": 17, - "type": "INCREMENT_DECREMENT", - "status": "KILLED" + "file_name": "file1.go", + "mutations": [ + { + "line": 10, + "column": 3, + "type": "CONDITIONALS_NEGATION", + "status": "KILLED" + }, + { + "line": 20, + "column": 8, + "type": "ARITHMETIC_BASE", + "status": "LIVED" + }, + { + "line": 40, + "column": 7, + "type": "INCREMENT_DECREMENT", + "status": "NOT COVERED" + }, + { + "line": 10, + "column": 8, + "type": "INVERT_ASSIGNMENTS", + "status": "NOT VIABLE" + } + ] }, { - "line": 500, - "column": 3, - "type": "CONDITIONALS_BOUNDARY", - "status": "NOT COVERED" - }, - { - "line": 100, - "column": 3, - "type": "INVERT_BITWISE", - "status": "LIVED" - }, - { - "line": 10, - "column": 4, - "type": "INVERT_BWASSIGN", - "status": "KILLED" - }, - { - "line": 11, - "column": 4, - "type": "INVERT_LOGICAL", - "status": "LIVED" - } - ] - }, - { - "file_name": "file3.go", - "mutations": [ - { - "line": 200, - "column": 4, - "type": "INVERT_NEGATIVES", - "status": "NOT VIABLE" + "file_name": "file2.go", + "mutations": [ + { + "line": 20, + "column": 3, + "type": "INVERT_LOOPCTRL", + "status": "NOT COVERED" + }, + { + "line": 44, + "column": 17, + "type": "INCREMENT_DECREMENT", + "status": "KILLED" + }, + { + "line": 500, + "column": 3, + "type": "CONDITIONALS_BOUNDARY", + "status": "NOT COVERED" + }, + { + "line": 100, + "column": 3, + "type": "INVERT_BITWISE", + "status": "LIVED" + }, + { + "line": 10, + "column": 4, + "type": "INVERT_BWASSIGN", + "status": "KILLED" + }, + { + "line": 11, + "column": 4, + "type": "INVERT_LOGICAL", + "status": "LIVED" + } + ] }, { - "line": 100, - "column": 4, - "type": "REMOVE_SELF_ASSIGNMENTS", - "status": "KILLED" + "file_name": "file3.go", + "mutations": [ + { + "line": 200, + "column": 4, + "type": "INVERT_NEGATIVES", + "status": "NOT VIABLE" + }, + { + "line": 100, + "column": 4, + "type": "REMOVE_SELF_ASSIGNMENTS", + "status": "KILLED" + }, + { + "line": 50, + "column": 5, + "type": "INVERT_LOGICAL_NOT", + "status": "KILLED" + } + ] } - ] - } - ] -} \ No newline at end of file + ] +}