diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81db85115..69f9acc5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - run: ./mutant.sh --since HEAD~1 -- 'Mutant*' + - run: bundle exec mutant run --zombie --since HEAD~1 ruby-integration-misc: name: Integration Misc runs-on: ${{ matrix.os }} diff --git a/Changelog.md b/Changelog.md index 0b208ae51..9a8b843fe 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,13 @@ -# Unreleased +# v0.10.24 2021-01-01 -* Reintroduce regexp mutation support [#1166](https://github.com/mbj/mutant/pull/1166) +* [#1176](https://github.com/mbj/mutant/pull/1176) + + Allow [subject matcher configuration](https://github.com/mbj/mutant/tree/master/docs/configuration.md#matcher) + in the configuration file. + +* [#1166](https://github.com/mbj/mutant/pull/1166) + + Reintroduce regexp mutation support # v0.10.23 2020-12-30 diff --git a/Gemfile.lock b/Gemfile.lock index 4345f0177..b3b2a92c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - mutant (0.10.23) + mutant (0.10.24) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) anima (~> 0.3.1) diff --git a/docs/configuration.md b/docs/configuration.md index b6c6f3096..16992a89e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,42 @@ When `fail_fast` is enabled, mutant will stop as soon as it encounters an alive fail_fast: true ``` +#### `matcher` + +Allows to set subject matchers in the configration file. + +```yaml: +matcher: + # Subject expressions to find subjects for mutation testing. + # Multiple entries are allowed and matches from each expression + # are unioned. + # + # Subject expressions can also be specified on the command line. Example: + # `bundle exec mutant run YourSubject` + # + # Note that expressions from the command line replace the subjects + # configured in the config file! + subjects: + - Your::App::Namespace # select all subjects on a specific constant + - Your::App::Namespace* # select all subjects on a specific constant, recursively + - Your::App::Namespace#some_method # select a specific instance method + - Your::App::Namespace.some_method # select a specific class method + # Expressions of subjects to ignore during mutation testing. + # Multiple entries are allowed and matches from each expression + # are unioned. + # + # Subject ignores can also be specified on the command line, via `--ignore-subject`. Example: + # `bundle exec mutant run --ignore-subject YourSubject#some_method` + # + # Note that subject ignores from the command line are added to the subject ignores + # configured on the command line! + ignore: + - Your::App::Namespace::Dirty # ignore all subjects on a specific constant + - Your::App::Namespace::Dirty* # ignore all subjects on a specific constant, recursively + - Your::App::Namespace::Dirty#some_method # ignore a specific instance method + - Your::App::Namespace::Dirty#some_method # ignore a specific class method +``` + #### `jobs` Specify how many processes mutant uses to kill mutations. Defaults to the number of processors on your system. diff --git a/docs/mutant-minitest.md b/docs/mutant-minitest.md index 7c914c569..7f48ad96e 100644 --- a/docs/mutant-minitest.md +++ b/docs/mutant-minitest.md @@ -64,7 +64,7 @@ This prints a report like: ```sh Mutant environment: -Matcher: # +Matcher: # Integration: Mutant::Integration::Minitest Jobs: 8 Includes: ["lib"] diff --git a/docs/mutant-rspec.md b/docs/mutant-rspec.md index db0b41063..22bbcf0f0 100644 --- a/docs/mutant-rspec.md +++ b/docs/mutant-rspec.md @@ -38,7 +38,7 @@ This prints a report like: ```sh Mutant environment: -Matcher: # +Matcher: # Integration: Mutant::Integration::Rspec Jobs: 8 Includes: ["lib"] @@ -89,7 +89,7 @@ evil:AUOM::Unit.new:/home/mrh-dev/example/auom/lib/auom/unit.rb:172:45e17 end ----------------------- Mutant configuration: -Matcher: # +Matcher: # Integration: Mutant::Integration::Rspec Jobs: 8 Includes: ["lib"] diff --git a/lib/mutant.rb b/lib/mutant.rb index 0b9a1ed7a..f9a78a2aa 100644 --- a/lib/mutant.rb +++ b/lib/mutant.rb @@ -41,6 +41,7 @@ module Mutant SCOPE_OPERATOR = '::' end # Mutant +require 'mutant/transform' require 'mutant/bootstrap' require 'mutant/version' require 'mutant/env' @@ -176,7 +177,6 @@ module Mutant require 'mutant/expression/namespace' require 'mutant/test' require 'mutant/timer' -require 'mutant/transform' require 'mutant/integration' require 'mutant/integration/null' require 'mutant/selector' @@ -184,6 +184,7 @@ module Mutant require 'mutant/selector/null' require 'mutant/world' require 'mutant/config' +require 'mutant/config/coverage_criteria' require 'mutant/cli' require 'mutant/cli/command' require 'mutant/cli/command/subscription' diff --git a/lib/mutant/cli/command/environment.rb b/lib/mutant/cli/command/environment.rb index 7c15423ee..f12cf3c5f 100644 --- a/lib/mutant/cli/command/environment.rb +++ b/lib/mutant/cli/command/environment.rb @@ -34,8 +34,8 @@ def expand(file_config) def parse_remaining_arguments(arguments) Mutant.traverse(@config.expression_parser, arguments) - .fmap do |match_expressions| - matcher(match_expressions: match_expressions) + .fmap do |expressions| + matcher(subjects: expressions) self end end @@ -82,7 +82,7 @@ def add_matcher_options(parser) parser.separator('Matcher:') parser.on('--ignore-subject EXPRESSION', 'Ignore subjects that match EXPRESSION as prefix') do |pattern| - add_matcher(:ignore_expressions, @config.expression_parser.call(pattern).from_right) + add_matcher(:ignore, @config.expression_parser.call(pattern).from_right) end parser.on('--start-subject EXPRESSION', 'Start mutation testing at a specific subject') do |pattern| add_matcher(:start_expressions, @config.expression_parser.call(pattern).from_right) diff --git a/lib/mutant/config.rb b/lib/mutant/config.rb index a596a4600..4348361e8 100644 --- a/lib/mutant/config.rb +++ b/lib/mutant/config.rb @@ -37,54 +37,6 @@ class Config private_constant(*constants(false)) - class CoverageCriteria - include Anima.new(:process_abort, :test_result, :timeout) - - EMPTY = new( - process_abort: nil, - test_result: nil, - timeout: nil - ) - - DEFAULT = new( - process_abort: false, - test_result: true, - timeout: false - ) - - TRANSFORM = - Transform::Sequence.new( - [ - Transform::Hash.new( - optional: [ - Transform::Hash::Key.new('process_abort', Transform::BOOLEAN), - Transform::Hash::Key.new('test_result', Transform::BOOLEAN), - Transform::Hash::Key.new('timeout', Transform::BOOLEAN) - ], - required: [] - ), - Transform::Hash::Symbolize.new, - ->(value) { Either::Right.new(DEFAULT.with(**value)) } - ] - ) - - def merge(other) - self.class.new( - process_abort: overwrite(other, :process_abort), - test_result: overwrite(other, :test_result), - timeout: overwrite(other, :timeout) - ) - end - - private - - def overwrite(other, attribute_name) - other_value = other.public_send(attribute_name) - - other_value.nil? ? public_send(attribute_name) : other_value - end - end # CoverageCriteria - # Merge with other config # # @param [Config] other @@ -116,13 +68,14 @@ def merge(other) # # @return [Either] def self.load_config_file(world) - config = DEFAULT - files = CANDIDATES.map(&world.pathname.public_method(:new)).select(&:readable?) + files = CANDIDATES + .map(&world.pathname.public_method(:new)) + .select(&:readable?) if files.one? - load_contents(files.first).fmap(&config.public_method(:with)) + load_contents(files.first).fmap(&DEFAULT.public_method(:with)) elsif files.empty? - Either::Right.new(config) + Either::Right.new(DEFAULT) else Either::Left.new(MORE_THAN_ONE_CONFIG_FILE % files.join(', ')) end @@ -159,13 +112,14 @@ def self.env Transform::Exception.new(YAML::SyntaxError, YAML.method(:safe_load)), Transform::Hash.new( optional: [ - Transform::Hash::Key.new('coverage_criteria', CoverageCriteria::TRANSFORM), + Transform::Hash::Key.new('coverage_criteria', ->(value) { CoverageCriteria::TRANSFORM.call(value) }), Transform::Hash::Key.new('fail_fast', Transform::BOOLEAN), Transform::Hash::Key.new('includes', Transform::STRING_ARRAY), Transform::Hash::Key.new('integration', Transform::STRING), Transform::Hash::Key.new('jobs', Transform::INTEGER), Transform::Hash::Key.new('mutation_timeout', Transform::FLOAT), - Transform::Hash::Key.new('requires', Transform::STRING_ARRAY) + Transform::Hash::Key.new('requires', Transform::STRING_ARRAY), + Transform::Hash::Key.new('matcher', Matcher::Config::LOADER) ], required: [] ), diff --git a/lib/mutant/config/coverage_criteria.rb b/lib/mutant/config/coverage_criteria.rb new file mode 100644 index 000000000..5b6144582 --- /dev/null +++ b/lib/mutant/config/coverage_criteria.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Mutant + class Config + # Configuration of coverge conditions + class CoverageCriteria + include Anima.new(:process_abort, :test_result, :timeout) + + EMPTY = new( + process_abort: nil, + test_result: nil, + timeout: nil + ) + + DEFAULT = new( + process_abort: false, + test_result: true, + timeout: false + ) + + TRANSFORM = + Transform::Sequence.new( + [ + Transform::Hash.new( + optional: [ + Transform::Hash::Key.new('process_abort', Transform::BOOLEAN), + Transform::Hash::Key.new('test_result', Transform::BOOLEAN), + Transform::Hash::Key.new('timeout', Transform::BOOLEAN) + ], + required: [] + ), + Transform::Hash::Symbolize.new, + ->(value) { Either::Right.new(DEFAULT.with(**value)) } + ] + ) + + # Merge coverage criteria with other instance + # + # Values from the other instance have precedence. + # + # @param [CoverageCriteria] other + # + # @return [CoverageCriteria] + def merge(other) + self.class.new( + process_abort: overwrite(other, :process_abort), + test_result: overwrite(other, :test_result), + timeout: overwrite(other, :timeout) + ) + end + + private + + def overwrite(other, attribute_name) + other_value = other.public_send(attribute_name) + + other_value.nil? ? public_send(attribute_name) : other_value + end + end # CoverageCriteria + end # Config +end # Mutant diff --git a/lib/mutant/expression.rb b/lib/mutant/expression.rb index c31c083d2..aefa3b588 100644 --- a/lib/mutant/expression.rb +++ b/lib/mutant/expression.rb @@ -4,8 +4,6 @@ module Mutant # Abstract base class for match expression class Expression - include AbstractType - fragment = /[A-Za-z][A-Za-z\d_]*/.freeze SCOPE_NAME_PATTERN = /(?#{fragment}(?:#{SCOPE_OPERATOR}#{fragment})*)/.freeze SCOPE_SYMBOL_PATTERN = '(?[.#])' @@ -16,16 +14,6 @@ def self.new(*) super.freeze end - # Syntax of expression - # - # @return [Matcher] - abstract_method :matcher - - # Syntax of expression - # - # @return [String] - abstract_method :syntax - # Match length with other expression # # @param [Expression] other diff --git a/lib/mutant/matcher.rb b/lib/mutant/matcher.rb index fa4e0f3ba..a95e1aecd 100644 --- a/lib/mutant/matcher.rb +++ b/lib/mutant/matcher.rb @@ -20,7 +20,7 @@ class Matcher # @return [Matcher] def self.from_config(config) Filter.new( - Chain.new(config.match_expressions.map(&:matcher)), + Chain.new(config.subjects.map(&:matcher)), method(:allowed_subject?).curry.call(config) ) end @@ -42,7 +42,7 @@ def self.select_subject?(config, subject) # # @return [Boolean] def self.ignore_subject?(config, subject) - config.ignore_expressions.any? do |expression| + config.ignore.any? do |expression| expression.prefix?(subject.expression) end end diff --git a/lib/mutant/matcher/config.rb b/lib/mutant/matcher/config.rb index f3d61d3db..63eb1e0c9 100644 --- a/lib/mutant/matcher/config.rb +++ b/lib/mutant/matcher/config.rb @@ -5,8 +5,8 @@ class Matcher # Subject matcher configuration class Config include Adamantium, Anima.new( - :ignore_expressions, - :match_expressions, + :ignore, + :subjects, :start_expressions, :subject_filters ) @@ -17,15 +17,34 @@ class Config ENUM_DELIMITER = ',' EMPTY_ATTRIBUTES = 'empty' PRESENTATIONS = IceNine.deep_freeze( - ignore_expressions: :syntax, - match_expressions: :syntax, - start_expressions: :syntax, - subject_filters: :inspect + ignore: :syntax, + start_expressions: :syntax, + subject_filters: :inspect, + subjects: :syntax ) private_constant(*constants(false)) DEFAULT = new(Hash[anima.attribute_names.map { |name| [name, []] }]) + expression = ->(input) { Mutant::Config::DEFAULT.expression_parser.call(input) } + + expression_array = Transform::Array.new(expression) + + LOADER = + Transform::Sequence.new( + [ + Transform::Hash.new( + optional: [ + Transform::Hash::Key.new('subjects', expression_array), + Transform::Hash::Key.new('ignore', expression_array) + ], + required: [] + ), + Transform::Hash::Symbolize.new, + ->(attributes) { Either::Right.new(DEFAULT.with(attributes)) } + ] + ) + # Inspection string # # @return [String] diff --git a/lib/mutant/version.rb b/lib/mutant/version.rb index 1b707c598..a6ea334a4 100644 --- a/lib/mutant/version.rb +++ b/lib/mutant/version.rb @@ -2,5 +2,5 @@ module Mutant # Current mutant version - VERSION = '0.10.23' + VERSION = '0.10.24' end # Mutant diff --git a/mutant.sh b/mutant.sh deleted file mode 100755 index 5dd0b8f1e..000000000 --- a/mutant.sh +++ /dev/null @@ -1,13 +0,0 @@ -#/usr/bin/bash -ex - -bundle exec mutant run \ - --zombie \ - --ignore-subject Mutant::Mutator::Node::Literal::Regex#body \ - --ignore-subject Mutant::CLI#add_debug_options \ - --ignore-subject Mutant::Expression::Namespace::Recursive#initialize \ - --ignore-subject Mutant::Isolation::Fork::Parent#call \ - --ignore-subject Mutant::Mutator::Node::Argument#skip? \ - --ignore-subject Mutant::Mutator::Node::ProcargZero#dispatch \ - --ignore-subject Mutant::Mutator::Node::When#mutate_conditions \ - --ignore-subject Mutant::Zombifier#call \ - $* diff --git a/mutant.yml b/mutant.yml index 540e82f47..ccd6a1e4e 100644 --- a/mutant.yml +++ b/mutant.yml @@ -6,3 +6,13 @@ requires: - mutant - mutant/integration/rspec - mutant/meta +matcher: + subjects: + - Mutant* + ignore: + - Mutant::Isolation::Fork::Parent#call + - Mutant::Mutator::Node::Argument#skip? + - Mutant::Mutator::Node::Literal::Regex#body + - Mutant::Mutator::Node::ProcargZero#dispatch + - Mutant::Mutator::Node::When#mutate_conditions + - Mutant::Zombifier#call diff --git a/scripts/devloop.sh b/scripts/devloop.sh index 2019748fa..9b86aa3c2 100755 --- a/scripts/devloop.sh +++ b/scripts/devloop.sh @@ -1,5 +1,5 @@ while inotifywait **/*.rb Gemfile Gemfile.shared mutant.gemspec; do bundle exec rspec spec/unit -fd --fail-fast --order default \ - && bundle exec ./mutant.sh --since master --fail-fast -- 'Mutant*' \ + && bundle exec mutant run --since master --fail-fast --zombie -- 'Mutant*' \ && bundle exec rubocop done diff --git a/spec/unit/mutant/bootstrap_spec.rb b/spec/unit/mutant/bootstrap_spec.rb index c37c8a595..2d2c7c28a 100644 --- a/spec/unit/mutant/bootstrap_spec.rb +++ b/spec/unit/mutant/bootstrap_spec.rb @@ -6,10 +6,10 @@ let(:integration_result) { Mutant::Either::Right.new(integration) } let(:kernel) { instance_double(Object, 'kernel') } let(:load_path) { %w[original] } - let(:match_expressions) { [] } let(:object_space) { class_double(ObjectSpace) } let(:object_space_modules) { [] } let(:start_expressions) { [] } + let(:subject_expressions) { [] } let(:timer) { instance_double(Mutant::Timer) } let(:warnings) { instance_double(Mutant::Warnings) } @@ -24,7 +24,7 @@ let(:matcher_config) do Mutant::Matcher::Config::DEFAULT.with( - match_expressions: match_expressions, + subjects: subject_expressions, start_expressions: start_expressions ) end @@ -195,15 +195,15 @@ def object.name [TestApp::Literal, TestApp::Empty] end - let(:match_expressions) do + let(:subject_expressions) do object_space_modules.map(&:name).map(&method(:parse_expression)) end let(:env_with_scopes) do env_initial.with( matchable_scopes: [ - Mutant::Scope.new(TestApp::Empty, match_expressions.last), - Mutant::Scope.new(TestApp::Literal, match_expressions.first) + Mutant::Scope.new(TestApp::Empty, subject_expressions.last), + Mutant::Scope.new(TestApp::Literal, subject_expressions.first) ] ) end @@ -236,7 +236,7 @@ def object.name config = Mutant::Config::DEFAULT.with( integration: integration, jobs: 1, - matcher: Mutant::Matcher::Config::DEFAULT.with(match_expressions: match_expressions), + matcher: Mutant::Matcher::Config::DEFAULT.with(subjects: subject_expressions), reporter: instance_double(Mutant::Reporter) ) diff --git a/spec/unit/mutant/cli_spec.rb b/spec/unit/mutant/cli_spec.rb index 00d4cc7a4..c3189d845 100644 --- a/spec/unit/mutant/cli_spec.rb +++ b/spec/unit/mutant/cli_spec.rb @@ -630,7 +630,7 @@ def self.main_body let(:bootstrap_config) do super().with( matcher: file_config.matcher.with( - match_expressions: [parse_expression('Foo#bar')] + subjects: [parse_expression('Foo#bar')] ) ) end @@ -662,7 +662,7 @@ def self.main_body let(:bootstrap_config) do super().with( matcher: file_config.matcher.with( - ignore_expressions: %w[Foo#bar Foo#baz].map(&method(:parse_expression)) + ignore: %w[Foo#bar Foo#baz].map(&method(:parse_expression)) ) ) end diff --git a/spec/unit/mutant/matcher/config_spec.rb b/spec/unit/mutant/matcher/config_spec.rb index f702bdd9b..56a0f1556 100644 --- a/spec/unit/mutant/matcher/config_spec.rb +++ b/spec/unit/mutant/matcher/config_spec.rb @@ -11,29 +11,29 @@ def apply let(:original) do described_class.new( - ignore_expressions: [parse_expression('Ignore#a')], - match_expressions: [parse_expression('Match#a')], - start_expressions: [parse_expression('Start#a')], - subject_filters: [proc_a] + ignore: [parse_expression('Ignore#a')], + start_expressions: [parse_expression('Start#a')], + subject_filters: [proc_a], + subjects: [parse_expression('Match#a')] ) end let(:other) do described_class.new( - ignore_expressions: [parse_expression('Ignore#b')], - match_expressions: [parse_expression('Match#b')], - start_expressions: [parse_expression('Start#b')], - subject_filters: [proc_b] + ignore: [parse_expression('Ignore#b')], + start_expressions: [parse_expression('Start#b')], + subject_filters: [proc_b], + subjects: [parse_expression('Match#b')] ) end it 'merges all config keys' do expect(apply).to eql( described_class.new( - ignore_expressions: [parse_expression('Ignore#a'), parse_expression('Ignore#b')], - match_expressions: [parse_expression('Match#a'), parse_expression('Match#b')], - start_expressions: [parse_expression('Start#a'), parse_expression('Start#b')], - subject_filters: [proc_a, proc_b] + ignore: [parse_expression('Ignore#a'), parse_expression('Ignore#b')], + start_expressions: [parse_expression('Start#a'), parse_expression('Start#b')], + subject_filters: [proc_a, proc_b], + subjects: [parse_expression('Match#a'), parse_expression('Match#b')] ) ) end @@ -49,28 +49,28 @@ def apply end context 'with one expression' do - let(:object) { described_class::DEFAULT.add(:match_expressions, parse_expression('Foo')) } - it { should eql('#') } + let(:object) { described_class::DEFAULT.add(:subjects, parse_expression('Foo')) } + it { should eql('#') } end context 'with many expressions' do let(:object) do described_class::DEFAULT - .add(:match_expressions, parse_expression('Foo')) - .add(:match_expressions, parse_expression('Bar')) + .add(:subjects, parse_expression('Foo')) + .add(:subjects, parse_expression('Bar')) end - it { should eql('#') } + it { should eql('#') } end context 'with match and ignore expression' do let(:object) do described_class::DEFAULT - .add(:match_expressions, parse_expression('Foo')) - .add(:ignore_expressions, parse_expression('Bar')) + .add(:subjects, parse_expression('Foo')) + .add(:ignore, parse_expression('Bar')) end - it { should eql('#') } + it { should eql('#') } end context 'with subject filter' do diff --git a/spec/unit/mutant/matcher_spec.rb b/spec/unit/mutant/matcher_spec.rb index 23c44c0fb..93ff63894 100644 --- a/spec/unit/mutant/matcher_spec.rb +++ b/spec/unit/mutant/matcher_spec.rb @@ -9,8 +9,8 @@ def apply let(:anon_matcher) { instance_double(Mutant::Matcher) } let(:env) { instance_double(Mutant::Env) } let(:ignore_expressions) { [] } - let(:match_expression_a) { expression('Foo::Bar#a', matcher_a) } - let(:match_expression_b) { expression('Foo::Bar#b', matcher_b) } + let(:expression_a) { expression('Foo::Bar#a', matcher_a) } + let(:expression_b) { expression('Foo::Bar#b', matcher_b) } let(:subject_filters) { [] } let(:matcher_a) do @@ -37,10 +37,10 @@ def apply let(:config) do Mutant::Matcher::Config.new( - ignore_expressions: ignore_expressions, - match_expressions: [match_expression_a, match_expression_b], - start_expressions: [], - subject_filters: subject_filters + ignore: ignore_expressions, + subjects: [expression_a, expression_b], + start_expressions: [], + subject_filters: subject_filters ) end diff --git a/test_app/Gemfile.minitest.lock b/test_app/Gemfile.minitest.lock index 03c0ebb03..816e78a28 100644 --- a/test_app/Gemfile.minitest.lock +++ b/test_app/Gemfile.minitest.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - mutant (0.10.23) + mutant (0.10.24) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) anima (~> 0.3.1) @@ -17,9 +17,9 @@ PATH regexp_parser (~> 2.0, >= 2.0.3) unparser (~> 0.5.6) variable (~> 0.0.1) - mutant-minitest (0.10.23) + mutant-minitest (0.10.24) minitest (~> 5.11) - mutant (= 0.10.23) + mutant (= 0.10.24) GEM remote: https://rubygems.org/ diff --git a/test_app/Gemfile.rspec3.8.lock b/test_app/Gemfile.rspec3.8.lock index 25487c776..52f9cd3a2 100644 --- a/test_app/Gemfile.rspec3.8.lock +++ b/test_app/Gemfile.rspec3.8.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - mutant (0.10.23) + mutant (0.10.24) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) anima (~> 0.3.1) @@ -17,8 +17,8 @@ PATH regexp_parser (~> 2.0, >= 2.0.3) unparser (~> 0.5.6) variable (~> 0.0.1) - mutant-rspec (0.10.23) - mutant (= 0.10.23) + mutant-rspec (0.10.24) + mutant (= 0.10.24) rspec-core (>= 3.8.0, < 4.0.0) GEM