mutant 0.9.13 → 0.10.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module CLI
5
+ class Command
6
+ class Root < self
7
+ SUBCOMMANDS = [Run, Subscription].freeze
8
+ SHORT_DESCRIPTION = 'mutation testing engine main command'
9
+ NAME = 'mutant'
10
+ end
11
+ end # Command
12
+ end # CLI
13
+ end # Mutant
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module CLI
5
+ class Command
6
+ # rubocop:disable Metrics/ClassLength
7
+ class Run < self
8
+ NAME = 'run'
9
+ SHORT_DESCRIPTION = 'Run code analysis'
10
+
11
+ OPTIONS =
12
+ %i[
13
+ add_environment_options
14
+ add_runner_options
15
+ add_integration_options
16
+ add_matcher_options
17
+ ].freeze
18
+
19
+ SLEEP = 40
20
+
21
+ UNLICENSED = <<~MESSAGE.lines.freeze
22
+ Soft fail, continuing in #{SLEEP} seconds
23
+ Next major version will enforce the license
24
+ See https://github.com/mbj/mutant#licensing
25
+ MESSAGE
26
+
27
+ # Test if command needs to be executed in zombie environment
28
+ #
29
+ # @return [Bool]
30
+ def zombie?
31
+ @config.zombie
32
+ end
33
+
34
+ private
35
+
36
+ def initialize(attributes)
37
+ super(attributes)
38
+ @config = Config::DEFAULT
39
+ end
40
+
41
+ def execute
42
+ soft_fail(License.apply(world))
43
+ .bind { Config.load_config_file(world) }
44
+ .fmap(&method(:expand))
45
+ .bind { Bootstrap.apply(world, @config) }
46
+ .bind(&Runner.public_method(:apply))
47
+ .from_right { |error| world.stderr.puts(error); return false }
48
+ .success?
49
+ end
50
+
51
+ def expand(file_config)
52
+ @config = Config.env.merge(file_config).merge(@config)
53
+ end
54
+
55
+ def soft_fail(result)
56
+ result.either(
57
+ lambda do |message|
58
+ stderr = world.stderr
59
+ stderr.puts(message)
60
+ UNLICENSED.each { |line| stderr.puts(unlicensed(line)) }
61
+ world.kernel.sleep(SLEEP)
62
+ Either::Right.new(nil)
63
+ end,
64
+ ->(_subscription) { Either::Right.new(nil) }
65
+ )
66
+ end
67
+
68
+ def unlicensed(message)
69
+ "[Mutant-License-Error]: #{message}"
70
+ end
71
+
72
+ def parse_remaining_arguments(arguments)
73
+ traverse(@config.expression_parser.public_method(:apply), arguments)
74
+ .fmap do |match_expressions|
75
+ matcher(match_expressions: match_expressions)
76
+ self
77
+ end
78
+ end
79
+
80
+ def traverse(action, values)
81
+ Either::Right.new(
82
+ values.map do |value|
83
+ action.call(value).from_right do |error|
84
+ return Either::Left.new(error)
85
+ end
86
+ end
87
+ )
88
+ end
89
+
90
+ def set(**attributes)
91
+ @config = @config.with(attributes)
92
+ end
93
+
94
+ def matcher(**attributes)
95
+ set(matcher: @config.matcher.with(attributes))
96
+ end
97
+
98
+ def add(attribute, value)
99
+ set(attribute => @config.public_send(attribute) + [value])
100
+ end
101
+
102
+ def add_matcher(attribute, value)
103
+ set(matcher: @config.matcher.add(attribute, value))
104
+ end
105
+
106
+ def add_environment_options(parser)
107
+ parser.separator('Environment:')
108
+ parser.on('--zombie', 'Run mutant zombified') do
109
+ set(zombie: true)
110
+ end
111
+ parser.on('-I', '--include DIRECTORY', 'Add DIRECTORY to $LOAD_PATH') do |directory|
112
+ add(:includes, directory)
113
+ end
114
+ parser.on('-r', '--require NAME', 'Require file with NAME') do |name|
115
+ add(:requires, name)
116
+ end
117
+ end
118
+
119
+ def add_integration_options(parser)
120
+ parser.separator('Integration:')
121
+
122
+ parser.on('--use INTEGRATION', 'Use INTEGRATION to kill mutations') do |name|
123
+ set(integration: name)
124
+ end
125
+ end
126
+
127
+ # rubocop:disable Metrics/MethodLength
128
+ def add_matcher_options(parser)
129
+ parser.separator('Matcher:')
130
+
131
+ parser.on('--ignore-subject EXPRESSION', 'Ignore subjects that match EXPRESSION as prefix') do |pattern|
132
+ add_matcher(:ignore_expressions, @config.expression_parser.apply(pattern).from_right)
133
+ end
134
+ parser.on('--start-subject EXPRESSION', 'Start mutation testing at a specific subject') do |pattern|
135
+ add_matcher(:start_expressions, @config.expression_parser.apply(pattern).from_right)
136
+ end
137
+ parser.on('--since REVISION', 'Only select subjects touched since REVISION') do |revision|
138
+ add_matcher(
139
+ :subject_filters,
140
+ Repository::SubjectFilter.new(
141
+ Repository::Diff.new(to: revision, world: world)
142
+ )
143
+ )
144
+ end
145
+ end
146
+
147
+ def add_runner_options(parser)
148
+ parser.separator('Runner:')
149
+
150
+ parser.on('--fail-fast', 'Fail fast') do
151
+ set(fail_fast: true)
152
+ end
153
+ parser.on('-j', '--jobs NUMBER', 'Number of kill jobs. Defaults to number of processors.') do |number|
154
+ set(jobs: Integer(number))
155
+ end
156
+ end
157
+ end # Run
158
+ # rubocop:enable Metrics/ClassLength
159
+ end # Command
160
+ end # CLI
161
+ end # Mutant
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ module CLI
5
+ class Command
6
+ class Subscription < self
7
+ NAME = 'subscription'
8
+ SHORT_DESCRIPTION = 'Subscription subcommands'
9
+
10
+ private
11
+
12
+ def license
13
+ License.apply(world)
14
+ end
15
+
16
+ class Test < self
17
+ NAME = 'test'
18
+ SUBCOMMANDS = [].freeze
19
+ SHORT_DESCRIPTION = 'Silently validates subscription, exits accordingly'
20
+
21
+ private
22
+
23
+ def execute
24
+ license.right?
25
+ end
26
+ end # Test
27
+
28
+ class Show < self
29
+ NAME = 'show'
30
+ SUBCOMMANDS = [].freeze
31
+ SHORT_DESCRIPTION = 'Show subscription status'
32
+
33
+ private
34
+
35
+ def execute
36
+ license.either(method(:unlicensed), method(:licensed))
37
+ end
38
+
39
+ def licensed(subscription)
40
+ world.stdout.puts(subscription.description)
41
+ true
42
+ end
43
+
44
+ def unlicensed(message)
45
+ world.stderr.puts(message)
46
+ false
47
+ end
48
+ end # Show
49
+
50
+ SUBCOMMANDS = [Show, Test].freeze
51
+ end # Subscription
52
+ end # Command
53
+ end # CLI
54
+ end # Mutant
@@ -1,55 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutant
4
- # The outer world IO objects mutant does interact with
5
- class World
6
- include Adamantium::Flat, Anima.new(
7
- :condition_variable,
8
- :gem,
9
- :gem_method,
10
- :io,
11
- :json,
12
- :kernel,
13
- :load_path,
14
- :marshal,
15
- :mutex,
16
- :object_space,
17
- :open3,
18
- :pathname,
19
- :process,
20
- :stderr,
21
- :stdout,
22
- :thread,
23
- :warnings
24
- )
25
-
26
- INSPECT = '#<Mutant::World>'
27
-
28
- private_constant(*constants(false))
29
-
30
- # Object inspection
31
- #
32
- # @return [String]
33
- def inspect
34
- INSPECT
35
- end
36
-
37
- # Capture stdout of a command
38
- #
39
- # @param [Array<String>] command
40
- #
41
- # @return [Either<String,String>]
42
- def capture_stdout(command)
43
- stdout, status = open3.capture2(*command, binmode: true)
44
-
45
- if status.success?
46
- Either::Right.new(stdout)
47
- else
48
- Either::Left.new("Command #{command} failed!")
49
- end
50
- end
51
- end # World
52
-
53
4
  # Standalone configuration of a mutant execution.
