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,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "yaml"
5
+
6
+ module Mutineer
7
+ # Raised by the config layer instead of calling exit/abort. A data class must
8
+ # never kill the host process. The CLI rescues this and maps it to exit 2.
9
+ class ConfigError < StandardError; end
10
+
11
+ # One row of the option schema: how a Config field is parsed and where the
12
+ # user can set it. `yaml_key` is nil for CLI-only options; `flag` is nil for
13
+ # YAML-only ones. `values`/`aliases` apply to the :enum type.
14
+ #
15
+ # @api private
16
+ ConfigOption = Struct.new(:field, :type, :yaml_key, :flag, :values, :aliases, keyword_init: true)
17
+
18
+ # Deprecated internal strategy names, mapped to their canonical equivalents.
19
+ STRATEGY_ALIASES = { "7a" => "reload", "7b" => "redefine" }.freeze
20
+
21
+ # The option schema. Config.parse is the only place that turns a raw value
22
+ # (a CLI string or a YAML scalar) into a typed one, so a value is checked once,
23
+ # at the boundary, whichever layer wrote it. Row order fixes the order of
24
+ # KNOWN_KEYS in warnings.
25
+ #
26
+ # @api private
27
+ CONFIG_OPTIONS = [
28
+ ConfigOption.new(field: :operators, type: :string_list, yaml_key: "operators", flag: "--operators"),
29
+ ConfigOption.new(field: :jobs, type: :positive_int, yaml_key: "jobs", flag: "--jobs"),
30
+ ConfigOption.new(field: :threshold, type: :percent, yaml_key: "threshold", flag: "--threshold"),
31
+ ConfigOption.new(field: :only, type: :string, yaml_key: "only", flag: "--only"),
32
+ ConfigOption.new(field: :require_paths, type: :string_list, yaml_key: "require"),
33
+ ConfigOption.new(field: :boot, type: :string, yaml_key: "boot", flag: "--boot"),
34
+ ConfigOption.new(field: :rails, type: :bool, yaml_key: "rails", flag: "--rails"),
35
+ ConfigOption.new(field: :since, type: :since, yaml_key: "since", flag: "--since"),
36
+ ConfigOption.new(field: :framework, type: :enum, yaml_key: "framework", flag: "--framework",
37
+ values: %w[minitest rspec]),
38
+ ConfigOption.new(field: :verbose, type: :bool, yaml_key: "verbose", flag: "--verbose"),
39
+ ConfigOption.new(field: :ignore, type: :string_list, yaml_key: "ignore"),
40
+ ConfigOption.new(field: :baseline, type: :string, yaml_key: "baseline", flag: "--baseline"),
41
+ ConfigOption.new(field: :fail_fast, type: :bool, yaml_key: "fail_fast", flag: "--fail-fast"),
42
+ ConfigOption.new(field: :matrix, type: :bool, yaml_key: "matrix", flag: "--matrix"),
43
+ ConfigOption.new(field: :test_command, type: :string, yaml_key: "test_command", flag: "--test-command"),
44
+ ConfigOption.new(field: :daemon, type: :bool, yaml_key: "daemon", flag: "--daemon"),
45
+ ConfigOption.new(field: :timeout, type: :positive_int, yaml_key: "timeout", flag: "--timeout"),
46
+ ConfigOption.new(field: :capture_timeout, type: :positive_int, yaml_key: "capture_timeout",
47
+ flag: "--capture-timeout"),
48
+ ConfigOption.new(field: :cache_dir, type: :string, yaml_key: "cache_dir", flag: "--cache-dir"),
49
+ ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
50
+ ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
51
+ aliases: STRATEGY_ALIASES),
52
+ ConfigOption.new(field: :output, type: :string, flag: "--output"),
53
+ ConfigOption.new(field: :baseline_epsilon, type: :nonneg_float, flag: "--baseline-epsilon"),
54
+ ConfigOption.new(field: :dry_run, type: :bool, flag: "--dry-run")
55
+ ].freeze
56
+
57
+ # Plain run configuration, populated by the CLI (or directly by the
58
+ # integration test). `operators` nil means "all default operators";
59
+ # `threshold` 0.0 means the CI gate is off (spec §10).
60
+ #
61
+ # Also holds: jobs (parallel workers), format (human|json), output (report
62
+ # file), strategy (reload|redefine), require_paths (extra files to load).
63
+ # Config loading and the CLI > file > default precedence merge live here; each
64
+ # layer holds only the keys the user wrote, and Config#explicit? reports them.
65
+ #
66
+ # `matrix` (--matrix) runs every covering test for each mutant and reports
67
+ # which tests kill it (see KillMatrix); it never changes a verdict.
68
+ #
69
+ # Boot mode adds: boot (a file to require ONCE in the parent so the app env,
70
+ # e.g. Rails, is booted before forking; sources are then NOT manually required)
71
+ # and rails (sugar: defaults boot to config/environment, prefers redefine without
72
+ # a daemon, and reconnects ActiveRecord per fork).
73
+ Config = Struct.new(
74
+ :sources, :tests, :operators, :threshold, :only, :dry_run,
75
+ :cache_dir, :project_root, :load_paths,
76
+ :jobs, :format, :output, :strategy, :require_paths,
77
+ :boot, :rails, :since, :framework, :verbose, :ignore,
78
+ # :daemon is user-facing (--daemon flag + KNOWN_KEYS + boolean coerce).
79
+ # :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
80
+ :baseline, :baseline_epsilon, :fail_fast, :test_command,
81
+ :daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix,
82
+ keyword_init: true
83
+ ) do
84
+ # Config file name.
85
+ CONFIG_FILE = ".mutineer.yml"
86
+ # Keys accepted in .mutineer.yml, derived from the schema. `require` maps to the
87
+ # :require_paths field.
88
+ KNOWN_KEYS = CONFIG_OPTIONS.filter_map(&:yaml_key).freeze
89
+
90
+ # @param explicit [Array<Symbol>] fields the user wrote (CLI or file). Derived
91
+ # values fill only the others; a programmatic Config.new writes none.
92
+ # @param from_file [Array<Symbol>] the explicit fields whose value came from
93
+ # the config file (the command line did not override them).
94
+ def initialize(explicit: [], from_file: [], **kwargs)
95
+ super(**kwargs)
96
+ @explicit = explicit.to_a.dup.freeze
97
+ @from_file = from_file.to_a.dup.freeze
98
+ self.sources ||= []
99
+ self.tests ||= []
100
+ self.threshold ||= 0.0
101
+ self.dry_run ||= false
102
+ self.cache_dir ||= ".mutineer"
103
+ self.project_root ||= Dir.pwd
104
+ self.load_paths ||= ["lib"]
105
+ self.jobs ||= Etc.nprocessors
106
+ self.format ||= "human"
107
+ self.strategy ||= "reload"
108
+ self.require_paths ||= []
109
+ self.rails = false if rails.nil?
110
+ self.verbose = false if verbose.nil?
111
+ self.ignore ||= []
112
+ self.baseline_epsilon ||= 0.0
113
+ self.fail_fast = false if fail_fast.nil?
114
+ self.daemon = false if daemon.nil?
115
+ self.matrix = false if matrix.nil?
116
+ end
117
+
118
+ # True when the user wrote `key`, on the command line or in the config
119
+ # file, whatever the value (`false` and `nil` count). The answer comes from
120
+ # which keys the layers held, so a new option needs no bookkeeping to be
121
+ # covered.
122
+ #
123
+ # @param key [Symbol] Config field name.
124
+ # @return [Boolean]
125
+ def explicit?(key)
126
+ @explicit.include?(key)
127
+ end
128
+
129
+ # Where the user set `key`, for messages: the config-file key (as
130
+ # `name in .mutineer.yml`) when its value came from the file, else the
131
+ # command-line flag.
132
+ #
133
+ # @param key [Symbol] Config field name (a row of the option schema).
134
+ # @return [String] e.g. `"--fail-fast"` or `"fail_fast in .mutineer.yml"`.
135
+ def origin(key)
136
+ opt = CONFIG_OPTIONS.find { |o| o.field == key } or raise ArgumentError, "unknown option #{key.inspect}"
137
+ @from_file.include?(key) && opt.yaml_key ? "#{opt.yaml_key} in #{CONFIG_FILE}" : (opt.flag || opt.yaml_key)
138
+ end
139
+
140
+ # Walk from `start` toward `home`, returning the first .mutineer.yml path found
141
+ # or nil. Checks `home` itself, then stops; if `start` is above `home`
142
+ # (e.g. /tmp), the walk continues to the filesystem root. Pure discovery;
143
+ # reads no file content.
144
+ def self.find_file(start = Dir.pwd, home = File.expand_path("~"))
145
+ dir = File.expand_path(start)
146
+ loop do
147
+ candidate = File.join(dir, CONFIG_FILE)
148
+ return candidate if File.file?(candidate)
149
+ break if dir == home
150
+
151
+ parent = File.dirname(dir)
152
+ break if parent == dir # filesystem root
153
+
154
+ dir = parent
155
+ end
156
+ nil
157
+ end
158
+
159
+ # Parse a .mutineer.yml into a symbol-keyed hash of recognized keys. Unknown
160
+ # keys emit a one-line stderr warning and are ignored. Unknown operator names
161
+ # warn and are dropped. If that leaves no names, the file is an error: an
162
+ # empty operator list would run nothing and exit 0. Pass
163
+ # +defer_operators: true+ only when the command line replaces that list, so
164
+ # a blank or all-unknown file list does not block +--operators+. A YAML
165
+ # syntax error raises ConfigError: never a silent fallback to defaults, and
166
+ # never an exit from the lib layer.
167
+ #
168
+ # @param path [String] config file path.
169
+ # @param defer_operators [Boolean] keep an empty operator list for a CLI override.
170
+ # @return [Hash{Symbol => Object}]
171
+ def self.from_file(path, defer_operators: false)
172
+ raw = YAML.safe_load(File.read(path)) || {}
173
+ name = File.basename(path)
174
+ unless raw.is_a?(Hash)
175
+ warn "mutineer: #{name} ignored: expected a YAML mapping of keys to values"
176
+ return {}
177
+ end
178
+
179
+ out = {}
180
+ raw.each do |key, value|
181
+ ks = key.to_s
182
+ unless KNOWN_KEYS.include?(ks)
183
+ warn "mutineer: unknown config key #{ks.inspect} in #{name} " \
184
+ "(known: #{KNOWN_KEYS.join(', ')}); ignored"
185
+ next
186
+ end
187
+ field = field_for(ks)
188
+ parsed = parse(field, value, file: name, defer_operators: defer_operators)
189
+ if field == :operators
190
+ parsed = filter_operators(parsed, name)
191
+ if parsed.empty? && !defer_operators
192
+ raise ConfigError, "#{name}: operators must name at least one known operator"
193
+ end
194
+ end
195
+ out[field] = parsed
196
+ end
197
+ out
198
+ rescue Psych::SyntaxError => e
199
+ raise ConfigError, "#{File.basename(path)} parse error: #{e.message}"
200
+ end
201
+
202
+ # Merges the two user layers, CLI over file, then derives what neither wrote.
203
+ # A layer is a Hash of only the keys the user set, so precedence is `merge`
204
+ # and "did the user write this" is whether the key exists. Nothing tracks
205
+ # provenance by hand, and `false`/`nil` are ordinary values.
206
+ #
207
+ # @param cli_opts [Hash{Symbol => Object}] parsed command-line fields.
208
+ # @param file_hash [Hash{Symbol => Object}] parsed .mutineer.yml fields.
209
+ # @return [Mutineer::Config]
210
+ def self.resolve(cli_opts, file_hash)
211
+ user = file_hash.merge(cli_opts)
212
+ config = new(**user, explicit: user.keys, from_file: file_hash.keys - cli_opts.keys)
213
+
214
+ # --rails sugar: boot config/environment. Prefer redefine only for the
215
+ # in-process path (daemon is whole-file reload only). In-process --rails
216
+ # shares one test database, so force serial unless --daemon.
217
+ if config.rails
218
+ config.boot ||= "config/environment"
219
+ unless config.daemon || config.explicit?(:strategy)
220
+ config.strategy = "redefine"
221
+ end
222
+ unless config.daemon
223
+ if config.jobs.to_i > 1
224
+ warn "[mutineer] --rails without --daemon runs serially (shared test DB); " \
225
+ "forcing --jobs 1. Use --daemon for safe --jobs N."
226
+ end
227
+ config.jobs = 1
228
+ end
229
+ end
230
+
231
+ # Auto-detect the framework only when the user wrote none: a value from
232
+ # either layer is already on config.framework and always wins. Default
233
+ # minitest unless the test files clearly look RSpec.
234
+ config.framework ||= detect_framework(config.tests)
235
+ config
236
+ end
237
+
238
+ # Pick rspec when a MAJORITY of the given test files end with _spec.rb;
239
+ # otherwise minitest. Empty/ambiguous -> minitest (the safe default).
240
+ #
241
+ # @param tests [Array<String>] test file paths.
242
+ # @return [String] `"rspec"` or `"minitest"`.
243
+ def self.detect_framework(tests)
244
+ tests = Array(tests)
245
+ specs = tests.count { |t| t.to_s.end_with?("_spec.rb") }
246
+ specs > tests.length / 2.0 ? "rspec" : "minitest"
247
+ end
248
+
249
+ # Maps a config key to its Struct field.
250
+ #
251
+ # @param known_key [String] config key.
252
+ # @return [Symbol] struct field name.
253
+ def self.field_for(known_key)
254
+ known_key == "require" ? :require_paths : known_key.to_sym
255
+ end
256
+
257
+ # Parses one raw value into the typed value for `field`. A value that does
258
+ # not fit its type raises ConfigError naming where it came from, so the CLI
259
+ # and .mutineer.yml report the same mistake in the same way. `nil` and
260
+ # `false` are valid results for some fields (`since: false` means "no scoping").
261
+ #
262
+ # @param field [Symbol] Config field name (a row of the option schema).
263
+ # @param value [Object] raw CLI string or YAML value.
264
+ # @param file [String, nil] config file name when the value came from it;
265
+ # nil when it came from the command line.
266
+ # @param defer_operators [Boolean] when true, a blank operator list is
267
+ # returned instead of raising, so +--operators+ can replace it. The flag
268
+ # itself still rejects a blank list.
269
+ # @return [Object] the typed value.
270
+ # @raise [Mutineer::ConfigError] when the value does not fit the field's type.
271
+ def self.parse(field, value, file: nil, defer_operators: false)
272
+ opt = CONFIG_OPTIONS.find { |o| o.field == field } or raise ArgumentError, "unknown option #{field.inspect}"
273
+ origin = file ? "#{file}: #{opt.yaml_key}" : opt.flag
274
+ got = "(got: #{value.inspect})"
275
+ case opt.type
276
+ when :positive_int
277
+ n = value.is_a?(Integer) ? value : (value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/))
278
+ raise ConfigError, "#{origin} must be a positive integer, digits only #{got}" if n.nil? || n < 1
279
+
280
+ n
281
+ when :percent
282
+ f = finite_float(value)
283
+ raise ConfigError, "#{origin} must be a number between 0 and 100 #{got}" unless f && (0.0..100.0).cover?(f)
284
+
285
+ f
286
+ when :nonneg_float
287
+ f = finite_float(value)
288
+ raise ConfigError, "#{origin} must be a finite number, 0 or greater #{got}" unless f && f >= 0.0
289
+
290
+ f
291
+ when :bool
292
+ return value if [true, false].include?(value)
293
+ return value == "true" if %w[true false].include?(value)
294
+
295
+ raise ConfigError, "#{origin} must be true or false #{got}"
296
+ when :enum
297
+ name = opt.aliases&.fetch(value, nil) || value
298
+ return name if opt.values.include?(name)
299
+
300
+ prefix = file ? "#{file}: " : ""
301
+ raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
302
+ when :string_list
303
+ items = Array(value).map(&:to_s)
304
+ # Only `operators` treats [] as "run these" rather than "use the
305
+ # default". A blank key then makes no mutants and exits 0. An empty
306
+ # `require` or `ignore` matches the default, so those stay valid.
307
+ if field == :operators && !defer_operators && ([nil, true, false].include?(value) || items.empty? || items.all? { |item| item.strip.empty? })
308
+ raise ConfigError, "#{origin} must name at least one operator, not blank #{got}"
309
+ end
310
+
311
+ items
312
+ when :string
313
+ # A key written with no value (`baseline:`) parses as nil. Keeping nil
314
+ # would switch the feature off without a word; main failed here, so the
315
+ # typo stays loud. An absent key never reaches parse, so it stays unset.
316
+ # `only: false` must not become the subject name "false" either: it
317
+ # matches nothing, so the run has no mutants and still exits 0.
318
+ raise ConfigError, "#{origin} must be a string #{got}" if [nil, true, false].include?(value)
319
+ # A blank cache directory (an unset CI variable) would put coverage.json
320
+ # in the project root, so it is an error, not the default.
321
+ raise ConfigError, "#{origin} must be a directory, not blank #{got}" if field == :cache_dir && value.to_s.strip.empty?
322
+
323
+ value.to_s
324
+ when :since
325
+ # `false` is the one way to say "no scoping" in the file. It becomes nil
326
+ # so every consumer's nil-check (runner scoping, the report's scoped
327
+ # marker) agrees; a false left raw would skip scoping but still mark
328
+ # the report scoped. A blank value is an error, not "no scoping": a
329
+ # `since: "$REF"` whose variable is unset must not turn scoping off.
330
+ return nil if value == false
331
+ raise ConfigError, "#{origin} must be a git ref, not blank #{got}" if value.to_s.strip.empty?
332
+
333
+ value.to_s
334
+ end
335
+ end
336
+
337
+ # Reads a finite Float from a number or a plain decimal string. Rejects booleans,
338
+ # NaN and Infinity: `Float(true)` is an error, but a YAML `.nan` is a Float.
339
+ # A string must be digits with an optional fraction, the same digits-only rule
340
+ # as `jobs`: `Float()` alone would also read `0x10`, `1_0`, `+2` and `1e2`.
341
+ #
342
+ # @api private
343
+ # @param value [Object] raw value.
344
+ # @return [Float, nil] the number, or nil when it is not a finite number.
345
+ def self.finite_float(value)
346
+ f = case value
347
+ when Integer, Float then value.to_f
348
+ when String then Float(value) if value.match?(/\A\d+(\.\d+)?\z/)
349
+ end
350
+ f if f&.finite?
351
+ end
352
+
353
+ # Drop (with a warning) operator names the registry does not know.
354
+ # Referenced lazily so config.rb carries no load-order dependency on the
355
+ # registry; by the time a config is parsed at runtime, it is loaded.
356
+ #
357
+ # @api private
358
+ # @param names [Array<String>] operator names.
359
+ # @param file_name [String] config file name for warnings.
360
+ # @return [Array<String>] known operator names.
361
+ def self.filter_operators(names, file_name)
362
+ known = MutatorRegistry::ALL.keys
363
+ names.select do |n|
364
+ next true if known.include?(n)
365
+
366
+ warn "mutineer: unknown operator #{n.inspect} in #{file_name} " \
367
+ "(known: #{known.join(', ')}); ignored"
368
+ false
369
+ end
370
+ end
371
+ end
372
+ end