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.

Constant Summary collapse

DAEMON_TIMEOUT =

Default per-mutant timeout on the daemon path. Generous because 2a runs the full --test set per mutant (no coverage narrowing until Phase 2c).

60

Class Method Summary collapse

Class Method Details

.collect_jobs(config, operator_classes) ⇒ Array(Array, Array<Result>, Hash<String,String>)

Collect every (subject, mutation, id) up front so a backend can run them. #10: a mutant the user marked known-equivalent (inline disable-line comment or .mutineer.yml ignore id) is classified :ignored here and NEVER run — 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. Shared by the in-process and external (#27) backends so job selection can never drift.

Returns:

  • (Array(Array, Array<Result>, Hash<String,String>))

    jobs, ignored, source_map.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/mutineer/runner.rb', line 145

def self.collect_jobs(config, operator_classes)
  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, ignored_results, source_map]
end

.coverage_selection(source_file, mutation, subject, source, coverage_map) ⇒ Array(Symbol, Object)

Coverage-based test selection, shared by the in-process (run) and daemon paths so both narrow identically (score parity, U7/V5). Returns [:run, abs_test_paths] when some test covers the mutant's line, or [:verdict, Result] (no_coverage / uncapturable) when none do.

#9/#25: an empty selection is :uncapturable (not :no_coverage) when the mutant's enclosing method body got coverage from no successful capture but a sibling test failed to capture — the coverage was lost, not absent. Both are excluded from the score denominator, so this distinction is reporting-only and never changes the daemon-vs-in-process score.

Parameters:

  • source_file (String)

    the mutated source file path.

  • mutation (Mutineer::Mutation)

    the mutation (for its line offset).

  • subject (Mutineer::Subject, nil)

    the subject (for its method body range).

  • source (String)

    the original source text.

  • coverage_map (Mutineer::CoverageMap)

    the built/loaded coverage map.

Returns:

  • (Array(Symbol, Object))

    [:run, Array<String>] or [:verdict, Result].



422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/mutineer/runner.rb', line 422

def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
  line   = source.byteslice(0, mutation.start_offset).count("\n") + 1
  chosen = coverage_map.tests_for(source_file, line)
  if chosen.empty?
    # Method BODY range, not the whole def: the def/end lines are "covered" at
    # class-load even when the body never runs (body_loc is the statements' span).
    loc   = subject&.body_loc
    range = loc ? (loc.start_line..loc.end_line) : (line..line)
    return [:verdict, coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage]
  end

  [:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
end

.daemon_boot_config(config, abs_tests, coverage: false) ⇒ Object

The boot config the daemon needs to boot the app once: where to boot, the test load roots (so require "test_helper" resolves in every fork), framework, and whether this is Rails.



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/mutineer/runner.rb', line 443

def self.daemon_boot_config(config, abs_tests, coverage: false)
  {
    project_root: config.project_root,
    boot: File.expand_path(config.boot || "config/environment", config.project_root),
    load_paths: test_load_roots(abs_tests),
    source_dirs: source_dirs(config), # so the daemon can sweep orphan mutant temps
    framework: config.framework,
    rails: config.rails,
    # #26/U5: schema for per-worker DB isolation. Sent when present; the daemon
    # skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
    schema: daemon_schema_path(config),
    # #26/U7: coverage narrowing. Only the short-lived map-building daemon starts
    # Coverage (before boot); worker daemons boot with it OFF (no wasted
    # instrumentation/memory across every mutant fork). `sources`/`tests` are the
    # map-build inputs.
    coverage: coverage,
    sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
    tests: abs_tests
  }
end

.daemon_coverage_map(config, abs_tests) ⇒ Mutineer::CoverageMap?

#26/U7: build the coverage map via a short-lived daemon (boots the app once, captures per-test coverage app-side, ships the map back). Returns a query-only CoverageMap, or nil when the build fails / returns empty — callers then run the full --test set (no narrowing) rather than mis-scoring. The dedicated daemon costs one extra boot; correctness over that boot (ponytail — the map is reused for every mutant).

ponytail: the coverage-build IPC read is unbounded (no wall-clock) — a --test file that infinite-loops during capture wedges this call, mirroring the existing in-process build_via_fork limitation. A capture timeout is a follow-up. When the map comes back empty (all captures failed), this returns nil and the run falls back to the FULL --test set per mutant — so on a total capture failure the daemon score is an upper bound (more testing), diverging from the in-process no_coverage scoring for that degenerate case only; a normal (nonempty) map scores identically.

Parameters:

  • config (Mutineer::Config)

    the run config.

  • abs_tests (Array<String>)

    absolute --test paths.

Returns:



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/mutineer/runner.rb', line 291

def self.daemon_coverage_map(config, abs_tests)
  client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests, coverage: true),
                            app_root: config.project_root).start
  data = begin
    client.coverage
  ensure
    client.quit
  end
  return nil unless data && !(data["map"] || {}).empty?

  CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
                        project_root: config.project_root)