54
5
  #
55
6
  # Does not reference any "external" volatile state. The configuration applied
@@ -106,6 +57,23 @@ module Mutant
106
57
  mutant.yml
107
58
  ].freeze
108
59
 
60
+ # Merge with other config
61
+ #
62
+ # @param [Config] other
63
+ #
64
+ # @return [Config]
65
+ def merge(other)
66
+ other.with(
67
+ fail_fast: fail_fast || other.fail_fast,
68
+ includes: includes + other.includes,
69
+ jobs: other.jobs || jobs,
70
+ integration: other.integration || integration,
71
+ matcher: matcher.merge(other.matcher),
72
+ requires: requires + other.requires,
73
+ zombie: zombie || other.zombie
74
+ )
75
+ end
76
+
109
77
  private_constant(*constants(false))
110
78
 
111
79
  # Load config file
@@ -114,11 +82,12 @@ module Mutant
114
82
  # @param [Config] config
115
83
  #
116
84
  # @return [Either<String,Config>]
117
- def self.load_config_file(world, config)
118
- files = CANDIDATES.map(&world.pathname.method(:new)).select(&:readable?)
85
+ def self.load_config_file(world)
86
+ config = DEFAULT
87
+ files = CANDIDATES.map(&world.pathname.public_method(:new)).select(&:readable?)
119
88
 
