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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "minitest_integration/stop_at_first_failure"
4
+ require_relative "minitest_integration/kill_recorder"
5
+
6
+ module Mutineer
7
+ # Child-process-only: loads a test file in the current process and runs it
8
+ # programmatically, returning an exit status integer (0 = all passed,
9
+ # 1 = any failure/error).
10
+ #
11
+ # Never call this in the parent — it manipulates global Minitest state
12
+ # (autorun, runnables) that only makes sense in a throwaway forked child.
13
+ #
14
+ # A missing minitest is rescued as FrameworkUnavailable (Isolation.run
15
+ # still turns that raise into exit 2). There is no rescue around the
16
+ # suite run itself: Isolation.run's fork block is the single exception
17
+ # boundary for unexpected errors. Swallowing those here would create a
18
+ # second exit-2 path and break this method's 0/1 return contract.
19
+ class MinitestIntegration
20
+ # The seed of a mutant run (one that stops at the first failure, or a
21
+ # `--matrix` run that records every test), unless `SEED` is set. With the
22
+ # stop, the test order can decide `killed` vs `timeout`; a fixed order keeps
23
+ # the verdict stable for a `--baseline` gate. A matrix run uses the same
24
+ # order, so its first failure comes at the same point as the stop's.
25
+ STOP_AT_FIRST_FAILURE_SEED = 1
26
+
27
+ # Tested via runner_test.rb, not in isolation — a direct unit test
28
+ # would require forking and duplicate isolation_test's coverage.
29
+ #
30
+ # `test_files` is one path or an Array of paths (coverage selection
31
+ # passes the covering subset); each is loaded before the single
32
+ # Minitest.run.
33
+ #
34
+ # @param test_files [String, Array<String>] one file or many files.
35
+ # @param stop_at_first_failure [Boolean] end the run at the first failing
36
+ # test. Only the mutant path passes true.
37
+ # @param record_to [IO, nil] a `--matrix` run: send each test's outcome to
38
+ # this channel (see {KillRecorder}) and run every test.
39
+ # @return [Integer] 0 on success, 1 on failure.
40
+ # @raise [ArgumentError] when both options are given: a matrix run never stops.
41
+ def self.run(test_files, stop_at_first_failure: false, record_to: nil)
42
+ if stop_at_first_failure && record_to
43
+ raise ArgumentError, "stop_at_first_failure and record_to cannot be combined: a matrix run runs every test"
44
+ end
45
+
46
+ begin
47
+ require "minitest"
48
+ rescue LoadError
49
+ raise Mutineer::FrameworkUnavailable,
50
+ "minitest is not available — add minitest to the project under test, " \
51
+ "or use --framework rspec"
52
+ end
53
+
54
+ # minitest is required LAZILY (never at load time) so Mutineer keeps
55
+ # zero runtime gem deps and loads fine in an rspec-only project; a missing
56
+ # minitest raises a clear Mutineer error rather than a LoadError backtrace.
57
+
58
+ # Neutralise autorun so a test file's `require "minitest/autorun"`
59
+ # registers no at_exit hook.
60
+ def Minitest.autorun; end # rubocop:disable Lint/NestedMethodDefinition
61
+
62
+ # Drop runnables inherited from the parent suite (this is the child's
63
+ # private copy — the parent is unaffected) so only the target test runs.
64
+ Minitest::Runnable.reset
65
+ # Each test class's file position: the file that first defined it.
66
+ rank = {}
67
+ Array(test_files).each_with_index do |f, i|
68
+ load f
69
+ Minitest::Runnable.runnables.each { |klass| rank[klass] ||= i }
70
+ end
71
+
72
+ armed =
73
+ if record_to
74
+ KillRecorder.arm!(record_to, Minitest::Runnable.runnables)
75
+ elsif stop_at_first_failure
76
+ StopAtFirstFailure.arm!(Minitest::Runnable.runnables)
77
+ end
78
+ # Pin the seed only when a hook is armed; an unknown Minitest shape gets
79
+ # the normal full, randomly ordered run.
80
+ args = armed && !ENV["SEED"] ? ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] : []
81
+ keep_file_order(rank) if armed
82
+ # No silencing here: the fork boundary that calls this method has already
83
+ # pointed stdout at File::NULL (see ChildStdout).
84
+ passed = Minitest.run(args)
85
+ # Reached only when the suite returned: a test that exits the process
86
+ # skips it, and the parent then keeps the row incomplete.
87
+ KillRecorder.finish! if record_to
88
+
89
+ # A plugin can replace the summary reporter, so a stop decides by itself.
90
+ passed && !StopAtFirstFailure.stopped_here? ? 0 : 1
91
+ ensure
92
+ StopAtFirstFailure.disarm!
93
+ KillRecorder.disarm!
94
+ end
95
+
96
+ # Runs the test classes in the order of their files (#203). Minitest
97
+ # shuffles the classes with the seed, then runs the serial classes before
98
+ # the parallel (`parallelize_me!`) ones. A stable sort by file position
99
+ # after that shuffle puts the files in the given order and keeps the
100
+ # seeded order within one file. The serial-then-parallel split is
101
+ # unchanged. Only the child's own class list gets {FileOrder}.
102
+ #
103
+ # @api private
104
+ # @param rank [Hash{Class => Integer}] each class's file position.
105
+ # @return [void]
106
+ def self.keep_file_order(rank)
107
+ FileOrder.rank = rank
108
+ ::Minitest::Runnable.runnables.extend(FileOrder)
109
+ end
110
+
111
+ # Extends the list of test classes: its `shuffle` keeps the file order.
112
+ module FileOrder
113
+ class << self
114
+ # Each test class's file position.
115
+ #
116
+ # @return [Hash{Class => Integer}, nil]
117
+ attr_accessor :rank
118
+ end
119
+
120
+ # The seeded shuffle, then a stable sort by file position.
121
+ #
122
+ # @return [Array<Class>]
123
+ def shuffle(...)
124
+ rank = FileOrder.rank || {}
125
+ super.sort_by.with_index { |klass, i| [rank.fetch(klass, rank.size), i] }
126
+ end
127
+
128
+ # Minitest 5.15 and older drop empty classes before the shuffle, so the
129
+ # filtered list keeps the file order too.
130
+ #
131
+ # @return [Array<Class>]
132
+ def reject(...)
133
+ super.extend(FileOrder)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_runners/rspec"
4
+
5
+ module Mutineer
6
+ # Separates MongoDB infrastructure failures from assertions that kill mutants.
7
+ # Loaded only in daemon children. Duplicate-key and other application errors
8
+ # still kill mutants; connection failures and server failovers do not.
9
+ module MongoidFailures
10
+ # Driver errors describing unavailable connections or authentication.
11
+ CONNECTION_ERRORS = %w[
12
+ Mongo::Error::SocketError Mongo::Error::SocketTimeoutError
13
+ Mongo::Error::NoServerAvailable Mongo::Error::ConnectionCheckOutTimeout
14
+ Mongo::Error::ConnectionUnavailable Mongo::Error::ConnectionPerished
15
+ Mongo::Error::PoolError Mongo::Error::TimeoutError
16
+ Mongo::Error::NoServiceConnectionAvailable Mongo::Error::MissingConnection
17
+ Mongo::Error::ClientClosed Mongo::Error::AuthError
18
+ ].freeze
19
+ # Retryable server/network failures; excludes duplicate-key and validation codes.
20
+ SERVER_CODES = [6, 7, 89, 91, 189, 10107, 11600, 11602, 13435, 13436].freeze
21
+
22
+ # Observe both examples and suite hooks before RSpec flattens their failures.
23
+ # @param tests [Array<String>] covering spec files.
24
+ # @return [Integer] 0 survived, 1 assertion/application failure, 2 infrastructure.
25
+ def self.run(tests)
26
+ require "rspec/core"
27
+ ::RSpec::Core::Reporter.prepend(Reporter) unless ::RSpec::Core::Reporter < Reporter
28
+ @failure = nil
29
+ status = TestRunners::RSpec.run(tests)
30
+ if @failure
31
+ warn "[mutineer] MongoDB infrastructure failure: #{@failure.class}"
32
+ 2
33
+ else
34
+ status
35
+ end
36
+ end
37
+
38
+ # Inspect aggregate failures and exception causes without looping on a cycle.
39
+ # @param exception [Exception, nil] failure reported by RSpec.
40
+ # @return [void]
41
+ def self.record(exception)
42
+ pending = [exception].compact
43
+ seen = {}
44
+ until pending.empty?
45
+ error = pending.pop
46
+ next if seen[error.object_id]
47
+
48
+ seen[error.object_id] = true
49
+ @failure = error if infrastructure_error?(error)
50
+ pending << error.cause if error.cause
51
+ pending.concat(error.all_exceptions) if error.respond_to?(:all_exceptions)
52
+ end
53
+ end
54
+
55
+ # Match actual driver classes, including subclasses, without requiring Mongo.
56
+ # @param error [Exception] reported exception.
57
+ # @return [Boolean]
58
+ def self.infrastructure_error?(error)
59
+ names = error.class.ancestors.map(&:name)
60
+ CONNECTION_ERRORS.any? { |name| names.include?(name) } ||
61
+ (names.include?("Mongo::Error::OperationFailure") && SERVER_CODES.include?(error.code))
62
+ end
63
+
64
+ # RSpec reports suite-hook exceptions separately from failed examples.
65
+ module Reporter
66
+ # @param example [RSpec::Core::Example] failed example.
67
+ # @return [Object] RSpec's notification result.
68
+ def example_failed(example)
69
+ MongoidFailures.record(example.exception)
70
+ super
71
+ end
72
+
73
+ # @param exception [Exception] exception raised outside an example.
74
+ # @param context_description [String] RSpec's hook description.
75
+ # @return [Object] RSpec's notification result.
76
+ def notify_non_example_exception(exception, context_description)
77
+ MongoidFailures.record(exception)
78
+ super
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+
6
+ module Mutineer
7
+ # Isolates Mongoid test runs in disposable databases, one per fork. Uses only
8
+ # the application's Mongoid and driver; neither is a runtime gem dependency.
9
+ # The boot-time snapshot includes documents and indexes. Every child starts
10
+ # from that snapshot, including after a killed or timed-out mutant.
11
+ class MongoidWorkerDb
12
+ # True only when the application has already loaded Mongoid.
13
+ # @return [Boolean]
14
+ def self.available?
15
+ defined?(Mongoid::Clients) ? true : false
16
+ end
17
+
18
+ # Capture the test database without writing to it. Multiple clients and
19
+ # mixed ActiveRecord/Mongoid applications fail before any suite can run.
20
+ # @raise [RuntimeError] for an unsupported or non-test configuration.
21
+ def initialize
22
+ raise "Mongoid worker databases require RAILS_ENV=test" unless ENV["RAILS_ENV"] == "test"
23
+ unless Mongoid.clients.keys.map(&:to_s) == ["default"]
24
+ raise "Mongoid worker databases support one default client only"
25
+ end
26
+ if defined?(ActiveRecord::Base)
27
+ raise "Mongoid worker databases do not support mixed ActiveRecord/Mongoid applications"
28
+ end
29
+
30
+ @client = Mongoid.default_client
31
+ source = Digest::SHA256.hexdigest(@client.database.name)[0, 8]
32
+ @prefix = "mutineer_#{source}_#{SecureRandom.hex(8)}_"
33
+ @snapshot = @client.database.list_collections.map do |info|
34
+ options = info.fetch("options", {})
35
+ if info["type"] != "collection" || options.key?("timeseries")
36
+ raise "Mongoid worker databases support ordinary collections only (#{info['name']})"
37
+ end
38
+
39
+ collection = @client[info.fetch("name")]
40
+ indexes = collection.indexes.to_a.reject { |index| index["name"] == "_id_" }
41
+ [collection.name, options, collection.find.to_a, indexes]
42
+ end
43
+ before_fork
44
+ end
45
+
46
+ # Seed documents, indexes and collection options all affect coverage.
47
+ # @return [String] opaque digest; no database contents are persisted in the cache.
48
+ def coverage_cache_key
49
+ Digest::SHA256.hexdigest(Marshal.dump(@snapshot))
50
+ end
51
+
52
+ # Close parent sockets before a fork, as required by the Mongoid driver.
53
+ # @return [void]
54
+ def before_fork
55
+ Mongoid.disconnect_clients
56
+ end
57
+
58
+ # Route the child before any specs load; failures become infrastructure
59
+ # errors rather than mutation kills. A fresh database needs no schema.rb.
60
+ # @param worker [Integer] worker slot (unused: process ids isolate children).
61
+ # @param schema_path [String, nil] ActiveRecord compatibility argument.
62
+ # @param seed [Boolean] ActiveRecord compatibility argument; always seeds.
63
+ # @return [void]
64
+ def after_fork(worker = 0, schema_path = nil, seed: true)
65
+ Mongoid.reconnect_clients
66
+ client = @client.use(database_name(Process.pid))
67
+ client.database.command(ping: 1)
68
+ Mongoid::Clients.set(:default, client)
69
+ Mongoid.override_database(client.database.name)
70
+ @snapshot.each do |name, options, documents, indexes|
71
+ client[name].create(**options.transform_keys(&:to_sym))
72
+ client[name].insert_many(documents) unless documents.empty?
73
+ indexes.each do |index|
74
+ attributes = index.reject { |key, _| %w[key v ns].include?(key) }.transform_keys(&:to_sym)
75
+ client[name].indexes.create_one(index.fetch("key"), **attributes)
76
+ end
77
+ end
78
+ end
79
+
80
+ # Parent-side cleanup runs after the child is reaped, even on timeout.
81
+ # Only this instance's random database prefix can be targeted.
82
+ # @param pid [Integer] the reaped child's process id.
83
+ # @return [void]
84
+ def cleanup(pid)
85
+ @client.use(database_name(pid)).database.drop
86
+ ensure
87
+ before_fork
88
+ end
89
+
90
+ # Exact disposable name for one child. Never accepts a database name.
91
+ # @param pid [Integer] child process id.
92
+ # @return [String]
93
+ def database_name(pid)
94
+ "#{@prefix}#{Integer(pid)}"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Mutineer
6
+ # Content-based id for a mutant — NOT byte offsets. Pure function, reused
7
+ # by the Runner (matching the ignore list), the Reporter (emitting a copy-
8
+ # pasteable id per survivor), and #13 baseline gating (diffing id-sets run to
9
+ # run). `digest` is stdlib, so zero new deps.
10
+ #
11
+ # Offset-free by design: keyed on the subject's project-relative file path +
12
+ # qualified_name (a method, not a byte position) + operator + the normalized
13
+ # mutated token + an occurrence ordinal among same-(operator, token) twins
14
+ # WITHIN the subject + (only when positive) the subject's ordinal among
15
+ # same-named subjects in its file. Unrelated edits that only shift byte offsets
16
+ # can preserve the id. File moves, renames, project-root changes, and changes to
17
+ # repeated-name or repeated-mutation order can change it.
18
+ module MutantId
19
+ module_function
20
+
21
+ # Computes the stable id for a single mutant.
22
+ #
23
+ # NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
24
+ # with the separator; `SHA256[0,12]` gives a fixed-length, copy-pasteable key.
25
+ # The file path is part of the key, so the same qualified name in two files
26
+ # (an owner-less `def`, or a class reopened elsewhere) cannot collide. `path`
27
+ # is required so no caller can get the colliding legacy id by accident.
28
+ #
29
+ # @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
30
+ # its `qualified_name` anchors the id to a method rather than a byte position.
31
+ # @param mutation [Mutineer::Mutation] the atomic edit whose operator is hashed.
32
+ # @param source [String] the full, unmutated source the mutation indexes into.
33
+ # @param occurrence [Integer] 0-based ordinal among twins sharing the same
34
+ # (operator, token) within the subject, disambiguating otherwise-identical mutants.
35
+ # @param path [String] the subject's file, normalized with {ProjectPath.relative}
36
+ # against the project root (an absolute real path when outside the root).
37
+ # @param subject_ordinal [Integer] 0-based ordinal among subjects in the same
38
+ # file sharing this qualified name (two owner-less `def index` in two DSL
39
+ # blocks). Hashed only when positive, so a subject whose name is unique in
40
+ # its file keeps the id it had without it.
41
+ # @return [String] a 12-character hex id, stable while its keyed identity and
42
+ # occurrence order stay unchanged.
43
+ def for(subject, mutation, source, occurrence = 0, path:, subject_ordinal: 0)
44
+ parts = [path, subject.qualified_name, mutation.operator, normalized_token(mutation, source), occurrence]
45
+ parts << subject_ordinal if subject_ordinal.positive?
46
+ digest(parts)
47
+ end
48
+
49
+ # Computes ids for a subject's full mutation list, in input order, assigning
50
+ # each its 0-based occurrence among twins sharing the same (operator, token).
51
+ # This is what disambiguates `a + b + c`'s two `+` mutants without an offset.
52
+ #
53
+ # @param subject [Mutineer::Subject] the subject the mutations belong to.
54
+ # @param source [String] the full, unmutated source for token normalization.
55
+ # @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
56
+ # @param path [String] the subject's normalized file path (see {.for}).
57
+ # @param subject_ordinal [Integer] the subject's ordinal among same-named
58
+ # subjects in its file (see {.for}).
59
+ # @return [Array<String>] one 12-character id per mutation, positionally aligned.
60
+ def for_subject(subject, source, mutations, path:, subject_ordinal: 0)
61
+ with_occurrences(mutations, source) do |m, occ|
62
+ self.for(subject, m, source, occ, path: path, subject_ordinal: subject_ordinal)
63
+ end
64
+ end
65
+
66
+ # The pre-1.3 id: the {.for} formula without the path, so it collides across
67
+ # files. Kept only to match ignore entries and baselines stored in the old
68
+ # format; retained for migration compatibility.
69
+ #
70
+ # @param subject [Mutineer::Subject] the subject (method) the mutant lives in.
71
+ # @param mutation [Mutineer::Mutation] the atomic edit whose operator is hashed.
72
+ # @param source [String] the full, unmutated source the mutation indexes into.
73
+ # @param occurrence [Integer] 0-based ordinal among same-(operator, token) twins.
74
+ # @return [String] a 12-character hex id in the old format.
75
+ def legacy_for(subject, mutation, source, occurrence = 0)
76
+ digest([subject.qualified_name, mutation.operator,
77
+ normalized_token(mutation, source), occurrence])
78
+ end
79
+
80
+ # {.for_subject} for the pre-1.3 id format (see {.legacy_for}).
81
+ #
82
+ # @param subject [Mutineer::Subject] the subject the mutations belong to.
83
+ # @param source [String] the full, unmutated source for token normalization.
84
+ # @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
85
+ # @return [Array<String>] one old-format id per mutation, positionally aligned.
86
+ def legacy_for_subject(subject, source, mutations)
87
+ with_occurrences(mutations, source) { |m, occ| legacy_for(subject, m, source, occ) }
88
+ end
89
+
90
+ # Maps each mutation to the block's result, passing its 0-based occurrence
91
+ # among earlier mutations with the same (operator, token).
92
+ #
93
+ # @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
94
+ # @param source [String] the full, unmutated source for token normalization.
95
+ # @yieldparam mutation [Mutineer::Mutation] the current mutation.
96
+ # @yieldparam occurrence [Integer] its ordinal among same-(operator, token) twins.
97
+ # @return [Array] the block's results, positionally aligned.
98
+ def with_occurrences(mutations, source)
99
+ seen = Hash.new(0)
100
+ mutations.map do |m|
101
+ key = [m.operator, normalized_token(m, source)]
102
+ occ = seen[key]
103
+ seen[key] += 1
104
+ yield m, occ
105
+ end
106
+ end
107
+
108
+ # NUL-joins the id parts and returns the first 12 hex chars of their SHA256.
109
+ #
110
+ # @param parts [Array] the values that make up the id.
111
+ # @return [String] a 12-character hex id.
112
+ def digest(parts)
113
+ Digest::SHA256.hexdigest(parts.join("\x00"))[0, 12]
114
+ end
115
+
116
+ # Extracts the exact code being mutated, whitespace-collapsed — the same
117
+ # normalization the Reporter's `diff_for` uses for its token label.
118
+ #
119
+ # @param mutation [Mutineer::Mutation] supplies the byte range to slice.
120
+ # @param source [String] the source to byteslice (byte offsets, never char).
121
+ # @return [String] the mutated token with runs of whitespace collapsed to one space.
122
+ def normalized_token(mutation, source)
123
+ source.byteslice(mutation.start_offset...mutation.end_offset).gsub(/\s+/, " ").strip
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+
5
+ module Mutineer
6
+ # One atomic byte-range edit.
7
+ #
8
+ # Immutable. One mutation per mutant — never combine. Source is mutated
9
+ # textually, never regenerated from the AST.
10
+ Mutation = Data.define(:start_offset, :end_offset, :replacement, :operator) do
11
+ # Applies the mutation to source text.
12
+ #
13
+ # @param source [String] original source text.
14
+ # @return [String] mutated source text.
15
+ def apply(source)
16
+ source.byteslice(0, start_offset) + replacement + source.byteslice(end_offset..)
17
+ end
18
+
19
+ # Checks whether the mutated source still parses cleanly.
20
+ #
21
+ # @param source [String] original source text.
22
+ # @return [Boolean] true when the mutated source re-parses without errors.
23
+ def valid?(source)
24
+ Parser.parse_string(apply(source)).errors.empty?
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mutators/arithmetic"
4
+ require_relative "mutators/comparison"
5
+ require_relative "mutators/boolean_connector"
6
+ require_relative "mutators/boolean_literal"
7
+ require_relative "mutators/statement_removal"
8
+ require_relative "mutators/return_nil"
9
+ require_relative "mutators/literal_mutation"
10
+ require_relative "mutators/condition_negation"
11
+ require_relative "mutators/string_literal"
12
+ require_relative "mutators/regex_literal"
13
+ require_relative "mutators/collection_method"
14
+ require_relative "mutators/safe_navigation"
15
+ require_relative "mutators/range_literal"
16
+ require_relative "mutators/negation_removal"
17
+ require_relative "mutators/chain_link"
18
+ require_relative "mutators/operand_removal"
19
+ require_relative "mutators/array_literal"
20
+ require_relative "mutators/condition_forcing"
21
+ require_relative "mutators/operator_assignment"
22
+
23
+ module Mutineer
24
+ # Maps operator names to operator classes.
25
+ #
26
+ # DEFAULT_NAMES is the v1 default set (Tier-1 plus statement-removal).
27
+ # The Tier-2 operators live in ALL but are OFF by default — they only
28
+ # run when named via `--operators` or `operators:` in `.mutineer.yml`.
29
+ # Keeping DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps
30
+ # the default survivor set unchanged.
31
+ class MutatorRegistry
32
+ # All available mutator classes keyed by operator name.
33
+ ALL = {
34
+ "arithmetic" => Mutators::Arithmetic,
35
+ "comparison" => Mutators::Comparison,
36
+ "boolean_connector" => Mutators::BooleanConnector,
37
+ "boolean_literal" => Mutators::BooleanLiteral,
38
+ "statement_removal" => Mutators::StatementRemoval,
39
+ "return_nil" => Mutators::ReturnNil,
40
+ "literal_mutation" => Mutators::LiteralMutation,
41
+ "condition_negation" => Mutators::ConditionNegation,
42
+ "string_literal" => Mutators::StringLiteral,
43
+ "regex" => Mutators::RegexLiteral,
44
+ "collection_method" => Mutators::CollectionMethod,
45
+ "safe_navigation" => Mutators::SafeNavigation,
46
+ "range" => Mutators::RangeLiteral,
47
+ "negation_removal" => Mutators::NegationRemoval,
48
+ "chain_link" => Mutators::ChainLink,
49
+ "operand_removal" => Mutators::OperandRemoval,
50
+ "array_literal" => Mutators::ArrayLiteral,
51
+ "condition_true" => Mutators::ConditionTrue,
52
+ "condition_false" => Mutators::ConditionFalse,
53
+ "operator_assignment" => Mutators::OperatorAssignment
54
+ }.freeze
55
+
56
+ # The default Tier-1 operator set.
57
+ DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
58
+ # Tier-2 operators that remain opt-in.
59
+ TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
60
+ safe_navigation range negation_removal
61
+ chain_link operand_removal array_literal
62
+ condition_true condition_false operator_assignment].freeze
63
+
64
+ # Short human-readable descriptions for each operator.
65
+ DESCRIPTIONS = {
66
+ "arithmetic" => "+ <-> -, * <-> /, % -> *, ** -> *",
67
+ "comparison" => "< <-> <=, > <-> >=, == <-> !=",
68
+ "boolean_connector" => "&& <-> ||",
69
+ "boolean_literal" => "true <-> false, nil -> true",
70
+ "statement_removal" => "replace a non-final statement with nil",
71
+ "return_nil" => "replace a return / final expression with nil",
72
+ "literal_mutation" => "integer -> 0, 1, n+1; string -> empty",
73
+ "condition_negation" => "wrap if/unless/ternary condition in !( ... )",
74
+ "string_literal" => "non-empty string -> \"\", empty string -> \"mutineer\"",
75
+ "regex" => "drop leading ^ / trailing $, swap + <-> *",
76
+ "collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject",
77
+ "safe_navigation" => "&. -> .",
78
+ "range" => ".. <-> ...",
79
+ "negation_removal" => "!x, not x -> x",
80
+ "chain_link" => "drop one call from a chain: a.b.c -> a.c",
81
+ "operand_removal" => "a && b -> a, b",
82
+ "array_literal" => "[a, b] -> []",
83
+ "condition_true" => "if/elsif/unless/ternary/modifier/case-in guard condition -> true",
84
+ "condition_false" => "if/elsif/unless/ternary/modifier/case-in guard condition -> false",
85
+ "operator_assignment" => "+= <-> -=, *= <-> /=, %= -> *=, **= -> *= (not ||=, &&=)"
86
+ }.freeze
87
+
88
+ # Resolves operator names to classes.
89
+ #
90
+ # @param names [Array<String>] operator names to resolve.
91
+ # @return [Array<Class>] mutator classes in the requested order.
92
+ # @raise [ArgumentError] when a name is unknown.
93
+ def self.resolve(names = DEFAULT_NAMES)
94
+ names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
95
+ end
96
+
97
+ # Returns whether the operator is part of the default Tier-1 set.
98
+ #
99
+ # @param name [String] operator name.
100
+ # @return [Boolean] true when the operator is default.
101
+ def self.default?(name) = DEFAULT_NAMES.include?(name)
102
+
103
+ # Returns the tier number for an operator name.
104
+ #
105
+ # @param name [String] operator name.
106
+ # @return [Integer] 2 for Tier-2 operators, otherwise 1.
107
+ def self.tier(name) = TIER2_NAMES.include?(name) ? 2 : 1
108
+ end
109
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Arithmetic operator mutator.
8
+ #
9
+ # One mutation per occurrence, rewriting the operator token.
10
+ class Arithmetic < Base
11
+ # Token replacements for arithmetic operators.
12
+ REPLACEMENTS = {
13
+ :+ => "-", :- => "+", :* => "/", :/ => "*", :% => "*", :** => "*"
14
+ }.freeze
15
+
16
+ # Visits call nodes and emits arithmetic mutations.
17
+ #
18
+ # @param node [Prism::CallNode] call node to inspect.
19
+ # @return [void]
20
+ def visit_call_node(node)
21
+ replacement = REPLACEMENTS[node.name]
22
+ loc = node.message_loc
23
+ if replacement && loc
24
+ @mutations << Mutation.new(
25
+ start_offset: loc.start_offset,
26
+ end_offset: loc.end_offset,
27
+ replacement: replacement,
28
+ operator: :arithmetic
29
+ )
30
+ end
31
+ super # nested calls (e.g. a + (b * c)) each get their own mutation
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Array-literal mutator (Tier-2).
8
+ #
9
+ # Replaces a non-empty array literal with an empty one, one mutation per
10
+ # literal: `[a, b]` and `%i[a b]` become `[]`. The mutant survives when no
11
+ # test checks the contents of the array.
12
+ class ArrayLiteral < Base
13
+ # Visits array nodes and emits array-literal mutations.
14
+ #
15
+ # @param node [Prism::ArrayNode] array node to inspect.
16
+ # @return [void]
17
+ def visit_array_node(node)
18
+ emit(node)
19
+ super # nested arrays each get their own mutation
20
+ end
21
+
22
+ private
23
+
24
+ # Emits a mutation for a non-empty array with brackets.
25
+ #
26
+ # The operator skips an implicit array (`x = 1, 2`), because it has no
27
+ # brackets. It also skips an array that holds a heredoc, because the
28
+ # heredoc body stays behind as code.
29
+ #
30
+ # @param node [Prism::ArrayNode] array node to inspect.
31
+ # @return [void]
32
+ def emit(node)
33
+ return if node.opening_loc.nil? || node.elements.empty? || heredoc?(node)
34
+
35
+ @mutations << Mutation.new(
36
+ start_offset: node.location.start_offset,
37
+ end_offset: node.location.end_offset,
38
+ replacement: "[]",
39
+ operator: :array_literal
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end