rescue DaemonBootError
  nil
end

.daemon_job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map) ⇒ Mutineer::Result

Build the payload for one job, run it on the given daemon/worker, and attach the subject/mutation/id — the shared body of both daemon paths.

Parameters:

  • job (Array(Mutineer::Subject, Mutineer::Mutation, String))

    the work item.

  • req_id (Integer)

    request id (echoed back for IPC ordering safety).

  • client (Mutineer::DaemonClient)

    the daemon handle to run on.

  • worker (Integer)

    the worker slot (→ worker DB) this daemon routes to.

Returns:



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/mutineer/runner.rb', line 379

def self.daemon_job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
  subject, mutation, id = job
  source  = source_map[subject.file]
  mutated = mutation.apply(source)
  # KTD-8 (carried): skip an invalid mutant tool-side — never ship a payload that
  # would fail to load and read as a false `killed`.
  # #26/U7: narrow to covering tests (shared with the in-process path via
  # coverage_selection, so scores match). :verdict = no_coverage/uncapturable, no
  # fork. No map (build failed) → run the full --test set (fallback, not narrowed).
  sel = coverage_map && coverage_selection(subject.file, mutation, subject, source, coverage_map)
  r =
    if Parser.parse_string(mutated).errors.any?
      Result.skipped
    elsif sel && sel[0] == :verdict
      sel[1]
    else
      verdict = client.request(
        id: req_id, worker: worker, timeout: config.daemon_timeout || DAEMON_TIMEOUT,
        payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
        tests: sel ? sel[1] : abs_tests
      )
      daemon_result(verdict)
    end
  r.with(subject: subject, mutation: mutation, id: id)
end

.daemon_result(verdict) ⇒ Object

Map a daemon verdict string to a Result. The daemon reports the four run-time states it can decide (KTD-5); pre-fork states (skipped/no_coverage/…) are resolved tool-side before a request is ever sent.



479
480
481
482
483
484
485
486
# File 'lib/mutineer/runner.rb', line 479

def self.daemon_result(verdict)
  case verdict
  when "survived" then Result.survived
  when "killed"   then Result.killed
  when "timeout"  then Result.timeout
  else Result.error("daemon verdict: #{verdict}")
  end
end

.daemon_schema_path(config) ⇒ String?

Absolute path to the app's db/schema.rb if it exists, else nil. Used by the daemon to schema-load each fork's isolated worker database (#26/U5). Only schema.rb is supported this pass; structure.sql apps get nil and fall back to whatever the worker DB already holds (Postgres provisioning is U10).

Parameters:

Returns:

  • (String, nil)

    absolute schema path or nil.



471
472
473
474
# File 'lib/mutineer/runner.rb', line 471

def self.daemon_schema_path(config)
  path = File.expand_path("db/schema.rb", config.project_root)
  File.exist?(path) ? path : nil
end

.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.



557
558
559
560
561
562
563
# File 'lib/mutineer/runner.rb', line 557

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) ⇒ Array(Mutineer::AggregateResult, Hash<String, String>)

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).

Parameters:

Returns:



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
# File 'lib/mutineer/runner.rb', line 41

def self.execute(config)
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)

  # #27: the external backend runs the suite as a subprocess in the app's own
  # runtime — it does no in-process boot/require or coverage build, so branch
  # before any of that. The in-process path below is untouched.
  return execute_external(config, operator_classes) if config.test_command

  # #26/#27 Phase 2a: the daemon backend boots the app ONCE in a persistent
  # subprocess under the app's bundle and forks per mutant. Tool-side we only
  # discover jobs + build payloads (Prism), so branch before any in-process boot.
  return execute_daemon(config, operator_classes) if config.daemon

  # 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(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
  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.
  jobs, ignored_results, source_map = collect_jobs(config, operator_classes)

  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.
  dirs = source_dirs(config)
  sweep_orphans(dirs)

  strategy = config.strategy
  results =
    begin
      framework = config.framework
      # #21: --fail-fast stops scheduling new mutants after the first survivor;
      # in-flight workers drain, unscheduled jobs stay nil (dropped below).
      stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
      bare = WorkerPool.new(config.jobs).run(jobs, stop_when: stop_when) 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.
      # filter_map drops nils for jobs --fail-fast left unscheduled.
      bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
    ensure
      sweep_orphans(dirs)
    end

  [AggregateResult.new(results + ignored_results), source_map]
