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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- 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
|