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.
Constant Summary collapse
- DAEMON_TIMEOUT =
Default per-mutant timeout on the daemon path. Generous because 2a runs the full
--testset per mutant (no coverage narrowing until Phase 2c). 60
Class Method Summary collapse
-
.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.
-
.coverage_selection(source_file, mutation, subject, source, coverage_map) ⇒ Array(Symbol, Object)
Coverage-based test selection, shared by the in-process (Runner.run) and daemon paths so both narrow identically (score parity, U7/V5).
-
.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. -
.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).
-
.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.
-
.daemon_result(verdict) ⇒ Object
Map a daemon verdict string to a Result.
-
.daemon_schema_path(config) ⇒ String?
Absolute path to the app's
db/schema.rbif it exists, else nil. -
.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) ⇒ Array(Mutineer::AggregateResult, Hash<String, String>)
Full Phase B orchestration: resolve operators, discover subjects, build the coverage map, run every mutation, and aggregate.
-
.execute_daemon(config, operator_classes) ⇒ Array(Mutineer::AggregateResult, Hash<String,String>)
#26/#27 Phase 2a — daemon backend orchestration (serial).
-
.execute_external(config, operator_classes) ⇒ Array(Mutineer::AggregateResult, Hash<String,String>)
#27: external backend orchestration.
-
.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") ⇒ Mutineer::Result
Runs a single mutation through isolation.
-
.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).
-
.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.
-
.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.
-
.source_dirs(config) ⇒ Array<String>
private
The unique absolute directories holding the sources — the sweep target for both orphan mechanisms (in-process mutant tempfiles and external backup files).
-
.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) ⇒ void
private
Removes stale mutant tempfiles from the given directories.
-
.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
.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.
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.
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.(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.(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.(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.
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.
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.(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).
471 472 473 474 |
# File 'lib/mutineer/runner.rb', line 471 def self.daemon_schema_path(config) path = File.("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).
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.(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(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 = 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. .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.
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.(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).
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.(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.(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.
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.
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).
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.
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.
572 573 574 |
# File 'lib/mutineer/runner.rb', line 572 def self.source_dirs(config) config.sources.map { |f| File.dirname(File.(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.
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.
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 |