120
89
  if files.one?
121
- load_contents(files.first).fmap(&config.method(:with))
90
+ load_contents(files.first).fmap(&config.public_method(:with))
122
91
  elsif files.empty?
123
92
  Either::Right.new(config)
124
93
  else
@@ -133,5 +102,12 @@ module Mutant
133
102
  .lmap(&:compact_message)
134
103
  end
135
104
  private_class_method :load_contents
105
+
106
+ # The configuration from the environment
107
+ #
108
+ # @return [Config]
109
+ def self.env
110
+ DEFAULT.with(jobs: Etc.nprocessors)
111
+ end
136
112
  end # Config
137
113
  end # Mutant
@@ -59,6 +59,5 @@ module Mutant
59
59
  names = anima.attribute_names
60
60
  new(Hash[names.zip(names.map(&match.method(:[])))])
61
61
  end
62
-
63
62
  end # Expression
64
63
  end # Mutant
@@ -4,53 +4,27 @@ module Mutant
4
4
  module License
5
5
  NAME = 'mutant-license'
6
6
  VERSION = '~> 0.1.0'
7
- SLEEP = 40
8
-
9
- UNLICENSED =
10
- IceNine.deep_freeze(
11
- [
12
- "Soft fail, continuing in #{SLEEP} seconds",
13
- 'Next major version will enforce the license',
14
- 'See https://github.com/mbj/mutant#licensing'
15
- ]
16
- )
17
7
 
8
+ # Load license
9
+ #
10
+ # @param [World] world
11
+ #
12
+ # @return [Either<String,Subscription>]
13
+ #
14
+ # @api private
18
15
  def self.apply(world)
19
- soft_fail(world, license_result(world))
20
- end
21
-
22
- def self.license_result(world)
23
16
  load_mutant_license(world)