end

.execute_daemon(config, operator_classes) ⇒ Array(Mutineer::AggregateResult, Hash<String,String>)

#26/#27 Phase 2a — daemon backend orchestration (serial). Boots the app ONCE in a persistent subprocess and forks per mutant, restoring the one-boot speed the Phase 1 subprocess path gives up. Tool-side we build the ready-to-load payload (KTD-2/KTD-3: whole-file reload by default — the spike-proven path) and ship it; the daemon needs no Prism/mutineer. Serial in 2a (worker-DB isolation + parallelism is Phase 2b). No coverage narrowing yet (Phase 2c), so every mutant runs the full --test set.

Returns:



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/mutineer/runner.rb', line 241

def self.execute_daemon(config, operator_classes)
  jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
  jobs = filter_since(jobs, source_map, config) if config.since
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }

  # #26/U7: build the coverage map once (app-side, via a short-lived daemon) so
  # each mutant runs ONLY its covering tests and a mutant on an uncovered line is
  # no_coverage. nil when the build fails — the runners fall back to the full
  # --test set (no narrowing) rather than mis-scoring everything as no_coverage.
  coverage_map = daemon_coverage_map(config, abs_tests)

  # #26/U6: worker count = resolved --jobs, capped at the job count (no idle
  # daemons). >1 → N concurrent daemon handles, each on its OWN worker DB (V6:
  # N-handles, the spike-proven shape). 1 → the serial single-daemon path.
  # --fail-fast forces serial: parallel's stop flag fires on the first survivor
  # by WALL-CLOCK, not input index, so the verdict set would diverge from serial
  # (a different, non-deterministic survivor set/score) — the "identical to
  # --jobs 1" guarantee below only holds when fail-fast can't race.
  worker_count = [config.jobs || 1, 1].max
  worker_count = 1 if config.fail_fast
  worker_count = [worker_count, jobs.size].min if jobs.size.positive?

  results =
    if worker_count > 1
      run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
    else
      run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
    end

  [AggregateResult.new(results + ignored_results), source_map]
end

.execute_external(config, operator_classes) ⇒ Array(Mutineer::AggregateResult, Hash<String,String>)

#27: external backend orchestration. Runs each mutant's whole-file mutation on disk (crash-safe swap) and executes the user's --test-command as a subprocess in the app's own runtime. Serial by construction (KTD-5: one shared DB, no per-worker isolation yet). No coverage narrowing — every mutant runs the full --test set (KTD-6); the score is therefore an upper bound and not comparable to an in-process run (the CLI discloses this).

Parameters:

  • config (Mutineer::Config)

    run configuration (test_command set).

  • operator_classes (Array<Class>)

    resolved operators.

Returns:



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/mutineer/runner.rb', line 179

def self.execute_external(config, operator_classes)
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
  dirs      = source_dirs(config)

  # Heal any file a prior hard-killed run left mutated BEFORE reading source —
  # collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
  # still-mutated file would yield garbage offsets against the later-healed
  # source. Heal first, then discover jobs from the clean tree.
  FileSwap.restore_orphans(dirs)

  jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
  jobs = filter_since(jobs, source_map, config) if config.since

  # Calibrate the per-mutant timeout from the clean run (a real suite far
  # outlasts the 10s in-process fork budget), and abort if it isn't green.
  # ponytail: 3x the clean run, floor 30s, ceiling 300s — a heuristic. The
  # floor covers a fast suite; the ceiling bounds a hung mutant (infinite loop)
  # so a handful can't stall a serial run for ~45min on a slow suite.
  smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
  timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil

  results = []
  begin
    jobs.each do |subject, mutation, id|
      r = run_external(subject, mutation, config.test_command, abs_tests,
                       timeout: timeout, verbose: config.verbose)
      results << r.with(subject: subject, mutation: mutation, id: id)
      break if config.fail_fast && r.survived? # #21: stop at the first survivor
    end
  ensure
    FileSwap.restore_orphans(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.



522
523
524
525
526
527
528
529
530
531
# File 'lib/mutineer/runner.rb', line 522

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") ⇒ Mutineer::Result

Runs a single mutation through isolation.

Parameters:

  • mutation (Mutineer::Mutation)

    mutation to run.

  • source_file (String)

    source file path.

  • coverage_map (Mutineer::CoverageMap, nil) (defaults to: nil)

    coverage map.

  • subject (Mutineer::Subject, nil) (defaults to: nil)

    subject for surgical strategy.

  • strategy (String) (defaults to: "reload")

    mutation strategy.

  • timeout (Integer) (defaults to: Isolation::DEFAULT_TIMEOUT)

    child timeout in seconds.

  • rails (Boolean) (defaults to: false)

    whether Rails reconnect handling is enabled.

  • framework (String) (defaults to: "minitest")

    test framework name.

Returns:



600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
# File 'lib/mutineer/runner.rb', line 600

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. Shared with the daemon path so both
  # narrow identically (score parity, U7/V5).
  kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
  return payload if kind == :verdict

  abs_tests = payload

  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

.run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map) ⇒ Array<Mutineer::Result>

