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,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+ require_relative "result"
5
+ require_relative "coverage_map"
6
+ require_relative "daemon_client"
7
+ require_relative "progress"
8
+ require_relative "job_plan"
9
+
10
+ module Mutineer
11
+ # Daemon execution backend. Boots the app ONCE in a persistent subprocess under
12
+ # the app's own bundle and forks per mutant, so a Rails run pays the boot cost
13
+ # once instead of per mutant. Tool-side this only discovers jobs and builds the
14
+ # ready-to-`load` payload (Prism); the daemon needs no Prism/mutineer.
15
+ #
16
+ # When jobs > 1 each worker runs against its OWN database, which is what makes
17
+ # `--jobs N` safe under Rails (#26): parallel verdicts are identical to serial.
18
+ #
19
+ # Job collection, `--since` filtering and coverage selection live in {JobPlan},
20
+ # which the in-process path calls too, so the daemon path can never drift from
21
+ # it on which mutants run or which tests narrow a mutant (score parity).
22
+ #
23
+ # Unlike {ExternalBackend}, which is a leaf {Runner} calls into, this module owns
24
+ # its orchestration and takes that shared vocabulary from {JobPlan}.
25
+ module DaemonBackend
26
+ # Default per-mutant timeout on the daemon path (seconds), overridden by
27
+ # config.daemon_timeout. Coverage narrowing usually keeps each job short; this
28
+ # still covers a slow suite or full-suite fallback when the map is unavailable.
29
+ # Named like its in-process counterpart {Isolation::DEFAULT_TIMEOUT}, not like
30
+ # {ExternalBackend::SMOKE_TIMEOUT}, which bounds a different thing.
31
+ DEFAULT_TIMEOUT = 60
32
+
33
+ # The daemon's per-mutant tempfile, written into the source dir so
34
+ # require_relative resolves. Kept in step with DaemonServer#sweep_temps.
35
+ DAEMON_TEMP_GLOB = "mutineer_daemon*.rb"
36
+
37
+ # Full daemon run: collect jobs, build the coverage map once, then execute
38
+ # serially or across N worker daemons. Fail-fast forces serial so the survivor
39
+ # set matches jobs 1.
40
+ #
41
+ # @param config [Mutineer::Config] run configuration (daemon set).
42
+ # @param operator_classes [Array<Class>] resolved operators.
43
+ # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
44
+ # source map, and the {JobPlan.collect_jobs} extras.
45
+ def self.execute(config, operator_classes)
46
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
47
+ jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
48
+ abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
49
+
50
+ # Nothing to mutate (`--since` matched no changed line, or every mutant is
51
+ # suppressed). Return before booting anything: the coverage daemon and the
52
+ # worker daemons below each boot the whole app, and README documents
53
+ # `--since origin/<base>` for PR CI, where a docs-only PR is routine.
54
+ if jobs.empty?
55
+ # The daemon sweeps orphaned temps at boot and nothing boots here, so sweep
56
+ # tool-side. A file a hard-killed run left in app/models breaks the app's own
57
+ # Zeitwerk boot, not just Mutineer's next run.
58
+ JobPlan.sweep_orphans(JobPlan.source_dirs(config), DAEMON_TEMP_GLOB)
59
+ return [AggregateResult.new(ignored_results), source_map, extras]
60
+ end
61
+
62
+ # Build the coverage map once (app-side). nil when the build fails: runners
63
+ # fall back to the full --test set (and emit a stderr warning) rather than
64
+ # mis-scoring everything as no_coverage.
65
+ coverage_map = build_coverage_map(config, abs_tests)
66
+
67
+ # Worker count = resolved --jobs, capped at the job count (no idle daemons).
68
+ # >1 → N concurrent daemon handles, each on its OWN worker DB (N-handles, the
69
+ # spike-proven shape). 1 → the serial single-daemon path. --fail-fast forces
70
+ # serial: parallel's stop flag fires on the first survivor by WALL-CLOCK, not
71
+ # input index, so the verdict set would diverge from serial (a different,
72
+ # non-deterministic survivor set/score). The "identical to --jobs 1" guarantee
73
+ # below only holds when fail-fast cannot race.
74
+ worker_count = [config.jobs || 1, 1].max
75
+ worker_count = 1 if config.fail_fast
76
+ worker_count = [worker_count, jobs.size].min
77
+
78
+ results =
79
+ if worker_count > 1
80
+ run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
81
+ else
82
+ run_serial(jobs, config, abs_tests, coverage_map, source_map)
83
+ end
84
+
85
+ [AggregateResult.new(results + ignored_results), source_map, extras]
86
+ end
87
+
88
+ # Build the coverage map via a short-lived daemon (boots the app once, captures
89
+ # per-test coverage app-side, ships the map back). Returns a query-only
90
+ # CoverageMap, or nil when the build fails / returns empty. Callers then run the
91
+ # full --test set. An empty map that came with boot lines returns a map with
92
+ # those lines only: it narrows nothing, but still classifies `ran_at_load`.
93
+ # Each capture is bounded by capture_timeout (#101); the client's wait for
94
+ # the whole map is not, because its length grows with the number of test
95
+ # files. A normal nonempty map scores like in-process;
96
+ # nil falls back to the full suite (more testing, not comparable).
97
+ #
98
+ # @param config [Mutineer::Config] the run config.
99
+ # @param abs_tests [Array<String>] absolute --test paths.
100
+ # @return [Mutineer::CoverageMap, nil]
101
+ def self.build_coverage_map(config, abs_tests)
102
+ client = DaemonClient.new(boot: boot_config(config, abs_tests, coverage: true),
103
+ app_root: config.project_root).start
104
+ data = begin
105
+ client.coverage
106
+ ensure
107
+ client.quit
108
+ end
109
+ # A red unmutated suite must abort, even when the shipped map is empty.
110
+ # Falling back to the full --test set would treat those failures as kills.
111
+ if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any?
112
+ JobPlan.abort_if_unclean!(CoverageMap.from_data(
113
+ map: data["map"] || {},
114
+ failed_test_files: data["failed_test_files"] || [],
115
+ project_root: config.project_root,
116
+ failed_clean_tests: data["failed_clean_tests"]
117
+ ))
118
+ end
119
+
120
+ unless data && !(data["map"] || {}).empty?
121
+ reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
122
+ warn_coverage_fallback(reason)
123
+ # No narrowing ({job_result} runs every test), but the lines and
124
+ # methods that ran at boot still classify a survivor on one of them.
125
+ load_lines = data.is_a?(Hash) ? Array(data["load_lines"]) : []
126
+ load_methods = data.is_a?(Hash) ? Array(data["load_methods"]) : []
127
+ return nil if load_lines.empty? && load_methods.empty?
128
+
129
+ return CoverageMap.from_data(map: {}, failed_test_files: [], project_root: config.project_root,
130
+ load_lines: load_lines, load_methods: load_methods)
131
+ end
132
+
133
+ CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
134
+ project_root: config.project_root, load_lines: data["load_lines"] || [],
135
+ load_methods: data["load_methods"] || [])
136
+ rescue DaemonBootTimeout
137
+ raise # a second daemon for the mutant runs would hang just as long
138
+ rescue DaemonBootError => e
139
+ warn_coverage_fallback("#{e.class}: #{e.message}")
140
+ nil
141
+ end
142
+
143
+ # Stderr note when daemon coverage is unavailable (full --test set per mutant).
144
+ #
145
+ # @api private
146
+ # @param reason [String] short cause (boot error message, empty map, …).
147
+ # @return [void]
148
+ def self.warn_coverage_fallback(reason = "unknown")
149
+ warn "[mutineer] daemon coverage map unavailable (#{reason}); running every " \
150
+ "mutant against the full --test set (score not comparable to an in-process run)."
151
+ end
152
+ private_class_method :warn_coverage_fallback
153
+
154
+ # Serial path: one daemon (worker 0), one mutant at a time. Honors --fail-fast
155
+ # (stop at the first survivor).
156
+ #
157
+ # @api private
158
+ # @return [Array<Mutineer::Result>] results in input order.
159
+ def self.run_serial(jobs, config, abs_tests, coverage_map, source_map)
160
+ client = DaemonClient.new(boot: boot_config(config, abs_tests),
161
+ app_root: config.project_root).start
162
+ results = []
163
+ progress = Progress.new(jobs.size)
164
+ begin
165
+ jobs.each_with_index do |job, i|
166
+ r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
167
+ results << r
168
+ progress.tick
169
+ break if config.fail_fast && r.survived?
170
+ end
171
+ ensure
172
+ client.quit
173
+ end
174
+ results
175
+ end
176
+
177
+ # Parallel path: N daemon handles, each pinned to its own worker slot (own DB).
178
+ # A shared queue of job indices feeds N tool-side threads; results are placed by
179
+ # input index so the verdict set matches serial. Callers must not pass fail_fast
180
+ # here ({execute} forces serial for fail-fast). Per-mutant crashes are classified
181
+ # in {job_result}, shared with the serial path; a {DaemonBootError} ends the run
182
+ # here rather than scoring the remainder against a daemon that has given up.
183
+ #
184
+ # @api private
185
+ # @return [Array<Mutineer::Result>] one result per input job, in input order.
186
+ def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
187
+ results = Array.new(jobs.size)
188
+ progress = Progress.new(jobs.size)
189
+ queue = Queue.new
190
+ jobs.each_index { |i| queue << i }
191
+
192
+ # Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
193
+ # can still quit the daemons already up. Array.new would lose every reference.
194
+ clients = []
195
+ begin
196
+ worker_count.times do
197
+ clients << DaemonClient.new(boot: boot_config(config, abs_tests),
198
+ app_root: config.project_root).start
199
+ end
200
+ rescue StandardError
201
+ clients.each(&:quit)
202
+ raise
203
+ end
204
+
205
+ clients.each_with_index.map do |client, worker|
206
+ Thread.new do
207
+ # The abort below is re-raised by join and reported once there; without
208
+ # this Ruby also dumps the thread's backtrace, which the serial path never
209
+ # does. Same fault, same output, whatever --jobs is set to.
210
+ Thread.current.report_on_exception = false
211
+ loop do
212
+ i = begin
213
+ queue.pop(true)
214
+ rescue ThreadError
215
+ break
216
+ end
217
+ results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
218
+ progress.tick
219
+ end
220
+ rescue DaemonBootError
221
+ # The daemon gave up for good. Stop feeding the other workers rather
222
+ # than letting them score the rest of the run against a dead client;
223
+ # Thread#join re-raises this and ends the run.
224
+ queue.clear
225
+ raise
226
+ ensure
227
+ client.quit
228
+ end
229
+ end.each(&:join)
230
+
231
+ # Every job was popped by some worker and every pop assigns, so no slot can
232
+ # be nil here: an escaping exception aborts the run via join instead.
233
+ results
234
+ end
235
+
236
+ # Build the payload for one job, run it on the given daemon/worker, and attach
237
+ # the subject/mutation/id. Shared body of both daemon paths, so `--jobs 1` and
238
+ # `--jobs N` classify an identical fault identically.
239
+ #
240
+ # Error model, in one place because both paths call this: a crash while running
241
+ # ONE mutant is {DaemonClient}'s business: it respawns and answers `"error"`.
242
+ # Nothing is caught here on purpose — anything reaching this far is either
243
+ # {DaemonBootError}, which must end the run, or a defect, which must stay visible.
244
+ #
245
+ # @param job [Array(Mutineer::Subject, Mutineer::Mutation, String)] the work item.
246
+ # @param req_id [Integer] request id (echoed back for IPC ordering safety).
247
+ # @param client [Mutineer::DaemonClient] the daemon handle to run on.
248
+ # @param worker [Integer] the worker slot (→ worker DB) this daemon routes to.
249
+ # @api private
250
+ # @raise [Mutineer::DaemonBootError] when the daemon has given up; ends the run.
251
+ # @return [Mutineer::Result] the decorated result.
252
+ def self.job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
253
+ subject, mutation, id = job
254
+ source = source_map[subject.file]
255
+ mutated = mutation.apply(source)
256
+ # Skip an invalid mutant tool-side: never ship a payload that would fail to
257
+ # load and read as a false `killed`.
258
+ # Narrow to covering tests (shared with the in-process path via
259
+ # JobPlan.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
260
+ # no fork. No map, or an empty one (build failed) → run the full --test set
261
+ # (fallback, not narrowed).
262
+ sel = coverage_map && !coverage_map.map.empty? && JobPlan.coverage_selection(subject.file, mutation, subject, source, coverage_map)
263
+ r =
264
+ if Parser.parse_string(mutated).errors.any?
265
+ Result.skipped
266
+ elsif sel && sel[0] == :verdict
267
+ sel[1]
268
+ else
269
+ verdict = client.request(
270
+ id: req_id, worker: worker, timeout: config.daemon_timeout || DEFAULT_TIMEOUT,
271
+ payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
272
+ tests: sel ? sel[1] : abs_tests
273
+ )
274
+ # A survivor whose line ran at load, as in-process (Runner.run).
275
+ JobPlan.load_verdict(result_for(verdict), subject.file, mutation, subject, source, coverage_map)
276
+ end
277
+ r.with(subject: subject, mutation: mutation, id: id)
278
+ end
279
+
280
+ # The boot config the daemon needs to boot the app once: where to boot, the
281
+ # --require files to load after it, the test load roots (so
282
+ # `require "test_helper"` resolves in every fork), framework, and whether this
283
+ # is Rails.
284
+ #
285
+ # @param config [Mutineer::Config] the run config.
286
+ # @param abs_tests [Array<String>] absolute --test paths.
287
+ # @param coverage [Boolean] whether this daemon builds the coverage map.
288
+ # @return [Hash] the boot config shipped to the daemon.
289
+ def self.boot_config(config, abs_tests, coverage: false)
290
+ {
291
+ project_root: config.project_root,
292
+ boot: File.expand_path(config.boot || "config/environment", config.project_root),
293
+ # Required after the boot, as in-process (Runner.execute); also part of
294
+ # the coverage digest.
295
+ require_paths: config.require_paths.map { |f| File.expand_path(f, config.project_root) },
296
+ load_paths: JobPlan.test_load_roots(abs_tests),
297
+ cache_dir: File.expand_path(config.cache_dir, config.project_root),
298
+ source_dirs: JobPlan.source_dirs(config), # so the daemon can sweep orphan mutant temps
299
+ framework: config.framework,
300
+ rails: config.rails,
301
+ # Schema for per-worker DB isolation. Sent when present; the daemon loads
302
+ # it over a worker's copy of the test DB only when that copy is out of date.
303
+ schema: schema_path(config),
304
+ # Coverage narrowing. Only the short-lived map-building daemon starts
305
+ # Coverage (before boot); worker daemons boot with it OFF (no wasted
306
+ # instrumentation/memory across every mutant fork). `sources`/`tests` are the
307
+ # map-build inputs.
308
+ coverage: coverage,
309
+ # The map-building daemon's capture limit (--capture-timeout); nil = default.
310
+ capture_timeout: config.capture_timeout,
311
+ sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
312
+ tests: abs_tests
313
+ }
314
+ end
315
+
316
+ # Absolute path to the app's `db/schema.rb` if it exists, else nil. Each worker
317
+ # DB starts as a copy of the test DB; the daemon loads this file over the copy
318
+ # when the copy's schema differs. `structure.sql` apps get nil and keep the
319
+ # copy as it is.
320
+ #
321
+ # @param config [Mutineer::Config] the run config.
322
+ # @api private
323
+ # @return [String, nil] absolute schema path or nil.
324
+ def self.schema_path(config)
325
+ path = File.expand_path("db/schema.rb", config.project_root)
326
+ File.exist?(path) ? path : nil
327
+ end
328
+
329
+ # Map a daemon verdict string to a Result. The daemon reports the four
330
+ # run-time states it can decide; pre-fork states (skipped/no_coverage/…) are
331
+ # resolved tool-side before a request is ever sent.
332
+ #
333
+ # @param verdict [String] the daemon's verdict word.
334
+ # @api private
335
+ # @return [Mutineer::Result] the matching result.
336
+ def self.result_for(verdict)
337
+ case verdict
338
+ when "survived" then Result.survived
339
+ when "killed" then Result.killed
340
+ when "timeout" then Result.timeout
341
+ else Result.error("daemon verdict: #{verdict}")
342
+ end
343
+ end
344
+
345
+ # The module's contract is {execute} (the backend entry point) plus the two the
346
+ # tests drive directly: {boot_config} from the zero-dep suite and
347
+ # {build_coverage_map} from the daemon suite. Everything else is daemon-pipeline
348
+ # internals with no caller outside this file.
349
+ private_class_method :run_serial, :run_parallel, :job_result, :schema_path, :result_for
350
+ end
351
+ end