24
17
  .fmap { license_path(world) }
25
- .fmap { |path| Subscription.from_json(world.json.load(path)) }
26
- .bind { |sub| sub.apply(world) }
18
+ .bind { |path| Subscription.load(world, world.json.load(path)) }
27
19
  end
28
- private_class_method :license_result
29
-
30
- # ignore :reek:NestedIterators
31
- def self.soft_fail(world, result)
32
- result.lmap do |message|
33
- stderr = world.stderr
34
- stderr.puts(message)
35
- UNLICENSED.each { |line| stderr.puts(unlicensed(line)) }
36
- world.kernel.sleep(SLEEP)
37
- end
38
-
39
- Either::Right.new(true)
40
- end
41
- private_class_method :soft_fail
42
20
 
43
21
  def self.load_mutant_license(world)
44
22
  Either
45
23
  .wrap_error(LoadError) { world.gem_method.call(NAME, VERSION) }
46
24
  .lmap(&:message)
47
25
  .lmap(&method(:check_for_rubygems_mutant_license))
48
- .lmap(&method(:unlicensed))
49
- end
50
-
51
- def self.unlicensed(message)
52
- "[Mutant-License-Error]: #{message}"
53
26
  end
27
+ private_class_method :load_mutant_license
54
28
 
55
29
  def self.check_for_rubygems_mutant_license(message)
56
30
  if message.include?('already activated mutant-license-0.0.0')
@@ -3,8 +3,15 @@
3
3
  module Mutant
4
4
  module License
5
5
  class Subscription
6
+ include Concord.new(:licensed)
6
7
 
7
- MESSAGE_FORMAT = <<~'MESSAGE'
8
+ FORMAT = <<~'MESSAGE'
9
+ %<subscription_name>s subscription:
10
+ Licensed:
11
+ %<licensed>s
12
+ MESSAGE
13
+
14
+ FAILURE_FORMAT = <<~'MESSAGE'
8
15
  Can not validate %<subscription_name>s license.
9
16
  Licensed:
10
17
  %<expected>s
@@ -12,31 +19,46 @@ module Mutant
12
19
  %<actual>s
13
20
  MESSAGE
14
21
 
15
- def self.from_json(value)
22
+ # Load value into subscription
23
+ #
24
+ # @param [Object] value
25
+ #
26
+ # @return [Subscription]
27
+ def self.load(world, value)
16
28
  {
17
29
  'com' => Commercial,
18
30
  'oss' => Opensource
19
- }.fetch(value.fetch('type')).from_json(value.fetch('contents'))
31
+ }.fetch(value.fetch('type'))
32
+ .from_json(value.fetch('contents'))
33
+ .apply(world)
34
+ end
35
+
36
+ # Subscription self description
37
+ #
38
+ # @return [String]
39
+ def description
40
+ FORMAT % {
41
+ licensed: licensed.to_a.join("\n"),
42
+ subscription_name: subscription_name
43
+ }
20
44
  end
21
45
 
22
46
  private
23
47
 
24
48
  def failure(expected, actual)
25
- Either::Left.new(message(expected, actual))
49
+ Either::Left.new(failure_message(expected, actual))
26
50
  end
27
51
 
28
- # ignore :reek:UtilityFunction
29
52
  def success
30
- # masked by soft fail
31
- Either::Right.new(nil)
53
+ Either::Right.new(self)
32
54
  end
33
55
 
34
56
  def subscription_name
35
57
  self.class.name.split('::').last.downcase
36
58
  end
37
59
 
38
- def message(expected, actual)
39
- MESSAGE_FORMAT % {
60
+ def failure_message(expected, actual)
61
+ FAILURE_FORMAT % {
40
62
  actual: actual.any? ? actual.map(&:to_s).join("\n") : '[none]',
41
63
  expected: expected.map(&:to_s).join("\n"),
42
64
  subscription_name: subscription_name