Class: Mutineer::Runner
- Inherits:
-
Object
- Object
- Mutineer::Runner
- Defined in:
- lib/mutineer/runner.rb
Overview
Orchestrates one mutation end-to-end: apply it textually, validate the
result, select its covering test files from the coverage map, then run only
those against the mutated source in an isolated child process (strategy 7a —
whole-file reload via load).
The source file path is passed explicitly because Mutation carries only byte
offsets, not its file. M3 replaces M2's hardcoded test_file: with coverage-
map selection: a mutation whose line no test exercises is :no_coverage (no
fork); otherwise exactly the covering test files run in the child.
Class Method Summary collapse
-
.ensure_rails_env(config) ⇒ Object
#7: when --rails is on and RAILS_ENV is unset, default it to "test" (and say so) before the app boots — otherwise it boots development and nothing is measured.
-
.execute(config) ⇒ Object
Full Phase B orchestration: resolve operators, discover subjects, build the coverage map, run every mutation, and aggregate.
-
.filter_since(jobs, source_map, config) ⇒ Object
--since: keep only jobs whose mutation lands on a line changed since the git ref.
- .run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload", timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest") ⇒ Object
-
.suppress_map(source) ⇒ Object
Scan a source once into { line_number => :all | Set } from inline
# mutineer:disable-line [ops]markers (RuboCop semantics: the marker sits on the same physical line as the code it silences). -
.suppressed?(operator, line, id, disabled, ignore_set) ⇒ Boolean
True when this mutant is suppressed: its line bears a disable-line marker (bare, or scoped to its operator), OR its stable id is in the config ignore list.
- .sweep_orphans(dirs) ⇒ Object
-
.test_load_roots(test_files) ⇒ Object
For each test file, the directory to add to $LOAD_PATH so its
require "test_helper"(or spec_helper) resolves: the nearest ancestor holding that helper, plus the file's own dir as a fallback.
Class Method Details
.ensure_rails_env(config) ⇒ Object
#7: when --rails is on and RAILS_ENV is unset, default it to "test" (and say so) before the app boots — otherwise it boots development and nothing is measured. An explicitly-set RAILS_ENV is always respected.
210 211 212 213 214 215 216 |
# File 'lib/mutineer/runner.rb', line 210 def self.ensure_rails_env(config) return unless config.rails return unless ENV["RAILS_ENV"].nil? || ENV["RAILS_ENV"].empty? ENV["RAILS_ENV"] = "test" warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails." end |
.execute(config) ⇒ Object
Full Phase B orchestration: resolve operators, discover subjects, build the coverage map, run every mutation, and aggregate. Returns [AggregateResult, source_map]. The CLI then reports + applies the exit code; the integration test asserts directly on the AggregateResult.
The parent process requires each source file so its classes exist; forked
children inherit them, so a covering test file's own require_relative of the
source is a no-op and does not clobber the mutated load (spec §7).
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/mutineer/runner.rb', line 35 def self.execute(config) operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES) # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted # in the parent and inherited by every fork. Do NOT manually require the # sources — under Zeitwerk a manual require of an autoloadable file raises; # the booted env autoloads them, and subject discovery is a static Prism # parse that needs nothing loaded. Standalone mode requires the sources as # before so their classes exist for the children to inherit. if config.boot # #7: under --rails an unset RAILS_ENV boots development, where the test # suite isn't loaded — coverage comes back empty and EVERY mutant is # falsely reported no_coverage (score N/A, exit 0). Default it to test. ensure_rails_env(config) # Coverage instruments only files loaded AFTER it starts. Start it BEFORE # the boot require so the entire app loaded during boot is instrumented; # forked children then measure each test's coverage delta against it. require "coverage" Coverage.start(lines: true) unless Coverage.running? require File.(config.boot, config.project_root) else config.sources.each { |f| require File.(f, config.project_root) } end config.require_paths.each { |f| require File.(f, config.project_root) } if config.boot # Rails/Minitest test files do `require "test_helper"`, which needs the # test root on $LOAD_PATH (`bin/rails test` adds it). Prepend each test # file's helper root here in the parent so loading them in the fork # children (both coverage capture and per-mutant) resolves. boot_tests = config.tests.map { |t| File.(t, config.project_root) } test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) } # Boot mode now uses coverage selection too: capture each test's coverage # by forking the booted parent, then select covering tests per mutant. coverage_map = CoverageMap.new( source_paths: config.sources, test_paths: config.tests, cache_dir: config.cache_dir, project_root: config.project_root, load_paths: config.load_paths, framework: config.framework, boot_path: File.(config.boot, config.project_root), verbose: config.verbose ).build_via_fork(rails: config.rails) else coverage_map = CoverageMap.new( source_paths: config.sources, test_paths: config.tests, cache_dir: config.cache_dir, project_root: config.project_root, load_paths: config.load_paths, framework: config.framework ).build_or_load end # Collect every (subject, mutation) up front so the pool can fan them out. # #10: a mutant the user marked known-equivalent (inline disable-line comment # or .mutineer.yml ignore id) is classified :ignored here and NEVER forked — # it is removed from the killed+survived denominator so a strong file reaches # 100%. The stable id is computed per subject (occurrence needs the full list) # and carried on every job so the parent can reattach it after the run. source_map = {} disabled_map = {} ignore_set = config.ignore.to_set jobs = [] ignored_results = [] Project.discover(config.sources, only: config.only).each do |subject| source = (source_map[subject.file] ||= File.read(subject.file)) disabled = (disabled_map[subject.file] ||= suppress_map(source)) mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) } ids = MutantId.for_subject(subject, source, mutations) mutations.each_with_index do |mutation, i| id = ids[i] line = source.byteslice(0, mutation.start_offset).count("\n") + 1 if suppressed?(mutation.operator, line, id, disabled, ignore_set) ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id) else jobs << [subject, mutation, id] end end end jobs = filter_since(jobs, source_map, config) if config.since # C3: 7a writes mutineer_mutant*.rb into each source dir (so require_relative # resolves). A SIGKILL'd child skips the tempfile's ensure-unlink, orphaning # it. `ensure` is unreliable vs SIGKILL, so the PARENT sweeps each source dir # before and after the run — orphans are impossible after a normal run. source_dirs = config.sources .map { |f| File.dirname(File.(f, config.project_root)) }.uniq sweep_orphans(source_dirs) strategy = config.strategy results = begin framework = config.framework = WorkerPool.new(config.jobs).run(jobs) do |subject, mutation| run(mutation, source_file: subject.file, coverage_map: coverage_map, subject: subject, strategy: strategy, rails: config.rails, framework: framework) end # The bare Results carry only status (Subjects hold live AST nodes that # do not marshal); reattach subject+mutation+id in the parent, in order. .each_with_index.map { |r, i| r.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) } ensure sweep_orphans(source_dirs) end [AggregateResult.new(results + ignored_results), source_map] end |
.filter_since(jobs, source_map, config) ⇒ Object
--since: keep only jobs whose mutation lands on a line changed since the git ref. Composes with coverage selection (it only narrows the job list; each surviving mutant still goes through Runner.run's coverage check). A file with no changed lines (absent from the diff) contributes no jobs. Line is computed exactly as Runner.run does, from the already-read source in source_map.
175 176 177 178 179 180 181 182 183 184 |
# File 'lib/mutineer/runner.rb', line 175 def self.filter_since(jobs, source_map, config) changed = ChangedLines.for(ref: config.since, files: config.sources, project_root: config.project_root) jobs.select do |subject, mutation| source = source_map[subject.file] line = source.byteslice(0, mutation.start_offset).count("\n") + 1 abs = File.(subject.file, config.project_root) changed.fetch(abs, []).include?(line) end end |
.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload", timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest") ⇒ Object
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/mutineer/runner.rb', line 226 def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload", timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest") source = File.read(source_file) mutated = mutation.apply(source) # Validity rule: a mutant that doesn't re-parse is skipped before forking. return Result.skipped if Parser.parse_string(mutated).errors.any? # Coverage selection (both standalone and boot mode): a mutation on a line # no test exercises is :no_coverage (no fork); otherwise exactly the # covering test files run in the child. line = source.byteslice(0, mutation.start_offset).count("\n") + 1 chosen = coverage_map.tests_for(source_file, line) # #9: distinguish a genuine coverage gap from a line whose would-be test # errored during capture (coverage lost) — the latter is :uncapturable. if chosen.empty? return coverage_map.uncapturable_source?(source_file) ? Result.uncapturable : Result.no_coverage end abs_tests = chosen.map { |t| File.(t, coverage_map.project_root) } Isolation.run(timeout: timeout) do # Forking inherits the parent's live DB connection; sharing one socket # across processes corrupts it. Drop it so AR reconnects per child. reconnect_active_record if rails if strategy == "redefine" Isolation.apply_surgical(mutation, subject, source) else Isolation.apply_whole_file(mutated, source_file) end TestRunners.for(framework).run(abs_tests) end end |
.suppress_map(source) ⇒ Object
Scan a source once into { line_number => :all | Set } from
inline # mutineer:disable-line [ops] markers (RuboCop semantics: the marker
sits on the same physical line as the code it silences). A bare marker
disables every operator on that line; disable-line a, b only the listed
operators. Block-form disable/enable ranges are intentionally not supported.
146 147 148 149 150 151 152 153 154 155 |
# File 'lib/mutineer/runner.rb', line 146 def self.suppress_map(source) map = {} source.each_line.with_index(1) do |text, line| next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/)) ops = m[1] map[line] = ops ? ops.split(",").map { |o| o.strip.to_sym }.reject(&:empty?).to_set : :all end map end |
.suppressed?(operator, line, id, disabled, ignore_set) ⇒ Boolean
True when this mutant is suppressed: its line bears a disable-line marker (bare, or scoped to its operator), OR its stable id is in the config ignore list. Checked at job-build time so a suppressed mutant is never forked.
160 161 162 163 164 165 166 167 168 |
# File 'lib/mutineer/runner.rb', line 160 def self.suppressed?(operator, line, id, disabled, ignore_set) return true if ignore_set.include?(id) case (entry = disabled[line]) when :all then true when Set then entry.include?(operator) else false end end |
.sweep_orphans(dirs) ⇒ Object
218 219 220 221 222 223 224 |
# File 'lib/mutineer/runner.rb', line 218 def self.sweep_orphans(dirs) dirs.each do |dir| Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f| File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier end end end |
.test_load_roots(test_files) ⇒ Object
For each test file, the directory to add to $LOAD_PATH so its
require "test_helper" (or spec_helper) resolves: the nearest ancestor
holding that helper, plus the file's own dir as a fallback.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/mutineer/runner.rb', line 189 def self.test_load_roots(test_files) test_files.flat_map do |f| dir = File.dirname(f) root = nil loop do if File.exist?(File.join(dir, "test_helper.rb")) || File.exist?(File.join(dir, "spec_helper.rb")) root = dir break end parent = File.dirname(dir) break if parent == dir dir = parent end [root, File.dirname(f)].compact end.uniq end |