Class: Mutineer::Runner

Inherits:
Object
  • Object
show all
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

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.expand_path(config.boot, config.project_root)
  else
    config.sources.each { |f| require File.expand_path(f, config.project_root) }
  end
  config.require_paths.each { |f| require File.expand_path(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.expand_path(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.expand_path(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.expand_path(f, config.project_root)) }.uniq
  sweep_orphans(source_dirs)

  strategy = config.strategy
  results =
    begin
      framework = config.framework
      bare = 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.
      bare.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.expand_path(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.expand_path(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.

Returns:

  • (Boolean)


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