mutineer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "digest"
6
+ require "fileutils"
7
+ require "rbconfig"
8
+
9
+ module Mutineer
10
+ # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
11
+ # the tests that actually exercise its line. Built once (Phase A), then queried
12
+ # per mutant (Phase B via #tests_for). Persisted to .mutineer/coverage.json with
13
+ # a content-based digest that rebuilds the map whenever any tracked file changes.
14
+ #
15
+ # Keys are "file:line" strings (relative to project_root) everywhere — in
16
+ # memory and on disk — so load/save needs no key transformation (KTD4).
17
+ class CoverageMap
18
+ DEFAULT_CAPTURE_TIMEOUT = 120 # seconds, per coverage subprocess (R3)
19
+
20
+ attr_reader :project_root, :failed_test_files, :phase_a_ran
21
+
22
+ def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
23
+ load_paths: ["lib"], project_root: Dir.pwd,
24
+ capture_timeout: DEFAULT_CAPTURE_TIMEOUT)
25
+ @source_paths = Array(source_paths)
26
+ @test_paths = Array(test_paths)
27
+ @cache_dir = cache_dir
28
+ @load_paths = Array(load_paths)
29
+ @project_root = project_root
30
+ @capture_timeout = capture_timeout
31
+ @map = {}
32
+ @failed_test_files = []
33
+ @phase_a_ran = false
34
+ end
35
+
36
+ # Phase A entry point: load the cached map when the content digest matches,
37
+ # otherwise rebuild from subprocesses and overwrite the cache.
38
+ def build_or_load
39
+ warn_external_sources
40
+ @digest = compute_digest
41
+
42
+ cached = read_cache
43
+ if cached && cached["digest"] == @digest
44
+ @map = cached["map"] || {}
45
+ @failed_test_files = cached["failed_test_files"] || []
46
+ warn_incomplete unless @failed_test_files.empty?
47
+ return self
48
+ end
49
+
50
+ run_phase_a
51
+ save
52
+ self
53
+ end
54
+
55
+ # Phase B lookup: the test files that cover `file:line`, or [] when none do.
56
+ # ponytail: per-file granularity; upgrade to per-method when throughput
57
+ # warrants (requires Minitest method isolation + finer Coverage tracking).
58
+ def tests_for(file, line)
59
+ @map["#{relativize(file)}:#{line}"] || []
60
+ end
61
+
62
+ private
63
+
64
+ def run_phase_a
65
+ @phase_a_ran = true
66
+ @map = {}
67
+ @failed_test_files = []
68
+
69
+ @test_paths.each do |test_path|
70
+ coverage = capture(test_path)
71
+ next unless coverage
72
+
73
+ record(coverage, test_path)
74
+ end
75
+ end
76
+
77
+ # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
78
+ # miss already-loaded app lines, so Coverage must start in a clean process
79
+ # before any source is loaded (KTD1/KTD2). Returns the parsed Coverage.result
80
+ # hash, or nil when the subprocess failed (logged + skipped per R6).
81
+ def capture(test_path)
82
+ out = +""
83
+ status = nil
84
+ Open3.popen2(RbConfig.ruby, "-") do |stdin, stdout, wait_thr|
85
+ stdin.write(subprocess_script(test_path))
86
+ stdin.close
87
+ reader = Thread.new { out << stdout.read }
88
+ # R3: bound the subprocess with a wall clock — a hanging test file must
89
+ # not wedge the whole run before any per-mutant timeout.
90
+ unless wait_thr.join(@capture_timeout)
91
+ Process.kill(:KILL, wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
92
+ reader.kill
93
+ return fail_test(test_path, "timed out after #{@capture_timeout}s")
94
+ end
95
+ reader.join
96
+ status = wait_thr.value
97
+ end
98
+ return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?
99
+
100
+ JSON.parse(out)
101
+ rescue JSON::ParserError => e
102
+ fail_test(test_path, "invalid coverage output: #{e.message}")
103
+ end
104
+
105
+ def fail_test(test_path, reason)
106
+ rel = relativize(test_path)
107
+ @failed_test_files << rel
108
+ warn "[mutineer] coverage skipped for #{rel}: #{reason}"
109
+ nil
110
+ end
111
+
112
+ def subprocess_script(test_path)
113
+ <<~RUBY
114
+ require "coverage"
115
+ require "json"
116
+ require "stringio"
117
+ require "minitest"
118
+ def Minitest.autorun; end
119
+ Coverage.start(lines: true)
120
+ $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
121
+ #{abs_source_paths.inspect}.each { |f| load f }
122
+ load #{absolute(test_path).inspect}
123
+ _orig = $stdout
124
+ $stdout = StringIO.new
125
+ Minitest.run([])
126
+ $stdout = _orig
127
+ puts Coverage.result.to_json
128
+ RUBY
129
+ end
130
+
131
+ # Records every source line with a non-zero execution count as covered by
132
+ # this test file. Coverage.result keys are absolute; relativize and drop any
133
+ # path outside the project (stdlib/gem files).
134
+ def record(coverage, test_path)
135
+ rel_test = relativize(test_path)
136
+ coverage.each do |abs_file, data|
137
+ rel = relativize(abs_file)
138
+ next if rel.start_with?("/") # outside project_root — not our source
139
+
140
+ counts = data.is_a?(Array) ? data : data["lines"]
141
+ counts.each_with_index do |count, idx|
142
+ next unless count&.positive?
143
+
144
+ (@map["#{rel}:#{idx + 1}"] ||= []) << rel_test
145
+ end
146
+ end
147
+ end
148
+
149
+ # R4: digest each file's ROLE + relative path + content length + content, plus
150
+ # the load_paths. Without role/path/length delimiters the digest collides
151
+ # (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
152
+ # accepting a stale cached map.
153
+ def compute_digest
154
+ d = Digest::SHA256.new
155
+ digest_group(d, "source", @source_paths)
156
+ digest_group(d, "test", @test_paths)
157
+ @load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
158
+ d.hexdigest
159
+ end
160
+
161
+ def digest_group(digest, role, paths)
162
+ paths.sort.each do |p|
163
+ content = File.read(absolute(p))
164
+ digest.update(role)
165
+ digest.update("\0")
166
+ digest.update(relativize(absolute(p)))
167
+ digest.update("\0")
168
+ digest.update(content.bytesize.to_s)
169
+ digest.update("\0")
170
+ digest.update(content)
171
+ digest.update("\0")
172
+ end
173
+ end
174
+
175
+ # R7: a configured source that resolves outside project_root would silently be
176
+ # dropped (its coverage relativizes to an absolute path). Warn instead.
177
+ def warn_external_sources
178
+ @source_paths.each do |p|
179
+ next unless relativize(absolute(p)).start_with?("/")
180
+
181
+ warn "[mutineer] source #{p} is outside project root #{@project_root}; " \
182
+ "its coverage will be ignored"
183
+ end
184
+ end
185
+
186
+ def cache_path = File.join(@cache_dir, "coverage.json")
187
+
188
+ def read_cache
189
+ return nil unless File.exist?(cache_path)
190
+
191
+ JSON.parse(File.read(cache_path))
192
+ rescue JSON::ParserError
193
+ nil # corrupt cache — rebuild from scratch
194
+ end
195
+
196
+ def save
197
+ FileUtils.mkdir_p(@cache_dir)
198
+ data = { "digest" => @digest, "failed_test_files" => @failed_test_files, "map" => @map }
199
+ tmp = "#{cache_path}.tmp"
200
+ File.write(tmp, JSON.generate(data))
201
+ File.rename(tmp, cache_path) # atomic swap
202
+ end
203
+
204
+ def warn_incomplete
205
+ warn "[mutineer] cached coverage map may be incomplete; these test files " \
206
+ "failed to contribute: #{@failed_test_files.join(', ')}"
207
+ end
208
+
209
+ def abs_source_paths = @source_paths.map { |p| absolute(p) }
210
+ def abs_load_paths = @load_paths.map { |p| absolute(p) }
211
+
212
+ def relativize(path)
213
+ return path unless path.start_with?("/")
214
+
215
+ path.delete_prefix("#{@project_root}/")
216
+ end
217
+
218
+ def absolute(path)
219
+ File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require_relative "result"
5
+ require_relative "parser"
6
+
7
+ module Mutineer
8
+ # Fork-based isolation for running one mutant. The block runs in a child
9
+ # process; the parent enforces a wall-clock timeout and decodes the child's
10
+ # exit status into a Result.
11
+ #
12
+ # Exit-status contract (the block's return value, or an explicit exit, is the
13
+ # child's status): 0 => survived, 1 => killed, 2 => error. Timeout is detected
14
+ # by the parent's monitor flag, not by status.signaled? (which is true for ANY
15
+ # signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart from the OS's).
16
+ #
17
+ # mutineer: the 7a strategy this enables (whole-file `load`) re-executes the
18
+ # entire file — any top-level code runs again. Acceptable for POROs; document
19
+ # if users hit issues with initializers/callbacks. Upgrade path: M5 strategy
20
+ # 7b (class_eval surgical redefinition).
21
+ class Isolation
22
+ DEFAULT_TIMEOUT = 10 # seconds
23
+
24
+ # Runs the block in a forked child. The block's return value (an Integer
25
+ # exit code) or any explicit `exit` is honoured; an unhandled exception
26
+ # becomes exit 2 with the cause written to STDERR.
27
+ def self.run(timeout: DEFAULT_TIMEOUT)
28
+ pid = fork do
29
+ code = 0
30
+ begin
31
+ result = yield
32
+ code = result.is_a?(Integer) ? result : 0
33
+ rescue SystemExit => e
34
+ code = e.status
35
+ rescue Exception => e # rubocop:disable Lint/RescueException
36
+ warn "[mutineer-child] #{e.class}: #{e.message}"
37
+ code = 2
38
+ end
39
+ $stderr.flush
40
+ # exit! skips at_exit handlers — critical, since a child forked from
41
+ # inside our own Minitest suite would otherwise re-run the parent's
42
+ # at_exit autorun hook on the way out.
43
+ exit!(code)
44
+ end
45
+
46
+ # Single-threaded deadline poll (R2): we are the ONLY caller of waitpid on
47
+ # this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG shows
48
+ # the child is still alive past the deadline — so the kill can never hit a
49
+ # reaped/recycled pid. Timeout is a parent-side fact (deadline reached), not
50
+ # status.signaled? (which is true for ANY signal death, e.g. SIGSEGV).
51
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
52
+ loop do
53
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
54
+ return decode(status) if reaped
55
+
56
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
57
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
58
+ Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
59
+ return Result.timeout
60
+ end
61
+ sleep 0.005
62
+ end
63
+ end
64
+
65
+ # Strategy 7a (default): write the whole mutated file and `load` it, which
66
+ # reopens its classes and redefines every method in place. Re-runs file-level
67
+ # side effects. Child-only — mutates the loaded program.
68
+ #
69
+ # The tempfile is created in the ORIGINAL file's directory, not the system
70
+ # temp dir, so any `require_relative` in the mutated source resolves against
71
+ # its real neighbours (e.g. a mutator's `require_relative "base"`). Writing it
72
+ # elsewhere makes those requires resolve to the temp dir and raise LoadError.
73
+ def self.apply_whole_file(mutated, source_file)
74
+ Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f|
75
+ f.write(mutated)
76
+ f.flush
77
+ load f.path
78
+ end
79
+ end
80
+
81
+ # Strategy 7b: extract just the enclosing DefNode, apply the mutation to that
82
+ # snippet, resolve the owner via the namespace path, and class_eval the one
83
+ # method (KTD6). No file-level side effects re-run. Child-only.
84
+ #
85
+ # ponytail: a single owner.class_eval handles BOTH instance and singleton
86
+ # methods — the extracted snippet keeps its own `def self.x` for singletons,
87
+ # so class_eval (self == owner) redefines it correctly. Routing singletons
88
+ # through singleton_class.class_eval (R17) would double-wrap and define on the
89
+ # wrong class.
90
+ def self.apply_surgical(mutation, subject, source)
91
+ loc = subject.def_node.location
92
+ def_start = loc.start_offset
93
+ # Byte slicing (C1): Prism offsets are byte offsets.
94
+ snippet = source.byteslice(def_start...loc.end_offset)
95
+ rel_s = mutation.start_offset - def_start
96
+ rel_e = mutation.end_offset - def_start
97
+ mutated_def = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..)
98
+
99
+ # Rebuild the FULL namespace nesting textually so unqualified enclosing-
100
+ # namespace constants resolve exactly as a whole-file `load` (7a) would.
101
+ # class_eval(string) would collapse Module.nesting to [owner] and raise
102
+ # NameError on such constants (C2 scope-collapse).
103
+ keywords = nesting_keywords(subject.namespace)
104
+ prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
105
+ prefix += "\n" unless prefix.empty?
106
+ wrapped = "#{prefix}#{mutated_def}#{"\nend" * keywords.size}"
107
+
108
+ # A snippet that fails to reparse must NOT silently fall through to running
109
+ # the ORIGINAL method (C2 false-survived). Raise -> the fork block aborts
110
+ # before any test runs -> Result.error, never a bogus `survived`.
111
+ raise "surgical snippet failed to reparse" if Parser.parse_string(wrapped).errors.any?
112
+
113
+ # Preserve original visibility — class/module bodies define methods public,
114
+ # but 7a's `load` would re-apply the file's private/protected (C2).
115
+ owner = subject.namespace.empty? ? Object : Object.const_get(subject.namespace.join("::"))
116
+ target = subject.singleton ? owner.singleton_class : owner
117
+ vis = method_visibility(target, subject.name)
118
+
119
+ # Byte-correct line number; eval at top level so the textual class/module
120
+ # wrappers rebuild Module.nesting. Offset the lineno by the wrapper prefix
121
+ # so the def lands on its real source line.
122
+ def_line = source.byteslice(0, def_start).count("\n") + 1
123
+ eval_line = [def_line - prefix.count("\n"), 1].max
124
+ eval(wrapped, TOPLEVEL_BINDING, subject.file, eval_line) # rubocop:disable Security/Eval
125
+
126
+ target.send(vis, subject.name) if vis && vis != :public
127
+ end
128
+
129
+ # Resolve each segment of the namespace to its live Module and pick the
130
+ # correct keyword (reopening a class with `module` — or vice versa — raises
131
+ # TypeError), so the textual wrapper matches the real definitions.
132
+ def self.nesting_keywords(namespace)
133
+ mod = Object
134
+ namespace.flat_map { |n| n.split("::") }.map do |name|
135
+ mod = mod.const_get(name)
136
+ [mod.is_a?(Class) ? "class" : "module", name]
137
+ end
138
+ end
139
+
140
+ def self.method_visibility(mod, name)
141
+ return :private if mod.private_method_defined?(name)
142
+ return :protected if mod.protected_method_defined?(name)
143
+ return :public if mod.public_method_defined?(name)
144
+
145
+ nil
146
+ end
147
+
148
+ def self.decode(status)
149
+ case status.exitstatus
150
+ when 0 then Result.survived
151
+ when 1 then Result.killed
152
+ when 2 then Result.error("child exited with status 2")
153
+ else Result.error("unexpected exit status: #{status.exitstatus.inspect}")
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "stringio"
5
+
6
+ module Mutineer
7
+ # Child-process-only: loads a test file in the current process and runs it
8
+ # programmatically, returning an exit status integer (0 = all passed,
9
+ # 1 = any failure/error).
10
+ #
11
+ # Never call this in the parent — it manipulates global Minitest state
12
+ # (autorun, runnables) that only makes sense in a throwaway forked child.
13
+ #
14
+ # No `rescue` here: Isolation.run's fork block is the single exception
15
+ # boundary (any exception there becomes exit 2). Adding a rescue would create
16
+ # a second exit-2 path and break this method's 0/1 return contract.
17
+ class MinitestIntegration
18
+ # ponytail: tested via runner_test.rb (U6), not in isolation — a direct
19
+ # unit test would require forking and duplicate isolation_test's coverage.
20
+ #
21
+ # `test_files` is one path or an Array of paths (M3 coverage selection passes
22
+ # the covering subset); each is loaded before the single Minitest.run.
23
+ def self.run(test_files)
24
+ # Neutralise autorun so a test file's `require "minitest/autorun"`
25
+ # registers no at_exit hook.
26
+ def Minitest.autorun; end # rubocop:disable Lint/NestedMethodDefinition
27
+
28
+ # Drop runnables inherited from the parent suite (this is the child's
29
+ # private copy — the parent is unaffected) so only the target test runs.
30
+ Minitest::Runnable.reset
31
+
32
+ Array(test_files).each { |f| load f }
33
+
34
+ # Silence the child's test output; the parent only cares about pass/fail.
35
+ orig = $stdout
36
+ $stdout = StringIO.new
37
+ passed = Minitest.run([])
38
+ $stdout = orig
39
+
40
+ passed ? 0 : 1
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+
5
+ module Mutineer
6
+ # One atomic byte-range edit. Immutable. One mutation per mutant — never
7
+ # combine. Source is mutated textually, never regenerated from the AST.
8
+ Mutation = Data.define(:start_offset, :end_offset, :replacement, :operator) do
9
+ # Pure: returns a new string, does not mutate `source`. Prism offsets are
10
+ # BYTE offsets, so all slicing is byte-based (byteslice) — char slicing would
11
+ # corrupt any source containing a multibyte char before the mutation point.
12
+ def apply(source)
13
+ source.byteslice(0, start_offset) + replacement + source.byteslice(end_offset..)
14
+ end
15
+
16
+ # Validity rule: a mutation is valid iff the mutated source re-parses clean.
17
+ def valid?(source)
18
+ Parser.parse_string(apply(source)).errors.empty?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mutators/arithmetic"
4
+ require_relative "mutators/comparison"
5
+ require_relative "mutators/boolean_connector"
6
+ require_relative "mutators/boolean_literal"
7
+ require_relative "mutators/statement_removal"
8
+ require_relative "mutators/return_nil"
9
+ require_relative "mutators/literal_mutation"
10
+ require_relative "mutators/condition_negation"
11
+
12
+ module Mutineer
13
+ # Maps operator name -> operator class. DEFAULT_NAMES is the v1 default set
14
+ # (the M4 Tier-1 + statement-removal operators per locked decision #2). The
15
+ # three Tier-2 operators live in ALL but are OFF by default — they only run
16
+ # when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
17
+ # DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
18
+ # survivor set unchanged.
19
+ class MutatorRegistry
20
+ ALL = {
21
+ "arithmetic" => Mutators::Arithmetic,
22
+ "comparison" => Mutators::Comparison,
23
+ "boolean_connector" => Mutators::BooleanConnector,
24
+ "boolean_literal" => Mutators::BooleanLiteral,
25
+ "statement_removal" => Mutators::StatementRemoval,
26
+ "return_nil" => Mutators::ReturnNil,
27
+ "literal_mutation" => Mutators::LiteralMutation,
28
+ "condition_negation" => Mutators::ConditionNegation
29
+ }.freeze
30
+
31
+ DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
32
+ TIER2_NAMES = %w[return_nil literal_mutation condition_negation].freeze
33
+
34
+ DESCRIPTIONS = {
35
+ "arithmetic" => "+ <-> -, * <-> /, % -> *, ** -> *",
36
+ "comparison" => "< <-> <=, > <-> >=, == <-> !=",
37
+ "boolean_connector" => "&& <-> ||",
38
+ "boolean_literal" => "true <-> false, nil -> true",
39
+ "statement_removal" => "replace a non-final statement with nil",
40
+ "return_nil" => "replace a return / final expression with nil",
41
+ "literal_mutation" => "integer -> 0, 1, n+1; string -> empty",
42
+ "condition_negation" => "wrap if/unless/ternary condition in !( ... )"
43
+ }.freeze
44
+
45
+ # Returns the operator classes for the given names. Unknown names raise
46
+ # ArgumentError immediately (caught at the CLI boundary -> exit 2).
47
+ def self.resolve(names = DEFAULT_NAMES)
48
+ names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
49
+ end
50
+
51
+ def self.default?(name) = DEFAULT_NAMES.include?(name)
52
+ def self.tier(name) = TIER2_NAMES.include?(name) ? 2 : 1
53
+ end
54
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Arithmetic operator: +<->-, *<->/, %->*, **->*. One mutation per
8
+ # occurrence, rewriting the operator token (CallNode#message_loc).
9
+ class Arithmetic < Base
10
+ REPLACEMENTS = {
11
+ :+ => "-", :- => "+", :* => "/", :/ => "*", :% => "*", :** => "*"
12
+ }.freeze
13
+
14
+ def visit_call_node(node)
15
+ replacement = REPLACEMENTS[node.name]
16
+ loc = node.message_loc
17
+ if replacement && loc
18
+ @mutations << Mutation.new(
19
+ start_offset: loc.start_offset,
20
+ end_offset: loc.end_offset,
21
+ replacement: replacement,
22
+ operator: :arithmetic
23
+ )
24
+ end
25
+ super # nested calls (e.g. a + (b * c)) each get their own mutation
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../mutation"
5
+
6
+ module Mutineer
7
+ module Mutators
8
+ # Base Prism visitor for operators. Subclasses override visit_* methods to
9
+ # push Mutation objects onto @mutations. Visiting only def_node.body is the
10
+ # body-only enforcement — the def signature line is never touched.
11
+ #
12
+ # ponytail: one implementor in M1; Base earns its keep at M4 when
13
+ # comparison/boolean operators land and share this contract.
14
+ class Base < Prism::Visitor
15
+ def mutations_for(subject, source)
16
+ @source = source
17
+ @mutations = []
18
+ subject.def_node.body&.accept(self)
19
+ @mutations
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Boolean connector operator: && <-> ||, and <-> or. Replacement is derived
8
+ # from the actual source token (operator_loc.slice) so symbolic and keyword
9
+ # forms each map to their own form — never crossing && to `or`, which would
10
+ # change precedence and surprise the reader (KTD-2).
11
+ #
12
+ # Clean-room: from the spec's operator table, not the mutant gem.
13
+ class BooleanConnector < Base
14
+ REPLACEMENTS = { "&&" => "||", "||" => "&&", "and" => "or", "or" => "and" }.freeze
15
+
16
+ def visit_and_node(node)
17
+ emit(node)
18
+ super
19
+ end
20
+
21
+ def visit_or_node(node)
22
+ emit(node)
23
+ super
24
+ end
25
+
26
+ private
27
+
28
+ def emit(node)
29
+ loc = node.operator_loc
30
+ replacement = REPLACEMENTS[loc.slice]
31
+ return unless replacement
32
+
33
+ @mutations << Mutation.new(
34
+ start_offset: loc.start_offset,
35
+ end_offset: loc.end_offset,
36
+ replacement: replacement,
37
+ operator: :boolean_connector
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Mutates true/false AND nil literals — "boolean_literal" is the spec's name
8
+ # for the family (§4), so nil is in-scope by design even though it is not
9
+ # strictly a boolean. true<->false, and nil->true (nil->true catches more
10
+ # return-value gaps than nil->false). Rewrites the whole node location;
11
+ # these nodes have no sub-token location.
12
+ #
13
+ # Clean-room: from the spec's operator table, not the mutant gem.
14
+ class BooleanLiteral < Base
15
+ def visit_true_node(node)
16
+ emit(node, "false")
17
+ super
18
+ end
19
+
20
+ def visit_false_node(node)
21
+ emit(node, "true")
22
+ super
23
+ end
24
+
25
+ def visit_nil_node(node)
26
+ emit(node, "true")
27
+ super
28
+ end
29
+
30
+ private
31
+
32
+ def emit(node, replacement)
33
+ loc = node.location
34
+ @mutations << Mutation.new(
35
+ start_offset: loc.start_offset,
36
+ end_offset: loc.end_offset,
37
+ replacement: replacement,
38
+ operator: :boolean_literal
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Comparison / boundary operator: <->-<=, >->-=>, ==->!=, etc. The single
8
+ # highest-value Tier-1 family (spec §4) — exposes off-by-one and boundary
9
+ # gaps line coverage never catches. Rewrites the operator token
10
+ # (CallNode#message_loc), one mutation per occurrence.
11
+ #
12
+ # Clean-room: implemented from the spec's operator table and public
13
+ # mutation-testing literature, not the mutant gem.
14
+ class Comparison < Base
15
+ REPLACEMENTS = {
16
+ :< => "<=", :<= => "<", :> => ">=", :>= => ">", :== => "!=", :!= => "=="
17
+ }.freeze
18
+
19
+ def visit_call_node(node)
20
+ replacement = REPLACEMENTS[node.name]
21
+ loc = node.message_loc
22
+ # receiver guard: reject any unary call accidentally named like an
23
+ # operator; binary comparisons always have a receiver.
24
+ if replacement && loc && node.receiver
25
+ @mutations << Mutation.new(
26
+ start_offset: loc.start_offset,
27
+ end_offset: loc.end_offset,
28
+ replacement: replacement,
29
+ operator: :comparison
30
+ )
31
+ end
32
+ super # nested comparisons (a >= b && c <= d) each get their own mutation
33
+ end
34
+ end
35
+ end
36
+ end