Parallel daemon path (#26/U6): N daemon handles, each pinned to its own worker slot (→ its own DB, so concurrent workers can't clobber each other's fixtures). A shared queue of job indices feeds N tool-side threads; each thread blocks on IPC (GVL released), so the N daemons run genuinely concurrently. Results are placed by input index and compacted, so the verdict SET is identical to serial regardless of finish order (R7). execute_daemon never calls this with config.fail_fast set (forced to the serial path instead) — a wall-clock stop here, unlike serial's input-index break, would make the survivor set and score non-deterministic. The stop-flag machinery below stays as a defensive no-op for any direct caller that bypasses that guard.

Returns:



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/mutineer/runner.rb', line 339

def self.run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
  results    = Array.new(jobs.size)
  queue      = Queue.new
  jobs.each_index { |i| queue << i }
  stop       = false
  stop_mutex = Mutex.new

  clients = Array.new(worker_count) do
    DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
                     app_root: config.project_root).start
  end

  clients.each_with_index.map do |client, worker|
    Thread.new do
      until stop_mutex.synchronize { stop }
        i = begin
          queue.pop(true) # non-blocking; ThreadError when drained
        rescue ThreadError
          break
        end
        r = daemon_job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
        results[i] = r
        stop_mutex.synchronize { stop = true } if config.fail_fast && r.survived?
      end
    ensure
      client.quit
    end
  end.each(&:join)

  results.compact
end

.run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map) ⇒ Array<Mutineer::Result>

Serial daemon path: one daemon (worker 0), one mutant at a time. Honors --fail-fast (#21: stop at the first survivor).

Returns:



311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/mutineer/runner.rb', line 311

def self.run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
  client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
                            app_root: config.project_root).start
  results = []
  begin
    jobs.each_with_index do |job, i|
      r = daemon_job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
      results << r
      break if config.fail_fast && r.survived?
    end
  ensure
    client.quit
  end
  results
end

.run_external(subject, mutation, command, abs_tests, timeout:, verbose:) ⇒ Mutineer::Result

Runs one mutant through the external backend: apply the whole-file mutation on disk, run the command, restore. KTD-8: an invalid (non-reparsing) mutant would fail to load and score a false killed, so skip it tool-side (Prism, already cheap) and never write the file — preserving the skipped verdict the in-process path gives at runner.rb's pre-fork check.

Returns:



222
223
224
225
226
227
228
229
230
# File 'lib/mutineer/runner.rb', line 222

def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
  source  = File.read(subject.file)
  mutated = mutation.apply(source)
  return Result.skipped if Parser.parse_string(mutated).errors.any?

  FileSwap.with(subject.file, mutated) do
    ExternalBackend.run(command, abs_tests, timeout: timeout, verbose: verbose)
  end
end

.source_dirs(config) ⇒ Array<String>

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The unique absolute directories holding the sources — the sweep target for both orphan mechanisms (in-process mutant tempfiles and external backup files). Shared so the path-expansion rule can't drift between the two paths.

Parameters:

Returns:

  • (Array<String>)

    unique absolute source directories.



572
573
574
# File 'lib/mutineer/runner.rb', line 572

def self.source_dirs(config)
  config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
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.



493
494
495
496
497
498
499
500
501
502
# File 'lib/mutineer/runner.rb', line 493

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)


507
508
509
510
511
512
513
514
515
# File 'lib/mutineer/runner.rb', line 507

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) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Removes stale mutant tempfiles from the given directories.

Parameters:

  • dirs (Array<String>)

    directories to sweep.



581
582
583
584
585
586
587
# File 'lib/mutineer/runner.rb', line 581

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.



536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
# File 'lib/mutineer/runner.rb', line 536

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