undercover 0.8.4 → 0.9.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.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Undercover
6
+ # Builds the Node tree of a Prism syntax tree
7
+ class NodeVisitor < Prism::Visitor
8
+ attr_reader :roots
9
+
10
+ def initialize(file_path, source_lines)
11
+ super()
12
+ @file_path = file_path
13
+ @source_lines = source_lines
14
+ @roots = []
15
+ @parents = []
16
+ end
17
+
18
+ def visit_class_node(node)
19
+ add(:class, node.name.to_s, node, node.body) { super }
20
+ end
21
+
22
+ def visit_module_node(node)
23
+ add(:module, node.name.to_s, node, node.body) { super }
24
+ end
25
+
26
+ def visit_def_node(node)
27
+ add(node.receiver ? :class_method : :instance_method, node.name_loc.slice, node, node.body) { super }
28
+ end
29
+
30
+ def visit_call_node(node)
31
+ visit_block_call(node) { super }
32
+ end
33
+
34
+ def visit_super_node(node)
35
+ visit_block_call(node) { super }
36
+ end
37
+
38
+ def visit_forwarding_super_node(node)
39
+ visit_block_call(node) { super }
40
+ end
41
+
42
+ def visit_lambda_node(node)
43
+ add(:block, block_name(node.parameters), node, node.body) { super }
44
+ end
45
+
46
+ private
47
+
48
+ def visit_block_call(node, &)
49
+ block = node.block
50
+ return yield unless block.is_a?(Prism::BlockNode)
51
+
52
+ add(:block, block_name(block.parameters), node, block.body, &)
53
+ end
54
+
55
+ def add(kind, name, prism_node, body)
56
+ new_node = build(kind, name, prism_node, empty: body.nil?)
57
+ (@parents.last&.children || roots) << new_node
58
+ @parents.push(new_node)
59
+ yield
60
+ @parents.pop
61
+ end
62
+
63
+ def build(kind, name, prism_node, empty:)
64
+ Node.new(kind, name, prism_node, @file_path, @source_lines, empty:)
65
+ end
66
+
67
+ # Lists explicit parameters only: `it` and numbered parameter blocks are a plain "block"
68
+ def block_name(parameters)
69
+ names = parameters.is_a?(Prism::BlockParametersNode) ? parameter_names(parameters) : []
70
+ names.empty? ? 'block' : "block (#{names.join(', ')})"
71
+ end
72
+
73
+ def parameter_names(block_parameters)
74
+ [*ordered_parameters(block_parameters.parameters), *block_parameters.locals].map do |param|
75
+ param.is_a?(Prism::MultiTargetNode) ? param.slice : (param.name if param.respond_to?(:name))
76
+ end
77
+ end
78
+
79
+ def ordered_parameters(params)
80
+ return [] unless params
81
+
82
+ [*params.requireds, *params.optionals, params.rest, *params.posts, *params.keywords, params.keyword_rest,
83
+ params.block].compact.grep_v(Prism::ImplicitRestNode)
84
+ end
85
+ end
86
+ end
@@ -32,32 +32,36 @@ module Undercover
32
32
  ].freeze
33
33
 
34
34
  DEFAULT_FILE_INCLUDE_GLOBS = %w[*.rb *.rake *.ru Rakefile].freeze
