mutineer-mongoid 2.0.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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. metadata +143 -0
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Immutable outcome of running one mutant. Ten distinct states:
5
+ # killed - a test failed/errored, so the mutation was caught.
6
+ # survived - every test passed, so the mutation went undetected.
7
+ # error - the child crashed (unhandled exception): exit status 2.
8
+ # timeout - the parent SIGKILLed a child that overran its wall clock.
9
+ # skipped - the mutated source failed to re-parse (invalid); no fork.
10
+ # no_coverage - no test exercises the mutated line; not run, not scored.
11
+ # uncapturable - the line's would-be covering test errored during capture,
12
+ # so coverage was lost. Excluded from the denominator exactly
13
+ # like no_coverage, but reported separately: it signals a
14
+ # broken harness (a test that failed to run), not a genuine
15
+ # coverage gap.
16
+ # unplaceable - under `--strategy redefine`, the method belongs to a class
17
+ # or module that cannot be named statically, so it has no
18
+ # owner to be loaded onto and is not run. Excluded from the
19
+ # denominator and, unlike uncapturable, from the
20
+ # no-verdict gate: nothing is broken. `--strategy reload`
21
+ # runs these mutants.
22
+ # ran_at_load - the mutated line ran while the app booted or its class
23
+ # loaded. That run happened before the mutant was applied,
24
+ # so a test can check a value the original code computed.
25
+ # A survivor on such a line, and a line that ran only at
26
+ # load, get this status; a kill stays killed. Excluded from
27
+ # the denominator and from the no-verdict gate, like
28
+ # unplaceable. `--test-command` verifies these mutants.
29
+ # ignored - a known-equivalent mutant the user suppressed, via an
30
+ # inline `# mutineer:disable-line` comment or a
31
+ # `.mutineer.yml` `ignore:` id. A pre-fork classification
32
+ # (never run); excluded from the denominator so a strong
33
+ # file can reach 100%.
34
+ #
35
+ # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
36
+ # validity failure (counted separately by the reporter), error is a runtime
37
+ # crash. Never conflate them via `details` string parsing. `no_coverage`,
38
+ # `uncapturable` and `unplaceable` are pre-fork results, and `ran_at_load` is
39
+ # pre-fork or a reclassified survivor: all excluded from the score
40
+ # denominator.
41
+ #
42
+ # `subject`, `mutation`, and `id` are nil when the Result is built by
43
+ # Isolation/Runner (which only know the outcome); the orchestrator attaches
44
+ # them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
45
+ # can render survivor diffs and emit the id. `id` is the content-based
46
+ # MutantId (it includes the project-relative file path).
47
+ #
48
+ # `kills` is nil except in a `--matrix` run, where a mutant that was forked
49
+ # carries the {Kills} its child reported. It annotates the verdict and never
50
+ # decides it.
51
+ #
52
+ # A Result crosses a process boundary only through WorkerPool's Marshal pipe,
53
+ # from a fork back to the process that forked it, so both ends always run the
54
+ # same code. Nothing persists a marshaled Result (reports, baselines and the
55
+ # coverage cache are JSON), so a new field needs no Marshal compatibility.
56
+ Result = Data.define(:status, :details, :subject, :mutation, :id, :kills) do
57
+ # Every field but `status` defaults to nil.
58
+ #
59
+ # @param status [Symbol] the outcome.
60
+ # @param details [String, nil] error or skip details.
61
+ # @param subject [Mutineer::Subject, nil] the mutated subject.
62
+ # @param mutation [Mutineer::Mutation, nil] the mutation.
63
+ # @param id [String, nil] the stable mutant id.
64
+ # @param kills [Mutineer::Kills, nil] the matrix row of a `--matrix` run.
65
+ def initialize(status:, details: nil, subject: nil, mutation: nil, id: nil, kills: nil)
66
+ super
67
+ end
68
+
69
+ # Builds a killed result.
70
+ #
71
+ # @return [Mutineer::Result] killed result.
72
+ def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
73
+
74
+ # Builds a survived result.
75
+ #
76
+ # @return [Mutineer::Result] survived result.
77
+ def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
78
+
79
+ # Builds an error result.
80
+ #
81
+ # @param details [String, nil] error details.
82
+ # @return [Mutineer::Result] error result.
83
+ def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
84
+
85
+ # Builds a timeout result.
86
+ #
87
+ # @return [Mutineer::Result] timeout result.
88
+ def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
89
+
90
+ # Builds a skipped result.
91
+ #
92
+ # @param details [String, nil] skip details.
93
+ # @return [Mutineer::Result] skipped result.
94
+ def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
95
+
96
+ # Builds a no_coverage result.
97
+ #
98
+ # @return [Mutineer::Result] no-coverage result.
99
+ def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
100
+
101
+ # Builds an uncapturable result.
102
+ #
103
+ # @return [Mutineer::Result] uncapturable result.
104
+ def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
105
+
106
+ # Builds an unplaceable result.
107
+ #
108
+ # @return [Mutineer::Result] unplaceable result.
109
+ def self.unplaceable = new(status: :unplaceable, details: nil, subject: nil, mutation: nil, id: nil)
110
+
111
+ # Builds a ran_at_load result.
112
+ #
113
+ # @return [Mutineer::Result] ran-at-load result.
114
+ def self.ran_at_load = new(status: :ran_at_load, details: nil, subject: nil, mutation: nil, id: nil)
115
+
116
+ # Builds an ignored result.
117
+ #
118
+ # @return [Mutineer::Result] ignored result.
119
+ def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
120
+
121
+ # @return [Boolean] true when the status is killed.
122
+ def killed? = status == :killed
123
+ # @return [Boolean] true when the status is survived.
124
+ def survived? = status == :survived
125
+ # @return [Boolean] true when the status is error.
126
+ def error? = status == :error
127
+ # @return [Boolean] true when the status is timeout.
128
+ def timeout? = status == :timeout
129
+ # @return [Boolean] true when the status is skipped.
130
+ def skipped? = status == :skipped
131
+ # @return [Boolean] true when the status is no_coverage.
132
+ def no_coverage? = status == :no_coverage
133
+ # @return [Boolean] true when the status is uncapturable.
134
+ def uncapturable? = status == :uncapturable
135
+ # @return [Boolean] true when the status is unplaceable.
136
+ def unplaceable? = status == :unplaceable
137
+ # @return [Boolean] true when the status is ran_at_load.
138
+ def ran_at_load? = status == :ran_at_load
139
+ # @return [Boolean] true when the status is ignored.
140
+ def ignored? = status == :ignored
141
+
142
+ end
143
+
144
+ # One mutant's row of the kill matrix (`--matrix`): which tests killed it and
145
+ # which ran against it. A test is a `[file, name, id]` triple: the
146
+ # project-relative file that defines it, its name (`CalculatorTest#test_add`,
147
+ # or an RSpec example's full description), and an id that tells apart tests
148
+ # sharing a file and name (an RSpec example id; the name again for Minitest).
149
+ # File and id identify a test; the name is display data that a mutant can
150
+ # change. Both lists are sorted and unique, and `ran` includes the killers.
151
+ #
152
+ # `complete` is true when the child ran its whole suite and reported it
153
+ # cleanly (see Isolation.finish). It is false when the child timed out,
154
+ # errored or exited the process early, when the recorder never armed, when
155
+ # its lines arrived out of order, when Minitest returned after an Interrupt
156
+ # with tests unseen, or when the row does not agree with the verdict.
157
+ Kills = Data.define(:killed_by, :ran, :complete)
158
+
159
+ # Aggregates a flat list of Results into counts, the mutation score, and the
160
+ # surviving-mutant list. The score denominator is killed + survived ONLY:
161
+ # no-coverage, uncapturable, unplaceable, ran-at-load, skipped (invalid), errored, timeout, and ignored
162
+ # (equivalent-mutant suppression) are each excluded and surfaced separately,
163
+ # so suppressing every survivor reaches 100%. An empty denominator yields a
164
+ # nil score (rendered "N/A"), never 0.0, distinguishing "no testable mutants"
165
+ # from "0% killed".
166
+ class AggregateResult
167
+ attr_reader :results
168
+
169
+ # Builds an aggregate from results.
170
+ #
171
+ # @param results [Array<Mutineer::Result>] classified results.
172
+ def initialize(results)
173
+ @results = results
174
+ @by_status = results.group_by(&:status)
175
+ end
176
+
177
+ # @return [Integer] killed count.
178
+ def killed_count = count(:killed)
179
+ # @return [Integer] survived count.
180
+ def survived_count = count(:survived)
181
+ # @return [Integer] no-coverage count.
182
+ def no_coverage_count = count(:no_coverage)
183
+ # @return [Integer] uncapturable count.
184
+ def uncapturable_count = count(:uncapturable)
185
+ # @return [Integer] unplaceable count.
186
+ def unplaceable_count = count(:unplaceable)
187
+ # @return [Integer] ran-at-load count.
188
+ def ran_at_load_count = count(:ran_at_load)
189
+ # @return [Integer] skipped-invalid count.
190
+ def skipped_invalid_count = count(:skipped)
191
+ # @return [Integer] errored count.
192
+ def errored_count = count(:error)
193
+ # @return [Integer] timeout count.
194
+ def timeout_count = count(:timeout)
195
+ # @return [Integer] ignored count.
196
+ def ignored_count = count(:ignored)
197
+
198
+ # Every generated, classified mutation. NOT the score denominator.
199
+ #
200
+ # @return [Integer] total result count.
201
+ def total = @results.size
202
+
203
+ # The score denominator (also shown to the reader).
204
+ #
205
+ # @return [Integer] killed plus survived.
206
+ def covered_count = killed_count + survived_count
207
+
208
+ # Computes the mutation score.
209
+ #
210
+ # @return [Float, nil] score percentage or nil when nothing was testable.
211
+ def mutation_score
212
+ return nil if covered_count.zero?
213
+
214
+ (killed_count.to_f / covered_count * 100).round(1)
215
+ end
216
+
217
+ # Returns the surviving mutants.
218
+ #
219
+ # @return [Array<Mutineer::Result>] surviving results.
220
+ def surviving_mutants = @results.select(&:survived?)
221
+
222
+ # Groups results by source file so the Reporter (per-source breakdown) and
223
+ # baseline diff can reuse the same aggregate math.
224
+ #
225
+ # @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
226
+ def by_source
227
+ @results.select { |r| r.subject }
228
+ .group_by { |r| r.subject.file }
229
+ .transform_values { |rs| AggregateResult.new(rs) }
230
+ end
231
+
232
+ private
233
+
234
+ # Counts results for a status.
235
+ #
236
+ # @param status [Symbol] result status.
237
+ # @return [Integer] count for that status.
238
+ def count(status) = (@by_status[status] || []).size
239
+ end
240
+ end
@@ -0,0 +1,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "parser"
5
+ require_relative "project"
6
+ require_relative "result"
7
+ require_relative "isolation"
8
+ require_relative "minitest_integration"
9
+ require_relative "test_runners"
10
+ require_relative "coverage_map"
11
+ require_relative "mutator_registry"
12
+ require_relative "worker_pool"
13
+ require_relative "progress"
14
+ require_relative "project_path"
15
+ require_relative "file_swap"
16
+ require_relative "external_backend"
17
+ require_relative "daemon_backend"
18
+ require_relative "job_plan"
19
+
20
+ module Mutineer
21
+ # Orchestrates one mutation end-to-end: apply it textually, validate the
22
+ # result, select its covering test files from the coverage map, then run only
23
+ # those against the mutated source in an isolated child process (strategy
24
+ # whole-file reload via `load`).
25
+ #
26
+ # The source file path is passed explicitly because Mutation carries only byte
27
+ # offsets, not its file. Coverage-map selection replaces a hardcoded test file:
28
+ # a mutation whose line no test exercises is :no_coverage (no fork); otherwise
29
+ # exactly the covering test files run in the child.
30
+ class Runner
31
+ # Full orchestration: resolve operators, discover subjects, build the
32
+ # coverage map, run every mutation, and aggregate. Returns
33
+ # [AggregateResult, source_map, extras], where extras is the hash
34
+ # {JobPlan.collect_jobs} returns (`:legacy_ignore_matches`, `:id_map`), unchanged.
35
+ # The CLI then reports + applies the exit code; the integration test asserts
36
+ # directly on the AggregateResult.
37
+ #
38
+ # The parent process `require`s each source file so its classes exist; forked
39
+ # children inherit them, so a covering test file's own require_relative of the
40
+ # source is a no-op and does not clobber the mutated `load` (spec §7).
41
+ #
42
+ # @param config [Mutineer::Config] run configuration.
43
+ # @return [Array(Mutineer::AggregateResult, Hash<String, String>, Hash)] aggregate,
44
+ # source map, and run extras.
45
+ def self.execute(config)
46
+ operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
47
+
48
+ # External backend: run the suite as a subprocess in the app's own runtime.
49
+ # It does no in-process boot/require or coverage build, so branch before any
50
+ # of that. The in-process path below is untouched.
51
+ return execute_external(config, operator_classes) if config.test_command
52
+
53
+ # Daemon backend: boot the app ONCE in a persistent subprocess under the
54
+ # app's bundle and fork per mutant. Tool-side we only discover jobs + build
55
+ # payloads (Prism), so branch before any in-process boot.
56
+ return DaemonBackend.execute(config, operator_classes) if config.daemon
57
+
58
+ # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
59
+ # in the parent and inherited by every fork. Do NOT manually require the
60
+ # sources. Under Zeitwerk a manual require of an autoloadable file raises;
61
+ # the booted env autoloads them, and subject discovery is a static Prism
62
+ # parse that needs nothing loaded. Standalone mode requires the sources as
63
+ # before so their classes exist for the children to inherit.
64
+ if config.boot
65
+ # Under --rails an unset RAILS_ENV boots development, where the test
66
+ # suite is not loaded. Coverage comes back empty and EVERY mutant is
67
+ # falsely reported no_coverage (score N/A, exit 0). Default it to test.
68
+ ensure_rails_env(config)
69
+
70
+ # Coverage instruments only files loaded AFTER it starts. Start it BEFORE
71
+ # the boot require so the entire app loaded during boot is instrumented;
72
+ # forked children then measure each test's coverage delta against it.
73
+ # An earlier run in this process leaves Coverage suspended (see below).
74
+ # Coverage a host started is the host's: it is left running.
75
+ require "coverage"
76
+ own_coverage = !Coverage.running?
77
+ case Coverage.state
78
+ when :idle then Coverage.start(lines: true, methods: true)
79
+ when :suspended then Coverage.resume
80
+ end
81
+ require File.expand_path(config.boot, config.project_root)
82
+ else
83
+ config.sources.each { |f| require File.expand_path(f, config.project_root) }
84
+ end
85
+ config.require_paths.each { |f| require File.expand_path(f, config.project_root) }
86
+ preload_owners(config) if config.boot && config.strategy == "redefine"
87
+
88
+ if config.boot
89
+ # Rails/Minitest test files do `require "test_helper"`, which needs the
90
+ # test root on $LOAD_PATH (`bin/rails test` adds it). Prepend each test
91
+ # file's helper root here in the parent so loading them in the fork
92
+ # children (both coverage capture and per-mutant) resolves.
93
+ boot_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
94
+ JobPlan.test_load_roots(boot_tests).each { |d| $LOAD_PATH.unshift(d) unless $LOAD_PATH.include?(d) }
95
+
96
+ # Boot mode now uses coverage selection too: capture each test's coverage
97
+ # by forking the booted parent, then select covering tests per mutant.
98
+ coverage_map = CoverageMap.new(
99
+ source_paths: config.sources, test_paths: config.tests,
100
+ cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
101
+ load_paths: config.load_paths, framework: config.framework,
102
+ boot_path: File.expand_path(config.boot, config.project_root),
103
+ require_paths: config.require_paths, # loaded above; here only for the cache digest
104
+ verbose: config.verbose,
105
+ capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
106
+ ).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
107
+ # Nothing reads Coverage once the map is built (cache hit or not), so
108
+ # stop paying for it in every mutant fork (#228).
109
+ Coverage.suspend if own_coverage && Coverage.running?
110
+ else
111
+ # As in boot mode, and with lib first as `rake test` does.
112
+ test_roots = JobPlan.test_load_roots(config.tests.map { |t| File.expand_path(t, config.project_root) })
113
+ libs = config.load_paths.map { |p| File.expand_path(p, config.project_root) }
114
+ $LOAD_PATH.unshift(*(libs + test_roots).uniq.reject { |d| $LOAD_PATH.include?(d) })
115
+ # Relative, so the cache digest does not depend on the checkout path.
116
+ rel_roots = test_roots.map { |d| Pathname(d).relative_path_from(File.expand_path(config.project_root)).to_s }
117
+ coverage_map = CoverageMap.new(
118
+ source_paths: config.sources, test_paths: config.tests,
119
+ cache_dir: File.expand_path(config.cache_dir, config.project_root), project_root: config.project_root,
120
+ load_paths: config.load_paths + rel_roots, framework: config.framework,
121
+ require_paths: config.require_paths,
122
+ capture_timeout: config.capture_timeout || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
123
+ ).build_or_load
124
+ end
125
+ JobPlan.abort_if_unclean!(coverage_map)
126
+
127
+ # Collect every (subject, mutation) up front so the pool can fan them out.
128
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
129
+
130
+ jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
131
+
132
+ # Whole-file reload writes mutineer_mutant*.rb into each source dir (so
133
+ # require_relative resolves). A SIGKILL'd child skips the tempfile's
134
+ # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
135
+ # PARENT sweeps each source dir before and after the run. Orphans are
136
+ # impossible after a normal run.
137
+ dirs = JobPlan.source_dirs(config)
138
+ JobPlan.sweep_orphans(dirs)
139
+
140
+ strategy = config.strategy
141
+ # Fail-fast must be serial: a parallel stop_when fires on the first survivor
142
+ # by wall-clock, not input order, so the survivor set would diverge from
143
+ # --jobs 1 (daemon already forces serial for the same reason).
144
+ jobs_n = config.fail_fast ? 1 : config.jobs
145
+ results =
146
+ begin
147
+ framework = config.framework
148
+ stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
149
+ progress = Progress.new(jobs.size)
150
+ bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
151
+ on_result: ->(_r) { progress.tick }) do |subject, mutation|
152
+ run(mutation, source_file: subject.file, coverage_map: coverage_map,
153
+ subject: subject, strategy: strategy, rails: config.rails, framework: framework,
154
+ timeout: config.timeout || Isolation::DEFAULT_TIMEOUT, matrix: config.matrix)
155
+ end
156
+ # The bare Results carry only status (Subjects hold live AST nodes that
157
+ # do not marshal); reattach subject+mutation+id in the parent, in order.
158
+ # filter_map drops nils for jobs --fail-fast left unscheduled.
159
+ share_tests(bare.each_with_index.filter_map do |r, i|
160
+ r = unreported_row(r) if r && config.matrix
161
+ r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2])
162
+ end)
163
+ ensure
164
+ JobPlan.sweep_orphans(dirs)
165
+ end
166
+
167
+ [AggregateResult.new(results + ignored_results), source_map, extras]
168
+ end
169
+
170
+ # A `--matrix` error that carries no {Kills} row (the worker crashed, or its
171
+ # result was lost) gets an empty, incomplete row: its tests never reported,
172
+ # so a blind test may have killed it, and the report must say so.
173
+ #
174
+ # @param result [Mutineer::Result] a worker's result.
175
+ # @return [Mutineer::Result]
176
+ def self.unreported_row(result)
177
+ return result unless result.error? && result.kills.nil?
178
+
179
+ result.with(kills: Kills.new(killed_by: [], ran: [], complete: false))
180
+ end
181
+
182
+ # External backend orchestration. Runs each mutant's whole-file mutation on
183
+ # disk (crash-safe swap) and executes the user's --test-command as a subprocess
184
+ # in the app's own runtime. Serial by construction (one shared DB, no
185
+ # per-worker isolation yet). No coverage narrowing: every mutant runs the full
186
+ # --test set; the score is therefore an upper bound and not comparable to an
187
+ # in-process run (the CLI discloses this).
188
+ #
189
+ # @param config [Mutineer::Config] run configuration (test_command set).
190
+ # @param operator_classes [Array<Class>] resolved operators.
191
+ # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
192
+ # source map, and the {JobPlan.collect_jobs} extras.
193
+ def self.execute_external(config, operator_classes)
194
+ abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
195
+ sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
196
+ dirs = sources.map { |s| File.dirname(s) }.uniq
197
+
198
+ # Own every source before healing leftovers or reading bytes for mutation.
199
+ # A backup file alone is not ownership; flock is. Canonical paths so
200
+ # symlink aliases of one inode share one lock, independent of cache_dir.
201
+ FileSwap.owning(sources) do
202
+ # Heal any file a prior hard-killed run left mutated BEFORE reading source.
203
+ # collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
204
+ # still-mutated file would yield garbage offsets against the later-healed
205
+ # source. Heal first, then discover jobs from the clean tree.
206
+ FileSwap.restore_orphans(dirs)
207
+
208
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
209
+ jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
210
+
211
+ # Nothing to mutate: return before the smoke check, which runs the whole
212
+ # --test set to calibrate a timeout no mutant would use (#76).
213
+ next [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
214
+
215
+ # Calibrate the per-mutant timeout from the clean run (a real suite far
216
+ # outlasts the 10s in-process fork budget), and abort if it is not green.
217
+ # 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
218
+ # a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
219
+ # handful cannot stall a serial run for ~45min on a slow suite.
220
+ smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
221
+ timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
222
+
223
+ results = []
224
+ progress = Progress.new(jobs.size)
225
+ begin
226
+ jobs.each do |subject, mutation, id|
227
+ r = run_external(subject, mutation, config.test_command, abs_tests,
228
+ timeout: timeout, verbose: config.verbose)
229
+ results << r.with(subject: subject, mutation: mutation, id: id)
230
+ progress.tick
231
+ break if config.fail_fast && r.survived? # stop at the first survivor
232
+ end
233
+ ensure
234
+ FileSwap.restore_orphans(dirs)
235
+ end
236
+
237
+ [AggregateResult.new(results + ignored_results), source_map, extras]
238
+ end
239
+ end
240
+
241
+ # Runs one mutant through the external backend: apply the whole-file mutation
242
+ # on disk, run the command, restore. An invalid (non-reparsing) mutant would
243
+ # fail to load and score a false `killed`, so skip it tool-side (Prism, already
244
+ # cheap) and never write the file, preserving the `skipped` verdict the
245
+ # in-process path gives at the pre-fork check.
246
+ #
247
+ # @return [Mutineer::Result] verdict for this mutant.
248
+ def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
249
+ source = File.read(subject.file)
250
+ mutated = mutation.apply(source)
251
+ return Result.skipped if Parser.parse_string(mutated).errors.any?
252
+
253
+ FileSwap.with(subject.file, mutated) do
254
+ ExternalBackend.run(command, abs_tests, timeout: timeout, verbose: verbose)
255
+ end
256
+ end
257
+
258
+ # Loads the class or module of every subject in the booted parent, before
259
+ # the boot coverage is read. Under redefine a child resolves the owner by
260
+ # name ({Isolation.nesting_keywords}), so a lazily loaded class (Zeitwerk,
261
+ # `autoload`) would run its class body there, with the original method,
262
+ # and that run would count as no load at all. Loading it here makes its
263
+ # class-body calls load lines, the same as an eager load. A constant that
264
+ # fails to load stays lazy.
265
+ #
266
+ # @param config [Mutineer::Config] run configuration.
267
+ # @return [void]
268
+ def self.preload_owners(config)
269
+ Project.discover(config.sources, only: config.only).each do |subject|
270
+ next if subject.owner_unknown
271
+
272
+ Isolation.nesting_keywords(subject.lexical_namespace)
273
+ # The owner itself, as the child resolves it: a Class.new block owner
274
+ # has no lexical namespace.
275
+ Object.const_get(subject.namespace.join("::")) unless subject.namespace.empty?
276
+ rescue StandardError, ScriptError
277
+ nil
278
+ end
279
+ end
280
+
281
+ # When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
282
+ # before the app boots. Otherwise it boots development and nothing is measured.
283
+ # An explicitly-set RAILS_ENV is always respected.
284
+ def self.ensure_rails_env(config)
285
+ return unless config.rails
286
+ return unless ENV["RAILS_ENV"].nil? || ENV["RAILS_ENV"].empty?
287
+
288
+ ENV["RAILS_ENV"] = "test"
289
+ warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
290
+ end
291
+
292
+ # Runs a single mutation through isolation.
293
+ #
294
+ # @param mutation [Mutineer::Mutation] mutation to run.
295
+ # @param source_file [String] source file path.
296
+ # @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
297
+ # @param subject [Mutineer::Subject, nil] subject for surgical strategy.
298
+ # @param strategy [String] mutation strategy.
299
+ # @param timeout [Integer] child timeout in seconds.
300
+ # @param rails [Boolean] whether Rails reconnect handling is enabled.
301
+ # @param framework [String] test framework name.
302
+ # @param matrix [Boolean] a `--matrix` run: run every covering test and
303
+ # attach the {Kills} row, with project-relative test files.
304
+ # @return [Mutineer::Result] mutant result.
305
+ def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
306
+ timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest", matrix: false)
307
+ source = File.read(source_file)
308
+ mutated = mutation.apply(source)
309
+
310
+ # Validity rule: a mutant that does not re-parse is skipped before forking.
311
+ return Result.skipped if Parser.parse_string(mutated).errors.any?
312
+
313
+ # Coverage selection (both standalone and boot mode): a mutation on a line
314
+ # no test exercises is :no_coverage (no fork); otherwise exactly the
315
+ # covering test files run in the child. Shared with the daemon path so both
316
+ # narrow identically (score parity).
317
+ kind, payload = JobPlan.coverage_selection(source_file, mutation, subject, source, coverage_map)
318
+ return payload if kind == :verdict
319
+ return Result.unplaceable if strategy == "redefine" && subject&.owner_unknown
320
+
321
+ abs_tests = payload
322
+
323
+ result = Isolation.run(timeout: timeout, channel: matrix) do |channel|
324
+ # Forking inherits the parent's live DB connection; sharing one socket
325
+ # across processes corrupts it. Drop it so AR reconnects per child.
326
+ reconnect_active_record if rails
327
+ if strategy == "redefine"
328
+ Isolation.apply_surgical(mutation, subject, source)
329
+ else
330
+ Isolation.apply_whole_file(mutated, source_file)
331
+ end
332
+ if channel
333
+ # --matrix: every covering test runs, and each outcome goes to the parent.
334
+ TestRunners.for(framework).run(abs_tests, record_to: channel)
335
+ else
336
+ # One failing test already kills the mutant, so the child stops there.
337
+ TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
338
+ end
339
+ end
340
+ result = relative_kills(result, coverage_map.project_root)
341
+ JobPlan.load_verdict(result, source_file, mutation, subject, source, coverage_map)
342
+ end
343
+
344
+ # Rewrites the test files of a result's {Kills} row relative to the project
345
+ # root, the form the coverage map and the report use. A result without a
346
+ # row comes back unchanged.
347
+ #
348
+ # @api private
349
+ # @param result [Mutineer::Result] a mutant result.
350
+ # @param root [String] project root.
351
+ # @return [Mutineer::Result]
352
+ def self.relative_kills(result, root)
353
+ return result unless (kills = result.kills)
354
+
355
+ paths = {}
356
+ rel = lambda do |tests|
357
+ tests.map { |file, name, id| [(paths[file] ||= ProjectPath.relative(file, root)), name, id] }.uniq.sort
358
+ end
359
+ result.with(kills: kills.with(killed_by: rel.call(kills.killed_by), ran: rel.call(kills.ran)))
360
+ end
361
+
362
+ # Makes every {Kills} row refer to one shared, frozen array per test. Each
363
+ # row arrives with its own copy of every test it ran, and a large matrix
364
+ # (thousands of mutants times hundreds of tests) would otherwise hold that
365
+ # many copies. Results without a row come back unchanged.
366
+ #
367
+ # A test is its file and its id. The name is display data and can differ
368
+ # between mutants (an RSpec example worded from its matcher reads
369
+ # differently under each mutant), so every row gets the name the first
370
+ # mutant reported, and one example stays one test.
371
+ #
372
+ # @api private
373
+ # @param results [Array<Mutineer::Result>] mutant results.
374
+ # @return [Array<Mutineer::Result>]
375
+ def self.share_tests(results)
376
+ shared = {}
377
+ share = lambda do |tests|
378
+ tests.map { |file, name, id| shared[[file, id]] ||= [file, name, id].map { |s| -s }.freeze }.uniq.sort.freeze
379
+ end
380
+ results.map do |r|
381
+ next r unless (kills = r.kills)
382
+
383
+ r.with(kills: kills.with(killed_by: share.call(kills.killed_by), ran: share.call(kills.ran)))
384
+ end
385
+ end
386
+
387
+ # Reconnects ActiveRecord in a forked child when available.
388
+ #
389
+ # @api private
390
+ # @return [void]
391
+ def self.reconnect_active_record
392
+ return unless defined?(ActiveRecord::Base)
393
+
394
+ base = ActiveRecord::Base
395
+ # Clearing connections here drops an open transactional-fixture
396
+ # transaction, so the test loses its fixture rows and fails. Skip the clear
397
+ # when a transaction is open; otherwise clear (per-fork write-safety).
398
+ return if fixture_transaction_open?(base)
399
+
400
+ base.connection_handler.clear_all_connections!
401
+ rescue StandardError
402
+ nil
403
+ end
404
+ private_class_method :reconnect_active_record
405
+
406
+ # Pure, injectable predicate: true when a transactional-fixture transaction is
407
+ # already open on the connection. Keys off open_transactions so it is correct
408
+ # whenever the transaction exists, regardless of when it opened. Any probe
409
+ # error degrades safe to false -> caller clears (existing behaviour).
410
+ def self.fixture_transaction_open?(base)
411
+ pool = base.connection_pool
412
+ pool.active_connection? && base.connection.open_transactions.positive?
413
+ rescue StandardError
414
+ false
415
+ end
416
+ private_class_method :fixture_transaction_open?
417
+ end
418
+ end