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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_path"
4
+
5
+ module Mutineer
6
+ # Source -> test pairing by path convention (#11). Pure stdlib path logic:
7
+ # no Rails, no class loading, no process. Two jobs:
8
+ # * expand_sources — a directory argument becomes its sorted **/*.rb files;
9
+ # a file inside the root becomes its root-relative path.
10
+ # * infer_tests — a source's test files by convention (app/ and lib/
11
+ # sources map to test/.../_test.rb, test/.../<name>_*_test.rb,
12
+ # test/.../test_*.rb or spec/.../_spec.rb), preserving
13
+ # namespaced subdirectories. The first EXISTING exact
14
+ # candidate stays first. Split Minitest files join a
15
+ # Minitest match. A matched spec is left as that one file.
16
+ #
17
+ # Independently unit-testable: every method is pure in/out over the
18
+ # filesystem, so the pairing contract is exercised with plain fixtures, no
19
+ # Rails, no fork.
20
+ module Pairing
21
+ module_function
22
+
23
+ # Expand each positional source: a directory -> its sorted **/*.rb files
24
+ # (relative to project_root); a file (or glob, or anything non-directory)
25
+ # -> its path relative to project_root, so `./lib/x.rb` and an absolute
26
+ # path pair like `lib/x.rb` (#104). A missing file, or one outside the
27
+ # root, stays as typed. Flattened, deduped, order-stable.
28
+ #
29
+ # @param args [Array<String>] source paths or directories.
30
+ # @param project_root [String] repository root for relative expansion.
31
+ # @return [Array<String>] flattened, deduped source file list.
32
+ def expand_sources(args, project_root:)
33
+ root = File.expand_path(project_root)
34
+ # The root as typed and as its real path (macOS `/var` vs `/private/var`),
35
+ # resolved once for every file below.
36
+ roots = [root, ProjectPath.root_real(root)].uniq
37
+ Array(args).flat_map do |arg|
38
+ abs = File.expand_path(arg, root)
39
+ if File.directory?(abs)
40
+ Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| existing_relative(f, roots) || f }
41
+ else
42
+ # `..` resolves as text, as FileSwap and the daemon already read it.
43
+ [(existing_relative(abs, roots) if File.exist?(abs)) || arg]
44
+ end
45
+ end.uniq
46
+ end
47
+
48
+ # An existing file's root-relative path, or nil outside the root. Tries the
49
+ # path as given, then with its directory resolved, so a path typed through
50
+ # a symlinked parent (or macOS `/var` under a `/private/var` root) matches.
51
+ # The file's own name is kept, so a symlinked file is not renamed.
52
+ #
53
+ # @param abs [String] expanded absolute path of an existing file.
54
+ # @param roots [Array<String>] the project root, expanded and real.
55
+ # @return [String, nil] root-relative path, or nil outside the root.
56
+ def existing_relative(abs, roots)
57
+ root_relative(abs, roots) ||
58
+ root_relative(File.join(File.realpath(File.dirname(abs)), File.basename(abs)), roots)
59
+ end
60
+
61
+ # `abs` relative to the first of `roots` it is under, or nil. Text only: a
62
+ # symlinked source keeps its own name (realpath would rename it to its
63
+ # target and pair the wrong test). `roots` holds the root's real path too,
64
+ # so the macOS `/var` vs `/private/var` alias still matches.
65
+ #
66
+ # @param abs [String] expanded absolute path.
67
+ # @param roots [Array<String>] the project root, expanded and real.
68
+ # @return [String, nil] root-relative path, or nil outside the root.
69
+ def root_relative(abs, roots)
70
+ roots.each do |r|
71
+ prefix = r.end_with?("/") ? r : "#{r}/"
72
+ return abs.delete_prefix(prefix) if abs.start_with?(prefix)
73
+ end
74
+ nil
75
+ end
76
+
77
+ # The first test path {#infer_tests} would run for a source, or nil.
78
+ # `prefer` is the resolved framework ("minitest" | "rspec"): its candidates
79
+ # are tried first, the other framework's as fallback, so a minitest default
80
+ # still finds a spec and vice-versa.
81
+ #
82
+ # @param source_rel [String] relative source path.
83
+ # @param project_root [String] repository root for existence checks.
84
+ # @param prefer [String] preferred framework name.
85
+ # @return [String, nil] existing test path or nil.
86
+ def infer_test(source_rel, project_root:, prefer: "minitest")
87
+ infer_tests(source_rel, project_root: project_root, prefer: prefer).first
88
+ end
89
+
90
+ # Every test file convention pairs with a source. The first existing exact
91
+ # candidate from {#candidates} stays first. Each `<basename>_*_test.rb` in
92
+ # the mirrored test directory is added after it (#87). A spec that the exact
93
+ # rules already found is left alone: split Minitest names do not join it
94
+ # and do not replace it.
95
+ #
96
+ # @param source_rel [String] relative source path.
97
+ # @param project_root [String] repository root for existence checks.
98
+ # @param prefer [String] preferred framework name.
99
+ # @return [Array<String>] existing test paths, exact candidate first.
100
+ def infer_tests(source_rel, project_root:, prefer: "minitest")
101
+ base, lib = logical_path(source_rel)
102
+ root = File.expand_path(project_root)
103
+ exact = candidates(base, lib, prefer).find { |rel| File.exist?(File.expand_path(rel, root)) }
104
+ return [exact] if exact&.end_with?("_spec.rb")
105
+
106
+ ([exact] + split_test_files(base, lib, root)).compact.uniq
107
+ end
108
+
109
+ # Strip the source root to a logical path (no ".rb") and flag lib/
110
+ # sources. app/foo/bar.rb and lib/foo/bar.rb both -> "foo/bar"; anything
111
+ # else -> the path minus ".rb" (still attempted). Namespaced subdirs are
112
+ # preserved verbatim — structural, never constant resolution.
113
+ #
114
+ # @param source_rel [String] relative source path.
115
+ # @return [Array(String, Boolean)] logical base path and whether it came from lib/.
116
+ def logical_path(source_rel)
117
+ no_ext = source_rel.sub(/\.rb\z/, "")
118
+ if no_ext.start_with?("app/")
119
+ [no_ext.sub(%r{\Aapp/}, ""), false]
120
+ elsif no_ext.start_with?("lib/")
121
+ [no_ext.sub(%r{\Alib/}, ""), true]
122
+ else
123
+ [no_ext, false]
124
+ end
125
+ end
126
+
127
+ # Ordered candidate test paths: _test.rb, then Minitest's test_*.rb, then
128
+ # _spec.rb. lib/ sources also get the test/lib/... and spec/lib/... layouts.
129
+ #
130
+ # @param base [String] logical source path without extension.
131
+ # @param lib [Boolean] whether the source originated from lib/.
132
+ # @param prefer [String] preferred framework name.
133
+ # @return [Array<String>] candidate test paths in preferred order.
134
+ def candidates(base, lib, prefer)
135
+ minitest = ["test/#{base}_test.rb"]
136
+ minitest << "test/lib/#{base}_test.rb" if lib
137
+ unless File.basename(base) == "helper" # test/test_helper.rb is Minitest's support file, not a test
138
+ prefixed = base.sub(%r{[^/]+\z}) { |name| "test_#{name}" }
139
+ minitest << "test/#{prefixed}.rb"
140
+ minitest << "test/lib/#{prefixed}.rb" if lib
141
+ end
142
+ rspec = ["spec/#{base}_spec.rb"]
143
+ rspec << "spec/lib/#{base}_spec.rb" if lib
144
+ prefer == "rspec" ? rspec + minitest : minitest + rspec
145
+ end
146
+
147
+ # `<basename>_*_test.rb` files in each mirrored test directory (#87). The
148
+ # exact `<basename>_test.rb` name is not included. `lib/` sources also look
149
+ # under `test/lib/`. Names are matched as text, not as a glob, so a basename
150
+ # that contains `*` stays literal. `test/` comes before `test/lib/`, and
151
+ # each directory is sorted.
152
+ #
153
+ # @param base [String] logical source path without extension.
154
+ # @param lib [Boolean] whether the source originated from lib/.
155
+ # @param root [String] expanded project root.
156
+ # @return [Array<String>] relative split-test paths.
157
+ def split_test_files(base, lib, root)
158
+ name = File.basename(base)
159
+ dirs = [mirror_dir("test", base)]
160
+ dirs << mirror_dir("test/lib", base) if lib
161
+ dirs.flat_map { |dir| split_tests_in(root, dir, name, base) }
162
+ end
163
+
164
+ # Relative test directory for a logical source path. A source with no
165
+ # subdirectory (`calc`) maps to `prefix` itself, not `prefix/.`.
166
+ #
167
+ # @param prefix [String] `test` or `test/lib`.
168
+ # @param base [String] logical source path without extension.
169
+ # @return [String] relative directory.
170
+ def mirror_dir(prefix, base)
171
+ dir = File.dirname(base)
172
+ dir == "." ? prefix : File.join(prefix, dir)
173
+ end
174
+
175
+ # Split test files directly inside `dir_rel` (not in subdirectories).
176
+ #
177
+ # @param root [String] expanded project root.
178
+ # @param dir_rel [String] relative directory.
179
+ # @param name [String] source basename without extension.
180
+ # @param base [String] logical source path without extension.
181
+ # @return [Array<String>] sorted relative paths.
182
+ def split_tests_in(root, dir_rel, name, base)
183
+ root_abs = File.expand_path(root)
184
+ dir_abs = File.expand_path(dir_rel, root_abs)
185
+ return [] unless inside_project?(dir_abs, root_abs)
186
+ return [] unless File.directory?(dir_abs)
187
+
188
+ Dir.children(dir_abs).filter_map do |entry|
189
+ next unless split_entry?(entry, name)
190
+ next unless File.file?(File.join(dir_abs, entry))
191
+ next if claimed_by_longer_source?(root, base, entry, dir_rel)
192
+
193
+ File.join(dir_rel, entry)
194
+ end.sort
195
+ end
196
+
197
+ # True when `dir_abs` is the project root or a directory inside it.
198
+ # A source path such as `../../evil.rb` must not list tests outside the project.
199
+ #
200
+ # @param dir_abs [String] expanded directory.
201
+ # @param root_abs [String] expanded project root.
202
+ # @return [Boolean]
203
+ def inside_project?(dir_abs, root_abs)
204
+ prefix = root_abs == File::SEPARATOR ? File::SEPARATOR : "#{root_abs}#{File::SEPARATOR}"
205
+ dir_abs == root_abs || dir_abs.start_with?(prefix)
206
+ end
207
+
208
+ # True when `entry` is `<name>_<piece>_test.rb` and not the exact
209
+ # `<name>_test.rb` file.
210
+ #
211
+ # @param entry [String] test file basename.
212
+ # @param name [String] source basename without extension.
213
+ # @return [Boolean]
214
+ def split_entry?(entry, name)
215
+ return false unless entry.end_with?("_test.rb")
216
+
217
+ rest = entry.delete_prefix("#{name}_")
218
+ rest != entry && rest.match?(/\A.+_test\.rb\z/)
219
+ end
220
+
221
+ # True when a longer source file on disk owns this split test. `user.rb`
222
+ # does not take `user_session_test.rb` when `user_session.rb` exists in the
223
+ # same directory, under `app/`, or under `lib/`. An intermediate file
224
+ # counts: `bar_upsert.rb` owns `bar_upsert_guards_test.rb`, so `bar.rb`
225
+ # does not.
226
+ #
227
+ # @param root [String] expanded project root.
228
+ # @param base [String] logical source path without extension.
229
+ # @param entry [String] test file basename.
230
+ # @param scanned_dir [String, nil] relative test directory being scanned.
231
+ # When set, a longer source claims the file only if that source searches
232
+ # this directory. An `app/` source does not take a `test/lib/` file.
233
+ # @return [Boolean]
234
+ def claimed_by_longer_source?(root, base, entry, scanned_dir = nil)
235
+ name = File.basename(base)
236
+ stem = entry.sub(/_test\.rb\z/, "")
237
+ return false unless stem.start_with?("#{name}_")
238
+
239
+ rest = stem.delete_prefix("#{name}_")
240
+ return false if rest.empty?
241
+
242
+ dir = File.dirname(base)
243
+ parts = rest.split("_", -1)
244
+ (1..parts.length).any? do |i|
245
+ longer = "#{name}_#{parts.first(i).join("_")}"
246
+ next false unless longer_owns_entry?(entry, longer)
247
+
248
+ longer_source_exists?(root, dir, longer, scanned_dir)
249
+ end
250
+ end
251
+
252
+ # True when `entry` is the exact test, or a split test with a non-empty
253
+ # piece, for `stem`. `user_session__test.rb` is neither for
254
+ # `user_session`, so that source does not take it from `user.rb`.
255
+ #
256
+ # @param entry [String] test file basename.
257
+ # @param stem [String] longer source basename without extension.
258
+ # @return [Boolean]
259
+ def longer_owns_entry?(entry, stem)
260
+ entry == "#{stem}_test.rb" || split_entry?(entry, stem)
261
+ end
262
+
263
+ # True when a longer source file exists beside this logical path.
264
+ # The logical directory itself is checked, so `src/user_session.rb` owns
265
+ # `test/src/user_session_test.rb`. `app/` and `lib/` are checked too, so an
266
+ # `app/` source still yields the name to a `lib/` sibling and the reverse.
267
+ #
268
+ # @param root [String] expanded project root.
269
+ # @param dir [String] logical directory, or `.` when the source has none.
270
+ # @param stem [String] longer source basename without extension.
271
+ # @param scanned_dir [String, nil] relative test directory being scanned.
272
+ # @return [Boolean]
273
+ def longer_source_exists?(root, dir, stem, scanned_dir = nil)
274
+ file = "#{stem}.rb"
275
+ folders = [dir == "." ? nil : dir]
276
+ %w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
277
+ folders.uniq.any? do |folder|
278
+ rel = folder ? File.join(folder, file) : file
279
+ next false unless File.file?(File.join(root, rel))
280
+ next false if scanned_dir && !source_searches_dir?(rel, scanned_dir)
281
+
282
+ true
283
+ end
284
+ end
285
+
286
+ # True when pairing this source looks in `scanned_dir`.
287
+ #
288
+ # @param source_rel [String] project-relative source path.
289
+ # @param scanned_dir [String] relative test directory.
290
+ # @return [Boolean]
291
+ def source_searches_dir?(source_rel, scanned_dir)
292
+ base, lib = logical_path(source_rel)
293
+ dirs = [mirror_dir("test", base)]
294
+ dirs << mirror_dir("test/lib", base) if lib
295
+ dirs.include?(scanned_dir)
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutineer
6
+ # Raised only for I/O failures while reading a source file.
7
+ #
8
+ # Prism syntax errors are NOT raised — they are in-band via ParseResult#errors.
9
+ class ParseError < StandardError; end
10
+
11
+ # Thin boundary around Prism.
12
+ #
13
+ # The parse methods return a Prism::ParseResult so all callers use
14
+ # result.value, result.source.source (raw bytes), and result.errors
15
+ # uniformly. No wrapping struct.
16
+ class Parser
17
+ # Parses a file with Prism.
18
+ #
19
+ # @param path [String] source file path.
20
+ # @return [Prism::ParseResult] Prism parse result.
21
+ # @raise [Mutineer::ParseError] when file I/O fails.
22
+ def self.parse_file(path)
23
+ Prism.parse_file(path)
24
+ rescue SystemCallError => e
25
+ raise ParseError, e.message
26
+ end
27
+
28
+ # Parses source text with Prism.
29
+ #
30
+ # @param source [String] source text.
31
+ # @return [Prism::ParseResult] Prism parse result.
32
+ def self.parse_string(source)
33
+ Prism.parse(source)
34
+ end
35
+
36
+ # The comments of source text, without building its syntax tree: a few
37
+ # times cheaper than {.parse_string} for a caller that needs only these.
38
+ #
39
+ # @param source [String] source text.
40
+ # @return [Array<Prism::Comment>] its comments, in source order.
41
+ def self.comments(source)
42
+ Prism.parse_comments(source)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Coarse run progress on stderr: one line per 10% step of completed mutants
5
+ # (which means every completion on a run smaller than ten). Shared by every
6
+ # backend so a long run is never silent between config resolution and the
7
+ # report. Thread-safe so the daemon's parallel workers can share one instance.
8
+ # Writes only to stderr, keeping `--format json` stdout and `--output` files
9
+ # byte-exact per the Reporter's stream contract.
10
+ class Progress
11
+ # Builds a progress counter for a run of `total` mutants.
12
+ #
13
+ # @param total [Integer] number of mutants that will run.
14
+ # @param io [IO] destination stream (defaults to stderr; injectable for tests).
15
+ def initialize(total, io: $stderr)
16
+ @total = total
17
+ @done = 0
18
+ @last_step = 0
19
+ @mutex = Mutex.new
20
+ @io = io
21
+ end
22
+
23
+ # Records one completed mutant and prints when a 10% boundary is crossed.
24
+ # A zero-total counter never prints (guards the division, and an empty run
25
+ # has nothing to report).
26
+ #
27
+ # @return [void]
28
+ def tick
29
+ @mutex.synchronize do
30
+ return if @total.zero?
31
+
32
+ @done += 1
33
+ step = (@done * 10) / @total
34
+ return if step <= @last_step
35
+
36
+ @last_step = step
37
+ @io.puts "[mutineer] #{@done}/#{@total} mutants (#{(@done * 100) / @total}%)"
38
+ end
39
+ rescue IOError, Errno::EPIPE
40
+ # Progress is best-effort: a closed stderr (piped consumer went away) must
41
+ # never kill the run — in the daemon's worker threads a raise here would
42
+ # propagate through Thread#join and abort scoring.
43
+ nil
44
+ end
45
+ end
46
+ end