mutaterb 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e60dd9f48be985c4852b318ee38410f6ce76cb924604c9d37f1f64004e2c27f6
4
+ data.tar.gz: 6ef1b8387ed290681db0ab7480021fe2536782cf57478834d56f1331b3676ecc
5
+ SHA512:
6
+ metadata.gz: 1f168ec32386423f8c2beb49856791fed45ce11c80f42d3d7284d1782577b9c55e7f3344b13360ecbcc2b1bbf22932e919d1b79d5af05529fc40d353445581ea
7
+ data.tar.gz: bf83a88519717178e2863c9ec85ef30acaeddb0dac5433ee0b467c4d2d7aa2f14d38ed657173903f74045a84446cb8e465002d7895d0ca69344cd780f72784ca
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MarceloM47
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # MutateRB
2
+
3
+ A mutation testing tool for Ruby and Ruby on Rails projects: it modifies code covered
4
+ by your RSpec tests and checks whether the test suite catches the change. If a mutated
5
+ test does not fail, that test is identified as weak.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ gem install mutaterb
11
+ ```
12
+
13
+ Or add it to your `Gemfile`:
14
+
15
+ ```ruby
16
+ gem "mutaterb", group: :development
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Run the command from your project root (where your `Gemfile` is):
22
+
23
+ ```bash
24
+ mutaterb
25
+ ```
26
+
27
+ Without flags, it automatically detects whether the project is pure Ruby or Rails,
28
+ locates specs in `spec/`, applies mutations to covered code, and displays a summary:
29
+ how many mutations were "killed" (caught by a test) and how many "survived" (no test
30
+ caught them — weak tests), with file, line, and related test(s) for each survivor.
31
+
32
+ ### Main flags
33
+
34
+ | Flag | Description |
35
+ |---|---|
36
+ | `--dir PATH` | Target folder to analyze |
37
+ | `--include PATHS` | Restrict analysis to these paths (comma-separated) |
38
+ | `--exclude PATHS` | Exclude these paths (comma-separated) |
39
+ | `--strictness LEVEL` | `low`, `default`, or `high` |
40
+ | `--mutation-types TYPES` | Mutation types to apply, comma-separated |
41
+ | `--exit-zero` | Do not fail (exit 0) even if "survived" mutations exist |
42
+ | `--json-output PATH` | Export the results summary to a JSON file |
43
+ | `--config PATH` | Use a config file other than `.mutaterb.yml` |
44
+
45
+ You can also set these options in a `.mutaterb.yml` file at the project root; CLI
46
+ flags take precedence over the file when both define the same option.
47
+
48
+ ## License
49
+
50
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/exe/mutaterb ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "mutaterb"
5
+
6
+ exit MutateRB::CLI.run(ARGV)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # Entry point: parses flags, resolves Config, runs the mutation analysis
5
+ # and returns the process exit code (contracts/cli.md).
6
+ class CLI
7
+ EXIT_OPERATIONAL_ERROR = 2
8
+ EXIT_INTERRUPTED = 130
9
+
10
+ def self.run(argv)
11
+ new.run(argv)
12
+ end
13
+
14
+ def run(argv)
15
+ config = resolve_config(argv)
16
+ execute(config)
17
+ rescue ConfigError => e
18
+ warn "mutaterb: #{e.message}"
19
+ EXIT_OPERATIONAL_ERROR
20
+ rescue StandardError => e
21
+ # Constitution Principle V: no error may go uncaught.
22
+ # Any unforeseen failure (project detection, baseline run, etc.) is
23
+ # reported in a controlled manner instead of propagating a raw backtrace
24
+ # (contracts/cli.md: exit 2 = operational error, SC-005).
25
+ warn "mutaterb: unexpected error: #{e.message}"
26
+ EXIT_OPERATIONAL_ERROR
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_config(argv)
32
+ flags = FlagParser.parse(argv)
33
+ base = Config.load_file(flags.fetch(:config_path, Config::DEFAULT_FILE_NAME))
34
+ base.merge_flags(flags.except(:config_path))
35
+ end
36
+
37
+ def execute(config)
38
+ interrupted = false
39
+ Signal.trap("INT") { interrupted = true }
40
+
41
+ detection = ProjectDetector.new(config).detect
42
+ if detection.spec_files.empty?
43
+ warn "mutaterb: no tests found in #{config.target_dir}"
44
+ return EXIT_OPERATIONAL_ERROR
45
+ end
46
+
47
+ test_suite = build_test_suite(config, detection)
48
+ run = MutationRun.new(config: config, test_suite: test_suite)
49
+ run_mutations(run, test_suite, config) { interrupted }
50
+
51
+ run.finished_at = Time.now
52
+ run.interrupted = interrupted
53
+ Reporter.new(run).report
54
+
55
+ return EXIT_INTERRUPTED if interrupted
56
+
57
+ run.exit_code
58
+ end
59
+
60
+ def build_test_suite(config, detection)
61
+ test_runner = TestRunner.new(config: config, project_type: detection.project_type)
62
+ baseline_examples = test_runner.run_baseline(detection.spec_files)
63
+ TestSuite.from_baseline(project_type: detection.project_type, baseline_examples: baseline_examples)
64
+ end
65
+
66
+ def run_mutations(run, test_suite, config)
67
+ mutator = Mutator.new(config: config, test_suite: test_suite)
68
+ mutator.each_mutant do |mutant|
69
+ run.add_mutant(mutant)
70
+ break if yield
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module MutateRB
6
+ # Resolved options for a run, merging .mutaterb.yml and CLI flags
7
+ # (flags win, FR-009). See contracts/config-schema.md for the file format.
8
+ class Config
9
+ ALL_MUTATION_TYPES = %i[conditional_boundary boolean_literal nil_literal arithmetic_comparison].freeze
10
+ VALID_STRICTNESS = %i[low default high].freeze
11
+ DEFAULT_FILE_NAME = ".mutaterb.yml"
12
+
13
+ attr_accessor :target_dir, :include_paths, :exclude_paths, :strictness,
14
+ :mutation_types, :exit_on_survivors, :json_output_path
15
+
16
+ def initialize(target_dir: ".", include_paths: [], exclude_paths: [],
17
+ strictness: :default, mutation_types: ALL_MUTATION_TYPES.dup,
18
+ exit_on_survivors: true, json_output_path: nil)
19
+ @target_dir = target_dir
20
+ @include_paths = include_paths
21
+ @exclude_paths = exclude_paths
22
+ @strictness = strictness
23
+ @mutation_types = mutation_types
24
+ @exit_on_survivors = exit_on_survivors
25
+ @json_output_path = json_output_path
26
+ validate!
27
+ end
28
+
29
+ def self.default
30
+ new
31
+ end
32
+
33
+ # Loads .mutaterb.yml (or the path given by --config) if present, validates
34
+ # it, and returns a Config with the file's values applied on top of the
35
+ # defaults (FR-007). Returns Config.default if no file is present.
36
+ def self.load_file(path = DEFAULT_FILE_NAME)
37
+ return default unless File.exist?(path)
38
+
39
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], symbolize_names: false)
40
+ raise ConfigError, "#{path} must contain a YAML mapping" unless raw.is_a?(Hash)
41
+
42
+ new(**attributes_from_yaml(raw))
43
+ rescue Psych::SyntaxError => e
44
+ raise ConfigError, "#{path} is not valid YAML: #{e.message}"
45
+ end
46
+
47
+ def self.attributes_from_yaml(raw)
48
+ known_keys = %w[target_dir include_paths exclude_paths strictness mutation_types
49
+ exit_on_survivors json_output_path]
50
+ raw.each_key do |key|
51
+ warn "mutaterb: ignoring unknown config key #{key.inspect}" unless known_keys.include?(key)
52
+ end
53
+
54
+ {
55
+ target_dir: raw.fetch("target_dir", "."),
56
+ include_paths: Array(raw["include_paths"]),
57
+ exclude_paths: Array(raw["exclude_paths"]),
58
+ strictness: raw.key?("strictness") ? symbolize(raw["strictness"], "strictness") : :default,
59
+ mutation_types: if raw.key?("mutation_types")
60
+ Array(raw["mutation_types"]).map do |t|
61
+ symbolize(t, "mutation_types")
62
+ end
63
+ else
64
+ ALL_MUTATION_TYPES.dup
65
+ end,
66
+ exit_on_survivors: raw.fetch("exit_on_survivors", true),
67
+ json_output_path: raw["json_output_path"]
68
+ }
69
+ end
70
+ private_class_method :attributes_from_yaml
71
+
72
+ def self.symbolize(value, field)
73
+ raise ConfigError, "#{field} must be a string" unless value.is_a?(String)
74
+
75
+ value.to_sym
76
+ end
77
+ private_class_method :symbolize
78
+
79
+ # Applies CLI flag overrides on top of this config, field by field
80
+ # (FR-009: a flag always wins over the config file).
81
+ def merge_flags(flags)
82
+ merged = dup
83
+ flags.each do |key, value|
84
+ next if value.nil?
85
+
86
+ merged.public_send("#{key}=", value)
87
+ end
88
+ merged.validate!
89
+ merged
90
+ end
91
+
92
+ def validate!
93
+ raise ConfigError, "target_dir must be a String" unless target_dir.is_a?(String)
94
+ raise ConfigError, "target_dir #{target_dir.inspect} does not exist" unless Dir.exist?(target_dir)
95
+ raise ConfigError, "include_paths must be an Array" unless include_paths.is_a?(Array)
96
+ raise ConfigError, "exclude_paths must be an Array" unless exclude_paths.is_a?(Array)
97
+ unless VALID_STRICTNESS.include?(strictness)
98
+ raise ConfigError, "strictness must be one of #{VALID_STRICTNESS.join(', ')}, got #{strictness.inspect}"
99
+ end
100
+ raise ConfigError, "mutation_types must be an Array" unless mutation_types.is_a?(Array)
101
+
102
+ unknown = mutation_types - ALL_MUTATION_TYPES
103
+ raise ConfigError, "unknown mutation_types: #{unknown.join(', ')}" unless unknown.empty?
104
+ raise ConfigError, "exit_on_survivors must be true or false" unless [true, false].include?(exit_on_survivors)
105
+
106
+ return unless json_output_path
107
+
108
+ parent = File.dirname(File.expand_path(json_output_path))
109
+ raise ConfigError, "directory for json_output_path does not exist: #{parent}" unless Dir.exist?(parent)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # Raised when the config file or CLI flags fail validation (FR-011).
5
+ class ConfigError < StandardError; end
6
+
7
+ # Raised when a mutation cannot be applied or reverted safely.
8
+ class MutationError < StandardError; end
9
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module MutateRB
6
+ # Parses CLI flags into the hash consumed by Config#merge_flags (FR-008,
7
+ # contracts/cli.md). Only flags actually passed appear in the result, so
8
+ # CLI flags win over the config file field by field (FR-009).
9
+ class FlagParser
10
+ def self.parse(argv)
11
+ new.parse(argv)
12
+ end
13
+
14
+ def parse(argv)
15
+ flags = { config_path: Config::DEFAULT_FILE_NAME }
16
+
17
+ parser = build_parser(flags)
18
+ parser.parse!(argv.dup)
19
+ flags
20
+ rescue OptionParser::ParseError => e
21
+ raise ConfigError, e.message
22
+ end
23
+
24
+ private
25
+
26
+ def build_parser(flags)
27
+ OptionParser.new do |opts|
28
+ opts.banner = "Usage: mutaterb [flags]"
29
+
30
+ opts.on("--dir PATH", "Target folder to analyze") { |v| flags[:target_dir] = v }
31
+ opts.on("--include PATHS", "Subdirectories/files to include, comma-separated") do |v|
32
+ flags[:include_paths] = v.split(",")
33
+ end
34
+ opts.on("--exclude PATHS", "Subdirectories/files to exclude, comma-separated") do |v|
35
+ flags[:exclude_paths] = v.split(",")
36
+ end
37
+ opts.on("--strictness LEVEL", "low|default|high") { |v| flags[:strictness] = v.to_sym }
38
+ opts.on("--mutation-types TYPES", "Mutation types to apply, comma-separated") do |v|
39
+ flags[:mutation_types] = v.split(",").map(&:to_sym)
40
+ end
41
+ opts.on("--exit-zero", "Do not fail even if survived mutations exist") do
42
+ flags[:exit_on_survivors] = false
43
+ end
44
+ opts.on("--json-output PATH", "Export summary to a JSON file") do |v|
45
+ flags[:json_output_path] = v
46
+ end
47
+ opts.on("--config PATH", "Use a config file other than .mutaterb.yml") do |v|
48
+ flags[:config_path] = v
49
+ end
50
+ opts.on("-h", "--help", "Show this help") do
51
+ puts opts
52
+ exit 0
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # A single mutation applied to one location of a source file.
5
+ #
6
+ # Status starts at :pending and transitions exactly once to :killed,
7
+ # :survived or :error (data-model.md).
8
+ class Mutant
9
+ VALID_STATUSES = %i[pending killed survived error].freeze
10
+ VALID_KILL_REASONS = %i[assertion_failure timeout].freeze
11
+
12
+ attr_reader :id, :operator_type, :file_path, :line, :column_range,
13
+ :original_fragment, :mutated_fragment, :status, :kill_reason,
14
+ :related_tests, :failing_tests, :error_message
15
+
16
+ def initialize(id:, operator_type:, file_path:, line:, column_range:,
17
+ original_fragment:, mutated_fragment:)
18
+ raise ArgumentError, "id must be a String" unless id.is_a?(String)
19
+ raise ArgumentError, "operator_type must be a Symbol" unless operator_type.is_a?(Symbol)
20
+ raise ArgumentError, "file_path must be a String" unless file_path.is_a?(String)
21
+ raise ArgumentError, "line must be an Integer" unless line.is_a?(Integer)
22
+ raise ArgumentError, "column_range must be a Range" unless column_range.is_a?(Range)
23
+
24
+ @id = id
25
+ @operator_type = operator_type
26
+ @file_path = file_path
27
+ @line = line
28
+ @column_range = column_range
29
+ @original_fragment = original_fragment
30
+ @mutated_fragment = mutated_fragment
31
+ @status = :pending
32
+ @kill_reason = nil
33
+ @related_tests = []
34
+ @failing_tests = []
35
+ @error_message = nil
36
+ end
37
+
38
+ def related_tests=(test_cases)
39
+ @related_tests = Array(test_cases)
40
+ end
41
+
42
+ # Records the single, final outcome of running this mutant (FR-004).
43
+ def finish!(status:, kill_reason: nil, failing_tests: [], error_message: nil)
44
+ raise MutationError, "Mutant #{id} already finished as #{@status}" unless @status == :pending
45
+ raise ArgumentError, "invalid status #{status.inspect}" unless VALID_STATUSES.include?(status)
46
+ raise ArgumentError, "status must not be :pending" if status == :pending
47
+ if kill_reason && !VALID_KILL_REASONS.include?(kill_reason)
48
+ raise ArgumentError, "invalid kill_reason #{kill_reason.inspect}"
49
+ end
50
+
51
+ @status = status
52
+ @kill_reason = kill_reason
53
+ @failing_tests = Array(failing_tests)
54
+ @error_message = error_message
55
+ self
56
+ end
57
+
58
+ def killed? = status == :killed
59
+ def survived? = status == :survived
60
+ def error? = status == :error
61
+ end
62
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ module MutationOperators
5
+ # Swaps an arithmetic or equality operator for its common typo/off-by-logic
6
+ # counterpart: + <-> -, * <-> /, == <-> !=.
7
+ class ArithmeticComparisonOperator < BaseOperator
8
+ SWAPS = { "+" => "-", "-" => "+", "*" => "/", "/" => "*", "==" => "!=", "!=" => "==" }.freeze
9
+
10
+ def self.operator_type = :arithmetic_comparison
11
+
12
+ def self.applicable?(node)
13
+ node.type == :OPCALL && SWAPS.key?(node.children[1].to_s)
14
+ end
15
+
16
+ def self.replacement_for(node, fragment)
17
+ op = node.children[1].to_s
18
+ fragment.sub(op, SWAPS.fetch(op))
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ module MutationOperators
5
+ # Walks a file's AST via RubyVM::AbstractSyntaxTree (research.md #1) and
6
+ # turns each matching node into a Mutant, without reserializing the file:
7
+ # only the exact node span gets patched as a text substitution.
8
+ #
9
+ # ponytail: multi-line expressions are skipped (first_lineno != last_lineno)
10
+ # to keep the text-patch approach simple; revisit with the `parser` gem if
11
+ # the operator catalog needs to mutate across line breaks.
12
+ class BaseOperator
13
+ class << self
14
+ def operator_type
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def applicable?(_node)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def replacement_for(_node, _fragment)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def candidates(file_path)
27
+ source = File.read(file_path)
28
+ root = RubyVM::AbstractSyntaxTree.parse(source)
29
+ lines = source.lines
30
+ mutants = []
31
+ walk(root) { |node| mutants << build_mutant(file_path, lines, node) if applicable?(node) }
32
+ mutants.compact
33
+ rescue SyntaxError
34
+ []
35
+ end
36
+
37
+ private
38
+
39
+ def walk(node, &block)
40
+ return unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
41
+
42
+ block.call(node) if node.first_lineno == node.last_lineno
43
+ node.children.each { |child| walk(child, &block) }
44
+ end
45
+
46
+ def build_mutant(file_path, lines, node)
47
+ range = node.first_column...node.last_column
48
+ original = lines[node.first_lineno - 1][range]
49
+ return nil if original.nil?
50
+
51
+ mutated = replacement_for(node, original)
52
+ return nil if mutated.nil? || mutated == original
53
+
54
+ Mutant.new(
55
+ id: "#{file_path}:#{node.first_lineno}:#{range.begin}:#{operator_type}",
56
+ operator_type: operator_type,
57
+ file_path: file_path,
58
+ line: node.first_lineno,
59
+ column_range: range,
60
+ original_fragment: original,
61
+ mutated_fragment: mutated
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ module MutationOperators
5
+ # Flips a literal `true`/`false`.
6
+ class BooleanLiteralOperator < BaseOperator
7
+ def self.operator_type = :boolean_literal
8
+
9
+ def self.applicable?(node)
10
+ %i[TRUE FALSE].include?(node.type)
11
+ end
12
+
13
+ def self.replacement_for(_node, fragment)
14
+ fragment == "true" ? "false" : "true"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ module MutationOperators
5
+ # Swaps a comparison operator for its off-by-one boundary: < <-> <=, > <-> >=.
6
+ class ConditionalBoundaryOperator < BaseOperator
7
+ SWAPS = { "<" => "<=", "<=" => "<", ">" => ">=", ">=" => ">" }.freeze
8
+
9
+ def self.operator_type = :conditional_boundary
10
+
11
+ def self.applicable?(node)
12
+ node.type == :OPCALL && SWAPS.key?(node.children[1].to_s)
13
+ end
14
+
15
+ def self.replacement_for(node, fragment)
16
+ op = node.children[1].to_s
17
+ fragment.sub(op, SWAPS.fetch(op))
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ module MutationOperators
5
+ # Replaces a literal `nil` with `false` — distinguishes code/tests that
6
+ # depend on `nil` specifically (`.nil?`, `== nil`) from merely-falsy checks.
7
+ #
8
+ # ponytail: a bare `nil` as the sole/last expression of a method body is
9
+ # optimized away by Ruby (implicit nil return, no NIL node at all), so it
10
+ # is never mutated. Upgrade path: only relevant if that trailing-position
11
+ # case turns out to matter in practice.
12
+ class NilLiteralOperator < BaseOperator
13
+ def self.operator_type = :nil_literal
14
+
15
+ def self.applicable?(node)
16
+ node.type == :NIL
17
+ end
18
+
19
+ def self.replacement_for(_node, _fragment)
20
+ "false"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # Aggregates a complete run: the config used, the detected test suite, and
5
+ # every mutant generated with its outcome (data-model.md).
6
+ class MutationRun
7
+ attr_reader :config, :test_suite, :mutants, :started_at
8
+ attr_accessor :finished_at, :interrupted
9
+
10
+ def initialize(config:, test_suite:)
11
+ @config = config
12
+ @test_suite = test_suite
13
+ @mutants = []
14
+ @started_at = Time.now
15
+ @finished_at = nil
16
+ @interrupted = false
17
+ end
18
+
19
+ def add_mutant(mutant)
20
+ raise ArgumentError, "expected a Mutant" unless mutant.is_a?(Mutant)
21
+
22
+ @mutants << mutant
23
+ mutant
24
+ end
25
+
26
+ def baseline_broken_tests
27
+ test_suite.baseline_broken_tests
28
+ end
29
+
30
+ def survived?
31
+ mutants.any?(&:survived?)
32
+ end
33
+
34
+ def survived_mutants
35
+ mutants.select(&:survived?)
36
+ end
37
+
38
+ def summary
39
+ {
40
+ total_mutants: mutants.size,
41
+ killed: mutants.count(&:killed?),
42
+ survived: mutants.count(&:survived?),
43
+ errors: mutants.count(&:error?),
44
+ baseline_broken_tests: baseline_broken_tests.size
45
+ }
46
+ end
47
+
48
+ # Exit code for a normal (non-interrupted) completion. SIGINT is handled
49
+ # separately by the CLI's signal trap (contracts/cli.md: exit 130).
50
+ def exit_code
51
+ return 0 unless survived?
52
+ return 0 unless config.exit_on_survivors
53
+
54
+ 1
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # Orchestrates one mutation at a time: patches the file, runs the related
5
+ # tests, classifies the outcome, and always restores the original file
6
+ # (FR-003, FR-005, FR-010).
7
+ class Mutator
8
+ OPERATORS = {
9
+ conditional_boundary: MutationOperators::ConditionalBoundaryOperator,
10
+ boolean_literal: MutationOperators::BooleanLiteralOperator,
11
+ nil_literal: MutationOperators::NilLiteralOperator,
12
+ arithmetic_comparison: MutationOperators::ArithmeticComparisonOperator
13
+ }.freeze
14
+
15
+ def initialize(config:, test_suite:, test_runner: nil)
16
+ @config = config
17
+ @test_suite = test_suite
18
+ @test_runner = test_runner || TestRunner.new(config: config, project_type: test_suite.project_type)
19
+ end
20
+
21
+ # Yields each finished Mutant, one at a time.
22
+ def each_mutant
23
+ source_files.each do |file|
24
+ candidates_for(file).each { |mutant| yield run_one(mutant) }
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :config, :test_suite, :test_runner
31
+
32
+ def source_files
33
+ Dir.glob(File.join(config.target_dir, "**", "*.rb"))
34
+ .reject { |f| f.include?("/spec/") || f.start_with?(File.join(config.target_dir, "spec")) }
35
+ .select { |f| in_scope?(f) }
36
+ end
37
+
38
+ def in_scope?(file)
39
+ included = config.include_paths.empty? || config.include_paths.any? { |p| file.start_with?(p) }
40
+ excluded = config.exclude_paths.any? { |p| file.start_with?(p) }
41
+ included && !excluded
42
+ end
43
+
44
+ def candidates_for(file)
45
+ config.mutation_types.flat_map { |type| OPERATORS.fetch(type).candidates(file) }
46
+ end
47
+
48
+ def run_one(mutant)
49
+ related = related_tests_for(mutant.file_path)
50
+ mutant.related_tests = related
51
+
52
+ begin
53
+ original_content = File.read(mutant.file_path)
54
+ rescue StandardError => e
55
+ mutant.finish!(status: :error, error_message: "could not read #{mutant.file_path}: #{e.message}")
56
+ return mutant
57
+ end
58
+
59
+ begin
60
+ apply_patch(mutant, original_content)
61
+ classify(mutant, related)
62
+ rescue StandardError => e
63
+ mutant.finish!(status: :error, error_message: e.message) if mutant.status == :pending
64
+ ensure
65
+ File.write(mutant.file_path, original_content)
66
+ end
67
+
68
+ mutant
69
+ end
70
+
71
+ def apply_patch(mutant, original_content)
72
+ lines = original_content.lines
73
+ line = lines[mutant.line - 1]
74
+ range = mutant.column_range
75
+ lines[mutant.line - 1] = line[0...range.begin] + mutant.mutated_fragment + line[range.end..]
76
+ patched = lines.join
77
+
78
+ begin
79
+ RubyVM::AbstractSyntaxTree.parse(patched)
80
+ rescue SyntaxError => e
81
+ mutant.finish!(status: :error, error_message: "mutation produced invalid code: #{e.message}")
82
+ return
83
+ end
84
+
85
+ File.write(mutant.file_path, patched)
86
+ end
87
+
88
+ def classify(mutant, related_tests)
89
+ return unless mutant.status == :pending # apply_patch already marked :error
90
+
91
+ if related_tests.empty?
92
+ finish_inconclusive(mutant, "no related tests")
93
+ return
94
+ end
95
+
96
+ result = test_runner.run_for_mutant(related_tests)
97
+ case result[:status]
98
+ when :timeout
99
+ mutant.finish!(status: :killed, kill_reason: :timeout)
100
+ when :error
101
+ finish_inconclusive(mutant, "failed to run tests")
102
+ else
103
+ classify_from_examples(mutant, related_tests, result[:examples])
104
+ end
105
+ end
106
+
107
+ # An inconclusive result (no tests exercised the mutation, or test
108
+ # execution failed) does not prove the mutation is detected. With
109
+ # strictness "high" this counts as evidence of weakness ("survived");
110
+ # at other levels it is reported separately as "error" without
111
+ # affecting the killed/survived count (US3/AC2, FR-008).
112
+ def finish_inconclusive(mutant, message)
113
+ if config.strictness == :high
114
+ mutant.finish!(status: :survived)
115
+ else
116
+ mutant.finish!(status: :error, error_message: message)
117
+ end
118
+ end
119
+
120
+ def classify_from_examples(mutant, related_tests, examples)
121
+ failing_ids = examples.select { |e| e[:status] == :failed }.map { |e| e[:id] }
122
+ failing_tests = related_tests.select { |t| failing_ids.include?(t.id) }
123
+ if failing_tests.any?
124
+ mutant.finish!(status: :killed, kill_reason: :assertion_failure, failing_tests: failing_tests)
125
+ else
126
+ mutant.finish!(status: :survived)
127
+ end
128
+ end
129
+
130
+ # Convention-based coverage mapping: lib/foo/bar.rb -> spec/foo/bar_spec.rb.
131
+ # ponytail: no real coverage tracking yet; falls back to the whole suite
132
+ # when no matching spec file exists. Upgrade path: integrate SimpleCov
133
+ # coverage data if this heuristic proves too coarse for real projects.
134
+ def related_tests_for(source_file)
135
+ mapped = mapped_spec_file(source_file)
136
+ matches = test_suite.test_cases.select { |t| t.file_path == mapped }
137
+ matches = test_suite.test_cases.dup if matches.empty?
138
+ matches.reject(&:baseline_broken?)
139
+ end
140
+
141
+ def mapped_spec_file(source_file)
142
+ relative = source_file.sub(%r{\A#{Regexp.escape(config.target_dir)}/?}, "")
143
+ relative = relative.sub(%r{\A(lib|app)/}, "")
144
+ spec_relative = relative.sub(/\.rb\z/, "_spec.rb")
145
+ File.join(config.target_dir, "spec", spec_relative)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # Detects whether the target project is Ruby or Rails and locates its RSpec
5
+ # files (FR-001, FR-002).
6
+ class ProjectDetector
7
+ Detection = Struct.new(:project_type, :spec_files)
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ def detect
14
+ Detection.new(project_type, discover_spec_files)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :config
20
+
21
+ def project_type
22
+ rails_marker = File.join(config.target_dir, "config", "application.rb")
23
+ rails_bin = File.join(config.target_dir, "bin", "rails")
24
+ File.exist?(rails_marker) || File.exist?(rails_bin) ? :rails : :ruby
25
+ end
26
+
27
+ def discover_spec_files
28
+ pattern = File.join(config.target_dir, "spec", "**", "*_spec.rb")
29
+ apply_scope(Dir.glob(pattern))
30
+ end
31
+
32
+ def apply_scope(files)
33
+ files = files.select { |f| config.include_paths.any? { |p| f.start_with?(p) } } unless config.include_paths.empty?
34
+ files.reject { |f| config.exclude_paths.any? { |p| f.start_with?(p) } }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module MutateRB
7
+ # Prints the console summary (FR-006) and, if configured, exports the same
8
+ # result to JSON (FR-015, contracts/json-report-schema.md).
9
+ class Reporter
10
+ def initialize(run)
11
+ @run = run
12
+ end
13
+
14
+ def report
15
+ print_console
16
+ write_json if run.config.json_output_path
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :run
22
+
23
+ def print_console
24
+ summary = run.summary
25
+ puts "MutateRB — #{summary[:total_mutants]} mutations: " \
26
+ "#{summary[:killed]} killed, #{summary[:survived]} survived, #{summary[:errors]} errors"
27
+ print_baseline_broken
28
+ print_survived
29
+ end
30
+
31
+ def print_baseline_broken
32
+ broken = run.baseline_broken_tests
33
+ return if broken.empty?
34
+
35
+ puts "Pre-broken tests before mutation (excluded from count): #{broken.size}"
36
+ broken.each { |t| puts " - #{t.id} #{t.description}" }
37
+ end
38
+
39
+ def print_survived
40
+ survived = run.survived_mutants
41
+ return if survived.empty?
42
+
43
+ puts "\nSurvived mutations (weak tests):"
44
+ survived.each do |mutant|
45
+ related = mutant.related_tests.map(&:id).join(", ")
46
+ puts " - #{mutant.file_path}:#{mutant.line} [#{mutant.operator_type}] " \
47
+ "'#{mutant.original_fragment}' -> '#{mutant.mutated_fragment}' (tests: #{related})"
48
+ end
49
+ end
50
+
51
+ def write_json
52
+ File.write(run.config.json_output_path, JSON.pretty_generate(to_h))
53
+ end
54
+
55
+ def to_h
56
+ {
57
+ mutaterb_version: MutateRB::VERSION,
58
+ started_at: run.started_at.utc.iso8601,
59
+ finished_at: run.finished_at&.utc&.iso8601,
60
+ interrupted: run.interrupted,
61
+ config: config_h,
62
+ summary: run.summary,
63
+ baseline_broken_tests: run.baseline_broken_tests.map { |t| test_case_h(t) },
64
+ survived_mutants: run.survived_mutants.map { |m| mutant_h(m) },
65
+ exit_code: run.exit_code
66
+ }
67
+ end
68
+
69
+ def config_h
70
+ c = run.config
71
+ {
72
+ target_dir: c.target_dir,
73
+ include_paths: c.include_paths,
74
+ exclude_paths: c.exclude_paths,
75
+ strictness: c.strictness.to_s,
76
+ mutation_types: c.mutation_types.map(&:to_s),
77
+ exit_on_survivors: c.exit_on_survivors
78
+ }
79
+ end
80
+
81
+ def mutant_h(mutant)
82
+ {
83
+ id: mutant.id,
84
+ operator_type: mutant.operator_type.to_s,
85
+ file_path: mutant.file_path,
86
+ line: mutant.line,
87
+ original_fragment: mutant.original_fragment,
88
+ mutated_fragment: mutant.mutated_fragment,
89
+ related_tests: mutant.related_tests.map { |t| test_case_h(t) }
90
+ }
91
+ end
92
+
93
+ def test_case_h(test_case)
94
+ { id: test_case.id, description: test_case.description }
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # A single test detected in the target project (data-model.md).
5
+ class TestCase
6
+ VALID_BASELINE_STATUSES = %i[passed failed].freeze
7
+
8
+ attr_reader :id, :description, :file_path
9
+ attr_accessor :baseline_status, :baseline_duration_seconds
10
+
11
+ def initialize(id:, description:, file_path:, baseline_status: nil,
12
+ baseline_duration_seconds: nil)
13
+ raise ArgumentError, "id must be a String" unless id.is_a?(String)
14
+ raise ArgumentError, "file_path must be a String" unless file_path.is_a?(String)
15
+ if baseline_status && !VALID_BASELINE_STATUSES.include?(baseline_status)
16
+ raise ArgumentError, "invalid baseline_status #{baseline_status.inspect}"
17
+ end
18
+
19
+ @id = id
20
+ @description = description.to_s
21
+ @file_path = file_path
22
+ @baseline_status = baseline_status
23
+ @baseline_duration_seconds = baseline_duration_seconds
24
+ end
25
+
26
+ def baseline_broken? = baseline_status == :failed
27
+ end
28
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require "bundler"
6
+
7
+ module MutateRB
8
+ # Runs the target project's own RSpec suite as a subprocess (research.md #2):
9
+ # MutateRB never bundles its own RSpec, it shells out to `bundle exec rspec`
10
+ # inside the target project so the project's own Gemfile.lock decides the
11
+ # RSpec version.
12
+ class TestRunner
13
+ MIN_TIMEOUT_SECONDS = 5
14
+
15
+ def initialize(config:, project_type:)
16
+ @config = config
17
+ @project_type = project_type
18
+ end
19
+
20
+ # Runs the given spec files once, unmutated, and returns one Hash per
21
+ # RSpec example: { id:, description:, file_path:, status:, duration: }.
22
+ # Used to build TestCase instances (FR-012's baseline_status) and as the
23
+ # basis for the per-mutant timeout (FR-014).
24
+ def run_baseline(spec_files)
25
+ spawn_rspec(spec_files, timeout_seconds: nil).fetch(:examples, [])
26
+ end
27
+
28
+ # Runs the given test cases against a mutation. Timeout = 2x the slowest
29
+ # related test's baseline duration, floor MIN_TIMEOUT_SECONDS (FR-014).
30
+ # On timeout the child process is killed explicitly (research.md #3) so no
31
+ # hung process survives the run.
32
+ def run_for_mutant(test_cases)
33
+ return { status: :error, examples: [] } if test_cases.empty?
34
+
35
+ slowest_baseline = test_cases.filter_map(&:baseline_duration_seconds).max || 0
36
+ timeout_seconds = [slowest_baseline * 2, MIN_TIMEOUT_SECONDS].max
37
+ files = test_cases.map(&:file_path).uniq
38
+ spawn_rspec(files, timeout_seconds: timeout_seconds)
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :config, :project_type
44
+
45
+ # Runs inside Bundler.with_unbundled_env (research.md #2): when `mutaterb`
46
+ # itself is invoked via `bundle exec`, BUNDLE_GEMFILE/RUBYOPT point at
47
+ # MutateRB's own Gemfile and would otherwise leak into this child process,
48
+ # making `bundle exec rspec` resolve the target project's suite against
49
+ # the wrong bundle. with_unbundled_env restores the pre-bundler
50
+ # environment for the duration of the spawn.
51
+ def spawn_rspec(files, timeout_seconds:)
52
+ env = project_type == :rails ? { "RAILS_ENV" => "test" } : {}
53
+ stdout_read, stdout_write = IO.pipe
54
+ pid = Bundler.with_unbundled_env do
55
+ Process.spawn(env, *rspec_command(files), out: stdout_write, err: File::NULL,
56
+ chdir: File.expand_path(config.target_dir))
57
+ end
58
+ stdout_write.close
59
+
60
+ output = wait_with_timeout(pid, stdout_read, timeout_seconds)
61
+ stdout_read.close unless stdout_read.closed?
62
+ output
63
+ end
64
+
65
+ def wait_with_timeout(pid, stdout_read, timeout_seconds)
66
+ if timeout_seconds
67
+ Timeout.timeout(timeout_seconds) { read_and_wait(pid, stdout_read) }
68
+ else
69
+ read_and_wait(pid, stdout_read)
70
+ end
71
+ rescue Timeout::Error
72
+ kill(pid)
73
+ { status: :timeout, examples: [] }
74
+ end
75
+
76
+ def read_and_wait(pid, stdout_read)
77
+ raw = stdout_read.read
78
+ Process.wait(pid)
79
+ parse_output(raw)
80
+ end
81
+
82
+ # Kills a hung child process: TERM first, KILL if it ignores TERM for 1s
83
+ # (research.md #3 — Timeout alone never touches the child process).
84
+ def kill(pid)
85
+ Process.kill("TERM", pid)
86
+ begin
87
+ Timeout.timeout(1) { Process.wait(pid) }
88
+ rescue Timeout::Error
89
+ Process.kill("KILL", pid)
90
+ Process.wait(pid)
91
+ end
92
+ rescue Errno::ESRCH, Errno::ECHILD
93
+ nil
94
+ end
95
+
96
+ def rspec_command(files)
97
+ ["bundle", "exec", "rspec", "--format", "json", *files]
98
+ end
99
+
100
+ def parse_output(raw)
101
+ data = JSON.parse(raw)
102
+ examples = data.fetch("examples", []).map do |example|
103
+ {
104
+ id: example["id"],
105
+ description: example["full_description"],
106
+ file_path: example["file_path"],
107
+ status: example["status"] == "passed" ? :passed : :failed,
108
+ duration: example["run_time"].to_f
109
+ }
110
+ end
111
+ { status: :completed, examples: examples }
112
+ rescue JSON::ParserError
113
+ { status: :error, examples: [] }
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ # The collection of tests detected for the target project (data-model.md).
5
+ class TestSuite
6
+ VALID_PROJECT_TYPES = %i[ruby rails].freeze
7
+
8
+ attr_reader :framework, :project_type, :test_cases
9
+
10
+ def initialize(project_type:, test_cases: [])
11
+ unless VALID_PROJECT_TYPES.include?(project_type)
12
+ raise ArgumentError,
13
+ "invalid project_type #{project_type.inspect}"
14
+ end
15
+
16
+ @framework = :rspec
17
+ @project_type = project_type
18
+ @test_cases = Array(test_cases)
19
+ end
20
+
21
+ # Builds a TestSuite from the raw example hashes returned by
22
+ # TestRunner#run_baseline (FR-012's baseline_status comes from here).
23
+ def self.from_baseline(project_type:, baseline_examples:)
24
+ test_cases = baseline_examples.map do |example|
25
+ TestCase.new(
26
+ id: example.fetch(:id),
27
+ description: example.fetch(:description),
28
+ file_path: example.fetch(:file_path),
29
+ baseline_status: example.fetch(:status),
30
+ baseline_duration_seconds: example.fetch(:duration)
31
+ )
32
+ end
33
+ new(project_type: project_type, test_cases: test_cases)
34
+ end
35
+
36
+ def find(id)
37
+ test_cases.find { |test_case| test_case.id == id }
38
+ end
39
+
40
+ def baseline_broken_tests
41
+ test_cases.select(&:baseline_broken?)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutateRB
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mutaterb.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mutaterb/version"
4
+ require_relative "mutaterb/errors"
5
+ require_relative "mutaterb/mutant"
6
+ require_relative "mutaterb/test_case"
7
+ require_relative "mutaterb/test_suite"
8
+ require_relative "mutaterb/config"
9
+ require_relative "mutaterb/mutation_run"
10
+ require_relative "mutaterb/flag_parser"
11
+ require_relative "mutaterb/project_detector"
12
+ require_relative "mutaterb/test_runner"
13
+ require_relative "mutaterb/mutation_operators/base_operator"
14
+ require_relative "mutaterb/mutation_operators/conditional_boundary_operator"
15
+ require_relative "mutaterb/mutation_operators/boolean_literal_operator"
16
+ require_relative "mutaterb/mutation_operators/nil_literal_operator"
17
+ require_relative "mutaterb/mutation_operators/arithmetic_comparison_operator"
18
+ require_relative "mutaterb/mutator"
19
+ require_relative "mutaterb/reporter"
20
+ require_relative "mutaterb/cli"
21
+
22
+ module MutateRB
23
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mutaterb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - MarceloM47
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.13'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.65'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.65'
54
+ description: Detects weak tests by mutating source code covered by an RSpec suite
55
+ and checking whether the suite catches the mutation.
56
+ email:
57
+ - marcelo.esteche@proton.me
58
+ executables:
59
+ - mutaterb
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - LICENSE.txt
64
+ - README.md
65
+ - exe/mutaterb
66
+ - lib/mutaterb.rb
67
+ - lib/mutaterb/cli.rb
68
+ - lib/mutaterb/config.rb
69
+ - lib/mutaterb/errors.rb
70
+ - lib/mutaterb/flag_parser.rb
71
+ - lib/mutaterb/mutant.rb
72
+ - lib/mutaterb/mutation_operators/arithmetic_comparison_operator.rb
73
+ - lib/mutaterb/mutation_operators/base_operator.rb
74
+ - lib/mutaterb/mutation_operators/boolean_literal_operator.rb
75
+ - lib/mutaterb/mutation_operators/conditional_boundary_operator.rb
76
+ - lib/mutaterb/mutation_operators/nil_literal_operator.rb
77
+ - lib/mutaterb/mutation_run.rb
78
+ - lib/mutaterb/mutator.rb
79
+ - lib/mutaterb/project_detector.rb
80
+ - lib/mutaterb/reporter.rb
81
+ - lib/mutaterb/test_case.rb
82
+ - lib/mutaterb/test_runner.rb
83
+ - lib/mutaterb/test_suite.rb
84
+ - lib/mutaterb/version.rb
85
+ homepage: https://github.com/MarceloM47/mutateRB
86
+ licenses:
87
+ - MIT
88
+ metadata:
89
+ homepage_uri: https://github.com/MarceloM47/mutateRB
90
+ source_code_uri: https://github.com/MarceloM47/mutateRB
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '3.0'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 4.0.16
107
+ specification_version: 4
108
+ summary: Mutation testing CLI for Ruby and Ruby on Rails projects
109
+ test_files: []