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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+ require_relative "project"
5
+ require_relative "result"
6
+ require_relative "isolation"
7
+ require_relative "minitest_integration"
8
+ require_relative "coverage_map"
9
+ require_relative "mutator_registry"
10
+ require_relative "worker_pool"
11
+
12
+ module Mutineer
13
+ # Orchestrates one mutation end-to-end: apply it textually, validate the
14
+ # result, select its covering test files from the coverage map, then run only
15
+ # those against the mutated source in an isolated child process (strategy 7a —
16
+ # whole-file reload via `load`).
17
+ #
18
+ # The source file path is passed explicitly because Mutation carries only byte
19
+ # offsets, not its file. M3 replaces M2's hardcoded `test_file:` with coverage-
20
+ # map selection: a mutation whose line no test exercises is :no_coverage (no
21
+ # fork); otherwise exactly the covering test files run in the child.
22
+ class Runner
23
+ # Full Phase B orchestration: resolve operators, discover subjects, build the
24
+ # coverage map, run every mutation, and aggregate. Returns
25
+ # [AggregateResult, source_map]. The CLI then reports + applies the exit code;
26
+ # the integration test asserts directly on the AggregateResult.
27
+ #
28
+ # The parent process `require`s each source file so its classes exist; forked
29
+ # children inherit them, so a covering test file's own require_relative of the
30
+ # source is a no-op and does not clobber the mutated `load` (spec §7).
31
+ def self.execute(config)
32
+ operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
33
+
34
+ # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
35
+ # in the parent and inherited by every fork. Do NOT manually require the
36
+ # sources — under Zeitwerk a manual require of an autoloadable file raises;
37
+ # the booted env autoloads them, and subject discovery is a static Prism
38
+ # parse that needs nothing loaded. Standalone mode requires the sources as
39
+ # before so their classes exist for the children to inherit.
40
+ if config.boot
41
+ require File.expand_path(config.boot, config.project_root)
42
+ else
43
+ config.sources.each { |f| require File.expand_path(f, config.project_root) }
44
+ end
45
+ config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
46
+
47
+ # Boot mode skips coverage selection entirely: every mutant runs the given
48
+ # --test files (precomputed absolute here, used directly by Runner.run).
49
+ if config.boot
50
+ coverage_map = nil
51
+ boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
52
+ # Rails/Minitest test files do `require "test_helper"`, which needs the
53
+ # test root on $LOAD_PATH (`bin/rails test` adds it). Prepend each test
54
+ # file's helper root here in the parent so loading them in the fork
55
+ # children resolves. Inherited by every fork.
56
+ test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
57
+ else
58
+ coverage_map = CoverageMap.new(
59
+ source_paths: config.sources, test_paths: config.tests,
60
+ cache_dir: config.cache_dir, project_root: config.project_root,
61
+ load_paths: config.load_paths
62
+ ).build_or_load
63
+ boot_tests = nil
64
+ end
65
+
66
+ # Collect every (subject, mutation) up front so the pool can fan them out.
67
+ source_map = {}
68
+ jobs = []
69
+ Project.discover(config.sources, only: config.only).each do |subject|
70
+ source = (source_map[subject.file] ||= File.read(subject.file))
71
+ operator_classes.each do |klass|
72
+ klass.new.mutations_for(subject, source).each do |mutation|
73
+ jobs << [subject, mutation]
74
+ end
75
+ end
76
+ end
77
+
78
+ # C3: 7a writes mutineer_mutant*.rb into each source dir (so require_relative
79
+ # resolves). A SIGKILL'd child skips the tempfile's ensure-unlink, orphaning
80
+ # it. `ensure` is unreliable vs SIGKILL, so the PARENT sweeps each source dir
81
+ # before and after the run — orphans are impossible after a normal run.
82
+ source_dirs = config.sources
83
+ .map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
84
+ sweep_orphans(source_dirs)
85
+
86
+ strategy = config.strategy
87
+ results =
88
+ begin
89
+ bare = WorkerPool.new(config.jobs).run(jobs) do |subject, mutation|
90
+ run(mutation, source_file: subject.file, coverage_map: coverage_map,
91
+ subject: subject, strategy: strategy,
92
+ test_files: boot_tests, rails: config.rails)
93
+ end
94
+ # The bare Results carry only status (Subjects hold live AST nodes that
95
+ # do not marshal); reattach subject+mutation in the parent, in order.
96
+ bare.each_with_index.map { |r, i| r.with(subject: jobs[i][0], mutation: jobs[i][1]) }
97
+ ensure
98
+ sweep_orphans(source_dirs)
99
+ end
100
+
101
+ [AggregateResult.new(results), source_map]
102
+ end
103
+
104
+ # For each test file, the directory to add to $LOAD_PATH so its
105
+ # `require "test_helper"` (or spec_helper) resolves: the nearest ancestor
106
+ # holding that helper, plus the file's own dir as a fallback.
107
+ def self.test_load_roots(test_files)
108
+ test_files.flat_map do |f|
109
+ dir = File.dirname(f)
110
+ root = nil
111
+ loop do
112
+ if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb"))
113
+ root = dir
114
+ break
115
+ end
116
+ parent = File.dirname(dir)
117
+ break if parent == dir
118
+
119
+ dir = parent
120
+ end
121
+ [root, File.dirname(f)].compact
122
+ end.uniq
123
+ end
124
+
125
+ def self.sweep_orphans(dirs)
126
+ dirs.each do |dir|
127
+ Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f|
128
+ File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
129
+ end
130
+ end
131
+ end
132
+
133
+ def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
134
+ timeout: Isolation::DEFAULT_TIMEOUT, test_files: nil, rails: false)
135
+ source = File.read(source_file)
136
+ mutated = mutation.apply(source)
137
+
138
+ # Validity rule: a mutant that doesn't re-parse is skipped before forking.
139
+ return Result.skipped if Parser.parse_string(mutated).errors.any?
140
+
141
+ # Boot mode passes its tests directly (already absolute); standalone mode
142
+ # selects covering tests from the map and is :no_coverage when none cover it.
143
+ if test_files
144
+ abs_tests = test_files
145
+ else
146
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
147
+ chosen = coverage_map.tests_for(source_file, line)
148
+ return Result.no_coverage if chosen.empty?
149
+
150
+ abs_tests = chosen.map { |t| File.expand_path(t, coverage_map.project_root) }
151
+ end
152
+
153
+ Isolation.run(timeout: timeout) do
154
+ # Forking inherits the parent's live DB connection; sharing one socket
155
+ # across processes corrupts it. Drop it so AR reconnects per child.
156
+ reconnect_active_record if rails
157
+ if strategy == "redefine"
158
+ Isolation.apply_surgical(mutation, subject, source)
159
+ else
160
+ Isolation.apply_whole_file(mutated, source_file)
161
+ end
162
+ MinitestIntegration.run(abs_tests)
163
+ end
164
+ end
165
+
166
+ def self.reconnect_active_record
167
+ return unless defined?(ActiveRecord::Base)
168
+
169
+ ActiveRecord::Base.connection_handler.clear_all_connections!
170
+ rescue StandardError
171
+ nil
172
+ end
173
+ private_class_method :reconnect_active_record
174
+ end
175
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # One discoverable method: its location, namespace context, and the live
5
+ # Prism::DefNode mutators walk. Struct (not Data) because def_node is a live
6
+ # AST node — value-equality would be hollow, so we don't promise it.
7
+ Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, keyword_init: true) do
8
+ # e.g. "Billing::Invoice#total", "Billing::Invoice.build".
9
+ # Top-level (empty namespace) -> "#name" (no :: prefix).
10
+ def qualified_name
11
+ namespace.join("::") + (singleton ? "." : "#") + name.to_s
12
+ end
13
+
14
+ # nil for empty methods (def empty; end).
15
+ def body_loc
16
+ def_node.body&.location
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module Mutineer
6
+ # Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at once;
7
+ # each child runs the block on one work item, marshals its Result to a private
8
+ # pipe, and exits. The parent reaps any finished child with Process.wait2(-1),
9
+ # opening exactly one slot per reap, then refills. Results are returned in the
10
+ # SAME ORDER as `items` regardless of finish order, so verdicts are identical to
11
+ # a serial run (R4) and downstream output is stable.
12
+ #
13
+ # The block is evaluated inside the child via `yield(*items[i])`; whatever it
14
+ # returns (a Result) is the marshaled payload. Per-mutant timeout is handled one
15
+ # level down by Isolation (KTD2) — the pool adds no separate wall clock.
16
+ class WorkerPool
17
+ def initialize(size)
18
+ @size = [size.to_i, 1].max
19
+ end
20
+
21
+ def run(items)
22
+ results = Array.new(items.size)
23
+ queue = (0...items.size).to_a
24
+ running = {} # pid => [index, read_io]
25
+
26
+ until queue.empty? && running.empty?
27
+ fill(items, queue, running) { |*args| yield(*args) }
28
+ reap(results, running)
29
+ end
30
+
31
+ results
32
+ end
33
+
34
+ private
35
+
36
+ def fill(items, queue, running)
37
+ while running.size < @size && !queue.empty?
38
+ idx = queue.shift
39
+ rd, wr = IO.pipe
40
+ begin
41
+ pid = fork do
42
+ rd.close
43
+ # R1: the child must ALWAYS hard-exit. If yield raises, marshal an
44
+ # error Result and exit! in `ensure` — otherwise the child unwinds
45
+ # normally and our Minitest at_exit autorun re-runs the parent suite
46
+ # inside the worker, losing the real error.
47
+ payload =
48
+ begin
49
+ yield(*items[idx])
50
+ rescue Exception => e # rubocop:disable Lint/RescueException
51
+ Result.error("worker crashed: #{e.class}: #{e.message}")
52
+ end
53
+ begin
54
+ wr.write(Marshal.dump(payload))
55
+ rescue StandardError # rubocop:disable Lint/SuppressedException
56
+ # pipe gone; parent will record "no result"
57
+ ensure
58
+ wr.close
59
+ exit!(0)
60
+ end
61
+ end
62
+ rescue Errno::EAGAIN
63
+ # Process table is full. Put the item back and reap before retrying;
64
+ # if nothing is running we cannot make progress, so re-raise.
65
+ rd.close
66
+ wr.close
67
+ raise if running.empty?
68
+
69
+ queue.unshift(idx)
70
+ return
71
+ end
72
+ wr.close
73
+ running[pid] = [idx, rd]
74
+ end
75
+ end
76
+
77
+ # R6: reap exactly one of OUR children. wait2(-1) would reap (steal) any of
78
+ # the host process's children — fatal when the pool runs under a forking test
79
+ # suite. Poll our known pids with WNOHANG instead.
80
+ def reap(results, running)
81
+ return if running.empty?
82
+
83
+ loop do
84
+ running.each_key do |pid|
85
+ reaped, = Process.waitpid2(pid, Process::WNOHANG)
86
+ next unless reaped
87
+
88
+ return collect(results, running, pid)
89
+ end
90
+ sleep 0.005
91
+ end
92
+ end
93
+
94
+ def collect(results, running, pid)
95
+ idx, rd = running.delete(pid)
96
+ data = rd.read
97
+ rd.close
98
+ results[idx] =
99
+ if data.empty?
100
+ Result.error("worker produced no result")
101
+ else
102
+ # R6: a partial/garbage Marshal stream (dead worker) must not crash the
103
+ # pool — degrade to an error Result.
104
+ begin
105
+ Marshal.load(data)
106
+ rescue StandardError => e
107
+ Result.error("worker result unreadable: #{e.class}: #{e.message}")
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
data/lib/mutineer.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mutineer/version"
4
+ require_relative "mutineer/config"
5
+ require_relative "mutineer/parser"
6
+ require_relative "mutineer/subject"
7
+ require_relative "mutineer/mutation"
8
+ require_relative "mutineer/project"
9
+ require_relative "mutineer/result"
10
+ require_relative "mutineer/coverage_map"
11
+ require_relative "mutineer/isolation"
12
+ require_relative "mutineer/minitest_integration"
13
+ require_relative "mutineer/mutators/base"
14
+ require_relative "mutineer/mutators/arithmetic"
15
+ require_relative "mutineer/mutators/comparison"
16
+ require_relative "mutineer/mutators/boolean_connector"
17
+ require_relative "mutineer/mutators/boolean_literal"
18
+ require_relative "mutineer/mutators/statement_removal"
19
+ require_relative "mutineer/mutators/return_nil"
20
+ require_relative "mutineer/mutators/literal_mutation"
21
+ require_relative "mutineer/mutators/condition_negation"
22
+ require_relative "mutineer/mutator_registry"
23
+ require_relative "mutineer/worker_pool"
24
+ require_relative "mutineer/runner"
25
+ require_relative "mutineer/reporter"
26
+ require_relative "mutineer/cli"
27
+
28
+ module Mutineer
29
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mutineer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - David Teren
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: Mutineer mutates your source one change at a time and runs your Minitest
41
+ suite against each mutant to find tests that don't actually test anything. Prism-based,
42
+ fork-isolated, zero runtime dependencies.
43
+ email:
44
+ - dteren@gmail.com
45
+ executables:
46
+ - mutineer
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - bin/mutineer
54
+ - lib/mutineer.rb
55
+ - lib/mutineer/cli.rb
56
+ - lib/mutineer/config.rb
57
+ - lib/mutineer/coverage_map.rb
58
+ - lib/mutineer/isolation.rb
59
+ - lib/mutineer/minitest_integration.rb
60
+ - lib/mutineer/mutation.rb
61
+ - lib/mutineer/mutator_registry.rb
62
+ - lib/mutineer/mutators/arithmetic.rb
63
+ - lib/mutineer/mutators/base.rb
64
+ - lib/mutineer/mutators/boolean_connector.rb
65
+ - lib/mutineer/mutators/boolean_literal.rb
66
+ - lib/mutineer/mutators/comparison.rb
67
+ - lib/mutineer/mutators/condition_negation.rb
68
+ - lib/mutineer/mutators/literal_mutation.rb
69
+ - lib/mutineer/mutators/return_nil.rb
70
+ - lib/mutineer/mutators/statement_removal.rb
71
+ - lib/mutineer/parser.rb
72
+ - lib/mutineer/project.rb
73
+ - lib/mutineer/reporter.rb
74
+ - lib/mutineer/result.rb
75
+ - lib/mutineer/runner.rb
76
+ - lib/mutineer/subject.rb
77
+ - lib/mutineer/version.rb
78
+ - lib/mutineer/worker_pool.rb
79
+ homepage: https://github.com/davidteren/mutineer
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ source_code_uri: https://github.com/davidteren/mutineer
84
+ changelog_uri: https://github.com/davidteren/mutineer/blob/main/CHANGELOG.md
85
+ bug_tracker_uri: https://github.com/davidteren/mutineer/issues
86
+ rubygems_mfa_required: 'true'
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '3.4'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.6.9
102
+ specification_version: 4
103
+ summary: A clean-room mutation-testing tool for Ruby (Prism + stdlib only).
104
+ test_files: []