35
- DEFAULT_FILE_EXCLUDE_GLOBS = %w[test/* spec/* db/* config/* *_test.rb *_spec.rb].freeze
35
+ DEFAULT_FILE_EXCLUDE_GLOBS = %w[test/* spec/* db/* config/* *_test.rb *_spec.rb vendor/*].freeze
36
+
37
+ FORMATS = %w[text json].freeze
36
38
 
37
39
  attr_accessor :lcov,
38
40
  :simplecov_resultset,
39
41
  :path,
40
42
  :git_dir,
41
43
  :compare,
42
- :syntax_version,
44
+ :commits_only,
43
45
  :run_mode,
44
46
  :file_scope,
45
47
  :glob_allow_filters,
46
48
  :glob_reject_filters,
47
- :max_warnings_limit
49
+ :max_warnings_limit,
50
+ :formatter,
51
+ :no_hint
48
52
 
49
53
  def initialize
50
54
  @run_mode = DIFF_TRIGGER_LINE
51
55
  @file_scope = FILE_SCOPE_EXTENDED
52
56
  # set defaults
53
- self.path = '.'
54
57
  self.git_dir = '.git'
55
58
  self.glob_allow_filters = DEFAULT_FILE_INCLUDE_GLOBS
56
59
  self.glob_reject_filters = DEFAULT_FILE_EXCLUDE_GLOBS
57
60
  self.max_warnings_limit = nil
61
+ self.formatter = 'text'
58
62
  end
59
63
 
60
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
64
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
61
65
  def parse(args)
62
66
  args = build_opts(args)
63
67
 
@@ -70,10 +74,10 @@ module Undercover
70
74
  end
71
75
 
72
76
  opts.on_tail('--version', 'Show version') do
73
- # :nocov:
77
+ # simplecov:disable
74
78
  puts VERSION
75
79
  exit
76
- # :nocov:
80
+ # simplecov:enable
77
81
  end
78
82
 
79
83
  lcov_path_option(opts)
@@ -81,16 +85,18 @@ module Undercover
81
85
  project_path_option(opts)
82
86
  git_dir_option(opts)
83
87
  compare_option(opts)
88
+ commits_only_option(opts)
84
89
  ruby_syntax_option(opts)
85
90
  max_warnings_limit_option(opts)
86
91
  file_filters(opts)
92
+ format_option(opts)
93
+ hint_option(opts)
87
94
  end.parse(args)
88
95
 
89
96
  guess_resultset_path unless simplecov_resultset
90
97
  guess_lcov_path unless lcov
91
98
  self
92
99
  end
93
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
94
100
 
95
101
  private
96
102
 
@@ -138,8 +144,12 @@ module Undercover
138
144
  end
139
145
  end
140
146
 
147
+ # Deprecated. The project root is now read from the git repository, and paths in the
148
+ # coverage report are matched onto it without needing to be told where either lives.
149
+ # Still parsed so that an existing .undercover config file does not fail to load.
141
150
  def project_path_option(parser)
142
- parser.on('-p', '--path path', 'Project directory') do |path|
151
+ parser.on('-p', '--path path', 'Deprecated, has no effect') do |path|
152
+ warn('undercover: --path is deprecated and ignored, it will be removed in a future release')
143
153
  self.path = path
144
154
  end
145
155
  end
@@ -158,11 +168,20 @@ module Undercover
158
168
  end
159
169
  end
160
170
 
171
+ def commits_only_option(parser)
172
+ desc = 'Only check committed changes after `--compare ref`, ignoring the index and working tree'
173
+ parser.on('--commits-only', desc) do
174
+ self.commits_only = true
175
+ end
176
+ end
177
+
178
+ # Deprecated. Kept so that a `-r ruby32` line in an existing .undercover file does not fail to load.
161
179
  def ruby_syntax_option(parser)
162
- versions = Imagen::AVAILABLE_RUBY_VERSIONS.sort.join(', ')
163
- desc = "Ruby syntax version, one of: #{versions}"
164
- parser.on('-r', '--ruby-syntax ver', desc) do |version|
165
- self.syntax_version = version.strip
180
+ parser.on('-r', '--ruby-syntax ver', 'Deprecated, has no effect') do
181
+ next if @ruby_syntax_warned
182
+
183
+ warn('--ruby-syntax is ignored, undercover parses with Prism; remove it from your config')
184
+ @ruby_syntax_warned = true
166
185
  end
167
186
  end
168
187
 
@@ -174,13 +193,12 @@ module Undercover
174
193
  end
175
194
 
176
195
  def guess_resultset_path
177
- cwd = Pathname.new(File.expand_path(path))
178
- try_path = File.join(cwd, 'coverage', 'coverage.json')
196
+ try_path = File.join(Dir.pwd, 'coverage', 'coverage.json')
179
197
  self.simplecov_resultset = try_path if File.exist?(try_path)
180
198
  end
181
199
 
182
200
  def guess_lcov_path
183
- cwd = Pathname.new(File.expand_path(path))
201
+ cwd = Pathname.new(Dir.pwd)
184
202
  try_path = File.join(cwd, 'coverage', 'lcov', "#{cwd.split.last}.lcov")
185
203
  self.lcov = try_path if File.exist?(try_path)
186
204
  end
@@ -192,10 +210,25 @@ module Undercover
192
210
  self.glob_allow_filters = split_comma_separated_with_braces(comma_separated_globs)
193
211
  end
194
212
 
195
- desc = 'Skip files matching specified glob patterns (comma separated). Empty by default.'
213
+ desc = 'Skip files matching specified glob patterns (comma separated). ' \
214
+ "Defaults to '#{DEFAULT_FILE_EXCLUDE_GLOBS.join(',')}'"
196
215
  parser.on('-x', '--exclude-files globs', desc) do |comma_separated_globs|
197
216
  self.glob_reject_filters = split_comma_separated_with_braces(comma_separated_globs)
198
217
  end
199
218
  end
219
+
220
+ def format_option(parser)
221
+ desc = "Output format: #{FORMATS.join(', ')} (default: text)"
222
+ parser.on('--format FORMAT', FORMATS, desc) do |format|
223
+ self.formatter = format
224
+ end
225
+ end
226
+
227
+ def hint_option(parser)
228
+ desc = 'Hide the one-line hint printed after the text report (or set UNDERCOVER_NO_HINT=1)'
229
+ parser.on('--no-hint', desc) do
230
+ self.no_hint = true
231
+ end
232
+ end
200
233
  end
201
234
  end
@@ -9,21 +9,14 @@ module Undercover
9
9
  attr_reader :node, :coverage, :coverage_adapter, :file_path
10
10
 
11
11
  def_delegators :node, :first_line, :last_line, :name
12
- def_delegators :coverage_adapter, :skipped?
12
+ def_delegators :coverage_adapter, :skipped?, :branch_label
13
13
 
14
- def initialize(node, coverage_adapter, file_path) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
14
+ # @param coverage_index [CoverageIndex] the file's coverage, shared by all of its nodes
15
+ def initialize(node, coverage_adapter, file_path, coverage_index: nil)
15
16
  @node = node
16
17
  @coverage_adapter = coverage_adapter
17
- file_cov = coverage_adapter.coverage(file_path)
18
- @coverage = file_cov.select do |ln, _|
19
- if first_line == last_line
20
- ln == first_line
21
- elsif node.empty_def? || node.is_a?(Imagen::Node::Block)
22
- ln >= first_line && ln <= last_line # rubocop:disable Style/ComparableBetween
23
- else
24
- ln > first_line && ln < last_line
25
- end
26
- end
18
+ coverage_index ||= CoverageIndex.new(coverage_adapter.coverage(file_path))
19
+ @coverage = coverage_index.within(coverage_lines)
27
20
 
28
21
  @file_path = file_path
29
22
  @flagged = false
@@ -37,24 +30,18 @@ module Undercover
37
30
  @flagged
38
31
  end
39
32
 
40
- def uncovered?(line_no) # rubocop:disable Metrics/AbcSize
33
+ def uncovered?(line_no)
41
34
  return false if skipped?(file_path, line_no)
42
35
  return true if coverage.empty?
36
+ return true if uncovered_branch_lines.include?(line_no)
43
37
 
44
- # check branch coverage for line_no
45
- coverage.each do |ln, _block, _branch, cov|
46
- return true if ln == line_no && cov && cov != 'ignored' && cov.zero?
47
- end
48
-
49
- # check line coverage for line_no
50
- line_cov = coverage.select { |cov| cov.size == 2 }.find { |ln, _cov| ln == line_no }
51
- line_cov && line_cov[1].zero?
38
+ line_hits[line_no]&.zero?
52
39
  end
53
40
 
54
41
  # Method `coverage_f` returns the total coverage of this Undercover::Result
55
42
  # as a % value, taking into account missing branches. Line coverage will be counted
56
43
  # as 0 if any branch is untested.
57
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
44
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
58
45
  def coverage_f
59
46
  return 0.0 if coverage.empty?
60
47
 
@@ -75,7 +62,6 @@ module Undercover
75
62
 
76
63
  (lines.values.sum.to_f / lines.keys.size).round(4)
77
64
  end
78
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
79
65
 
80
66
  # TODO: create a formatter interface instead and add some tests.
81
67
  # TODO: re-enable rubocops
@@ -105,7 +91,7 @@ module Undercover
105
91
  Rainbow(formatted_line).darkgray.dark
106
92
  elsif skipped?(file_path, num)
107
93
  Rainbow(formatted_line).darkgray.dark +
108
- Rainbow(' skipped with :nocov:').italic.darkgray.dark
94
+ Rainbow(' skipped').italic.darkgray.dark
109
95
  elsif covered.nil?
110
96
  Rainbow(formatted_line).darkgray.dark +
111
97
  Rainbow(' hits: n/a').italic.darkgray.dark +
@@ -123,6 +109,15 @@ module Undercover
123
109
  end
124
110
  # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
125
111
 
112
+ # Returns raw branch coverage entries for this result as structured hashes.
113
+ # Callers that need human-readable descriptions should use AstBranchAnnotator
114
+ # separately (e.g. JsonFormatter).
115
+ def branches
116
+ coverage.select { |cov| cov.size == 4 }.map do |ln, block_no, branch_no, count|
117
+ {line: ln, block: block_no, branch: branch_no, count: count}
118
+ end
119
+ end
120
+
126
121
  def file_path_with_lines
127
122
  "#{file_path}:#{first_line}:#{last_line}"
128
123
  end
@@ -135,6 +130,30 @@ module Undercover
135
130
 
136
131
  private
137
132
 
133
+ def coverage_lines
134
+ return first_line..first_line if first_line == last_line
135
+ return first_line..last_line if covers_own_boundaries?
136
+
137
+ (first_line + 1)..(last_line - 1)
138
+ end
139
+
140
+ def covers_own_boundaries?
141
+ node.empty_def? || node.kind == :block || node.is_a?(ErbNode)
142
+ end
143
+
144
+ def uncovered_branch_lines
145
+ @uncovered_branch_lines ||= coverage.filter_map do |ln, _block, _branch, cov|
146
+ ln if cov && cov != 'ignored' && cov.zero?
147
+ end.to_set
148
+ end
149
+
150
+ # first line tuple per line, as a linear search would find it
151
+ def line_hits
152
+ @line_hits ||= coverage.each_with_object({}) do |cov, hits|
153
+ hits[cov[0]] = cov[1] if cov.size == 2 && !hits.key?(cov[0])
154
+ end
155
+ end
156
+
138
157
  def count_covered_branches(line_number) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
139
158
  branches = coverage.select { |cov| cov.size == 4 && cov[0] == line_number }
140
159
  count_covered = branches.count do |cov|
@@ -1,34 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'simplecov'
4
- require 'simplecov_json_formatter'
4
+ require 'json'
5
+ require 'undercover/version'
6
+
7
+ # SimpleCov ships its own simplecov_json_formatter.rb shim that can shadow the
8
+ # standalone gem on $LOAD_PATH. Require the gem's classes by subpath (which the
9
+ # shim does not define) so the SimpleCovJSONFormatter::* constants are present.
10
+ require 'simplecov_json_formatter/result_hash_formatter'
11
+ require 'simplecov_json_formatter/result_exporter'
5
12
 
6
13
  # Patch ResultExporter to allow setting a custom export_path
7
14
  module SimpleCovJSONFormatter
8
15
  class ResultExporter
9
16
  def export_path
10
- # :nocov:
17
+ # simplecov:disable
11
18
  File.join(SimpleCov.coverage_path, SimpleCov::Formatter::Undercover.output_filename || FILENAME)
12
- # :nocov:
19
+ # simplecov:enable
13
20
  end
14
21
  end
15
22
  end
16
23
 
17
- module SimpleCov
24
+ module SimpleCov # rubocop:disable Style/OneClassPerFile
18
25
  class << self
19
26
  attr_accessor :filter_definitions
20
27
 
21
- alias filtered_uncached filtered
22
-
23
- def filtered(files)
28
+ # Records the filters undercover needs to reproduce SimpleCov's ignore
29
+ # decisions. String/regex filters are serialized declaratively; files
30
+ # dropped by block/array/custom filters (which can't be serialized) are
31
+ # recorded by their project-relative path.
32
+ def track_filtered_out(original_files, filtered_files)
24
33
  @filter_definitions ||= extract_filter_definitions
25
- original_files = files.dup
26
- filtered_uncached(files).tap do |filtered_files|
27
- filtered_file_paths = (original_files.map(&:filename) - filtered_files.map(&:filename))
28
- filtered_file_paths.each do |file|
29
- relative_path = file.delete_prefix("#{SimpleCov.root}/")
30
- @filter_definitions << {file: relative_path} unless covered_by_serializable_filters?(relative_path)
31
- end
34
+ filtered_file_paths = (original_files.map(&:filename) - filtered_files.map(&:filename))
35
+ filtered_file_paths.each do |file|
36
+ relative_path = file.delete_prefix("#{SimpleCov.root}/")
37
+ next if covered_by_serializable_filters?(relative_path)
38
+
39
+ entry = {file: relative_path}
40
+ @filter_definitions << entry unless @filter_definitions.include?(entry)
32
41
  end
33
42
  end
34
43
 
@@ -50,7 +59,7 @@ module SimpleCov
50
59
  end
51
60
 
52
61
  def covered_by_serializable_filters?(relative_path)
53
- @filter_definitions.any? do |filter_def|
62
+ (@filter_definitions || []).any? do |filter_def|
54
63
  if filter_def[:string]
55
64
  relative_path.include?(filter_def[:string])
56
65
  elsif filter_def[:regex]
@@ -61,7 +70,20 @@ module SimpleCov
61
70
  end
62
71
  end
63
72
 
64
- module Undercover
73
+ # SimpleCov applies filters inside the private SimpleCov::Result#apply_filters!,
74
+ # so hook that method to populate filter_definitions as files are dropped.
75
+ module Undercover # rubocop:disable Style/OneClassPerFile
76
+ module SimplecovResultFilterTracking
77
+ def apply_filters!(_filters)
78
+ files_before = @files.to_a
79
+ super
80
+ SimpleCov.track_filtered_out(files_before, @files)
81
+ end
82
+ end
83
+ end
84
+ SimpleCov::Result.prepend(Undercover::SimplecovResultFilterTracking)
85
+
86
+ module Undercover # rubocop:disable Style/OneClassPerFile
65
87
  class ResultHashFormatterWithRoot < SimpleCovJSONFormatter::ResultHashFormatter
66
88
  def format
67
89
  formatted_result[:meta] = {timestamp: @result.created_at.to_i}
@@ -75,7 +97,7 @@ module Undercover
75
97
 
76
98
  def add_undercover_meta_fields
77
99
  formatted_result.tap do |result|
78
- result[:meta].merge!(simplecov_root: SimpleCov.root)
100
+ result[:meta].merge!(simplecov_root: SimpleCov.root, undercover_version: Undercover::VERSION)
79
101
  end
80
102
  end
81
103
 
@@ -93,11 +115,20 @@ module Undercover
93
115
  end
94
116
  end
95
117
 
96
- class UndercoverSimplecovFormatter < SimpleCov::Formatter::JSONFormatter
118
+ class UndercoverSimplecovFormatter
97
119
  class << self
98
120
  attr_accessor :output_filename
99
121
  end
100
122
 
123
+ # Own #format instead of inheriting SimpleCov::Formatter::JSONFormatter's,
124
+ # which bypasses #format_result and always writes coverage.json, dropping
125
+ # undercover's meta and custom filename.
126
+ def format(result)
127
+ result_hash = format_result(result)
128
+ SimpleCovJSONFormatter::ResultExporter.new(result_hash).export
129
+ result_hash
130
+ end
131
+
101
132
  def format_result(result)
102
133
  result_hash_formater = ResultHashFormatterWithRoot.new(result)
103
134
  result_hash_formater.format
@@ -105,7 +136,7 @@ module Undercover
105
136
  end
106
137
  end
107
138
 
108
- module SimpleCov
139
+ module SimpleCov # rubocop:disable Style/OneClassPerFile
109
140
  module Formatter
110
141
  Undercover = ::Undercover::UndercoverSimplecovFormatter
111
142
  end
@@ -1,28 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'undercover/root_to_relative_paths'
3
+ require 'undercover/coverage_root'
4
4
 
5
5
  module Undercover
6
6
  class SimplecovResultAdapter
7
- include RootToRelativePaths
8
-
9
- attr_reader :simplecov_result
7
+ attr_reader :simplecov_result, :only_files, :coverage_root
10
8
 
11
9
  # @param file[File] JSON file supplied by SimpleCov::Formatter::Undercover
12
10
  # @return SimplecovResultAdapter
13
- def self.parse(file, opts = nil)
14
- # :nocov:
11
+ def self.parse(file, opts = nil, only_files: nil)
12
+ # simplecov:disable
15
13
  result_h = JSON.parse(file.read)
16
14
  raise ArgumentError, 'empty SimpleCov' if result_h.empty?
17
15
 
18
- new(result_h, opts)
19
- # :nocov:
16
+ new(result_h, opts, only_files: only_files)
17
+ # simplecov:enable
20
18
  end
21
19
 
22
20
  # @param simplecov_result[SimpleCov::Result]
23
- def initialize(simplecov_result, opts)
21
+ def initialize(simplecov_result, _opts = nil, only_files: nil)
24
22
  @simplecov_result = simplecov_result
25
- @code_dir = opts&.path
23
+ @only_files = only_files
24
+ @coverage_root = CoverageRoot::NONE
25
+ @erb_coverage = (simplecov_result['coverage'] || {}).keys.any? { _1.end_with?('.erb') }
26
+ end
27
+
28
+ # @return Boolean whether the report covers any ERB template, which takes
29
+ # SimpleCov's eval coverage. Recorded before only_files drops unchanged files.
30
+ def erb_coverage?
31
+ @erb_coverage
32
+ end
33
+
34
+ # Filtering waits for the coverage root, since only_files is relative to the repository
35
+ # while coverage keys are relative to SimpleCov.root.
36
+ def coverage_root=(root)
37
+ @coverage_root = root || CoverageRoot::NONE
38
+ keep_only_files!
39
+ end
40
+
41
+ # @return String SimpleCov.root as recorded when the report was written, an absolute
42
+ # path on the machine that ran the tests
43
+ def simplecov_root
44
+ simplecov_result.dig('meta', 'simplecov_root')
45
+ end
46
+
47
+ # @return Array paths relative to SimpleCov.root
48
+ def coverage_keys
49
+ simplecov_result['coverage'].keys
26
50
  end
27
51
 
28
52
  # @param filepath[String]
@@ -52,6 +76,13 @@ module Undercover
52
76
  source_file['lines'][line_no - 1] == 'ignored'
53
77
  end
54
78
 
79
+ def branch_label(filepath, branch_no)
80
+ source_file = find_file(filepath)
81
+ return nil unless source_file
82
+
83
+ source_file['branches']&.dig(branch_no - 1, 'type')
84
+ end
85
+
55
86
  # unused for now
56
87
  def total_coverage; end
57
88
  def total_branch_coverage; end
@@ -62,8 +93,15 @@ module Undercover
62
93
 
63
94
  private
64
95
 
96
+ def keep_only_files!
97
+ return unless only_files
98
+
99
+ wanted = only_files.to_set { |path| CoverageRoot.strip(path, coverage_root) }
100
+ simplecov_result['coverage'].select! { |path, _| wanted.include?(path) }
101
+ end
102
+
65
103
  def find_file(filepath)
66
- simplecov_result['coverage'][fix_relative_filepath(filepath)]
104
+ simplecov_result['coverage'][CoverageRoot.strip(filepath, coverage_root)]
67
105
  end
68
106
  end
69
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Undercover
4
- VERSION = '0.8.4'
4
+ VERSION = '0.9.0'
5
5
  end