undercover 0.8.5 → 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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Undercover
6
+ # Coverage reports record paths relative to SimpleCov.root, while a changeset records
7
+ # them relative to the git repository root. In a monorepo the two differ by a directory
8
+ # prefix, e.g. coverage says "main.rb" where git says "apps/dash/main.rb".
9
+ #
10
+ # SimpleCov.root is stored in the report, but as an absolute path from whichever machine
11
+ # ran the tests, which need not exist where undercover runs. Its tail still names the
12
+ # directory though, so match the longest tail that exists in the repository.
13
+ module CoverageRoot
14
+ NONE = ''
15
+
16
+ # Coverage keys checked when confirming a candidate. A handful is enough to tell a
17
+ # real match from a directory that merely shares a name.
18
+ SAMPLE_SIZE = 20
19
+
20
+ # @param git_root [String] absolute path to the repository working directory
21
+ # @param simplecov_root [String, nil] SimpleCov.root as recorded in the report
22
+ # @param coverage_keys [Array<String>] paths relative to SimpleCov.root, used to
23
+ # confirm a candidate actually holds the covered files
24
+ # @param exists [#call, nil] whether a path relative to git_root exists in the
25
+ # repository; checks the disk by default
26
+ # @return [String] prefix that joins a coverage key onto a repository path,
27
+ # NONE when coverage was recorded at the repository root or cannot be placed
28
+ def self.derive(git_root, simplecov_root, coverage_keys = nil, exists: nil)
29
+ return NONE if git_root.nil? || simplecov_root.nil? || simplecov_root.empty?
30
+
31
+ exists ||= ->(path) { File.exist?(File.join(git_root, path)) }
32
+ candidates(simplecov_root, exists).find do |prefix|
33
+ holds_covered_files?(prefix, coverage_keys, exists)
34
+ end || NONE
35
+ end
36
+
37
+ # @return [String] filepath relative to the coverage root
38
+ def self.strip(filepath, root)
39
+ return filepath if root.nil? || root.empty?
40
+
41
+ filepath.delete_prefix("#{root}/")
42
+ end
43
+
44
+ # @return [Boolean] whether a repository path lives under the coverage root
45
+ def self.under?(filepath, root)
46
+ return true if root.nil? || root.empty?
47
+
48
+ filepath.start_with?("#{root}/")
49
+ end
50
+
51
+ # Tails of SimpleCov.root that exist in the repository, longest first. "/build/x/app"
52
+ # offers "app", then "x/app", then "build/x/app"; the longest that exists wins, so a
53
+ # nested app beats a directory that happens to share its leaf name.
54
+ def self.candidates(simplecov_root, exists)
55
+ parts = Pathname.new(simplecov_root).each_filename.to_a
56
+ (1..parts.length)
57
+ .map { |depth| parts.last(depth).join('/') }
58
+ .select { |prefix| exists.call(prefix) }
59
+ .sort_by { |prefix| -prefix.length }
60
+ end
61
+ private_class_method :candidates
62
+
63
+ # Guards against a coincidence: the repository may contain a directory named like
64
+ # SimpleCov.root's leaf without being the place the coverage came from.
65
+ def self.holds_covered_files?(prefix, coverage_keys, exists)
66
+ keys = Array(coverage_keys).reject { |key| key.nil? || key.empty? }
67
+ return true if keys.empty?
68
+
69
+ keys.first(SAMPLE_SIZE).any? { |key| exists.call(File.join(prefix, key)) }
70
+ end
71
+ private_class_method :holds_covered_files?
72
+ end
73
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erubi'
4
+
5
+ module Undercover
6
+ # Extracts a Ruby string from an ERB template, preserving line positions so
7
+ # Prism produces a syntax tree whose line numbers map 1:1 back to the
8
+ # original .erb file.
9
+ #
10
+ # Uses erubi (pure Ruby, no native extension).
11
+ module ErbExtractor
12
+ # Mirrors ActionView's Erubi handler: `<%= helper do |x| %>` opens a block,
13
+ # so it must not be wrapped in `( ... ).to_s` like other expressions.
14
+ class Engine < ::Erubi::Engine
15
+ BLOCK_EXPR = /((\s|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
16
+
17
+ private
18
+
19
+ def add_expression(indicator, code)
20
+ return super unless BLOCK_EXPR.match?(code)
21
+
22
+ src << " #{bufvar}.append= " << code
23
+ end
24
+ end
25
+
26
+ module_function
27
+
28
+ def call(erb_source)
29
+ Engine.new(erb_source, trim: true).src
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undercover
4
+ # A Ruby construct parsed from an ERB template (if, unless, while, until, case, block)
5
+ class ErbNode < Node
6
+ KINDS = %i[block if unless while until case].freeze
7
+
8
+ # ErbExtractor keeps the template's line numbers, so nodes print the template's own lines.
9
+ # partial_script allows a top-level yield, as in a layout.
10
+ def self.parse(template, file_path)
11
+ Node.parse(ErbExtractor.call(template), file_path, visitor_class: ErbVisitor,
12
+ source_lines: template.lines.map(&:chomp),
13
+ partial_script: true)
14
+ end
15
+
16
+ def human_name
17
+ "#{kind} block"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undercover
4
+ # Also reports the control flow of ERB templates, where whole sections of markup sit in
5
+ # an if or a loop. An if is one node with its elsif branches.
6
+ class ErbVisitor < NodeVisitor
7
+ def visit_if_node(node)
8
+ add(:if, 'if', node, node.statements) { visit_if_branches(node) }
9
+ end
10
+
11
+ def visit_unless_node(node)
12
+ add(:unless, 'unless', node, node.statements) { super }
13
+ end
14
+
15
+ def visit_while_node(node)
16
+ add(:while, 'while', node, node.statements) { super }
17
+ end
18
+
19
+ def visit_until_node(node)
20
+ add(:until, 'until', node, node.statements) { super }
21
+ end
22
+
23
+ def visit_case_node(node)
24
+ add(:case, 'case', node, node.conditions) { super }
25
+ end
26
+
27
+ private
28
+
29
+ def visit_if_branches(node)
30
+ node.predicate.accept(self)
31
+ node.statements&.accept(self)
32
+ branch = node.subsequent
33
+ branch.is_a?(Prism::IfNode) ? visit_if_branches(branch) : branch&.accept(self)
34
+ end
35
+
36
+ def build(kind, name, prism_node, empty:)
37
+ return super unless ErbNode::KINDS.include?(kind)
38
+
39
+ ErbNode.new(kind, name, prism_node, @file_path, @source_lines, empty:)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undercover
4
+ # Parses a changed file into Undercover::Result nodes. ERB templates go through
5
+ # ErbExtractor first, which turns a template into Ruby with line positions preserved
6
+ # so the parser can place nodes back onto the original template's line numbers.
7
+ #
8
+ # Expects the host to provide #code_dir, #coverage_adapter, #loaded_files and #erb_parse_failures.
9
+ module FileLoader
10
+ ERB_EXTENSION = '.erb'
11
+
12
+ def load_and_parse_file(filepath)
13
+ key = filepath.gsub(/^\.\//, '')
14
+ return if loaded_files.key?(key)
15
+
16
+ loaded_files[key] = nil
17
+ nodes = filepath.end_with?(ERB_EXTENSION) ? load_erb_file(key, filepath) : load_ruby_file(key, filepath)
18
+ loaded_files[key] = build_results(nodes, filepath) if nodes&.any?
19
+ end
20
+
21
+ private
22
+
23
+ def load_erb_file(key, filepath)
24
+ return unless coverage_adapter.erb_coverage?
25
+
26
+ full_path = File.join(code_dir, filepath)
27
+ return unless File.exist?(full_path)
28
+
29
+ all_nodes(ErbNode.parse(File.read(full_path), full_path))
30
+ rescue StandardError => e
31
+ erb_parse_failures << key
32
+ warn "#{key}: ERB parsing failed (#{e.message})"
33
+ end
34
+
35
+ def load_ruby_file(key, filepath)
36
+ all_nodes(Node.parse_file(File.join(code_dir, filepath)))
37
+ rescue Node::ParseError => e
38
+ warn "#{key}: Ruby parsing failed (#{e.message})"
39
+ end
40
+
41
+ def all_nodes(roots)
42
+ roots.flat_map { _1.find_all(->(_node) { true }) }
43
+ end
44
+
45
+ # Indexes the file's coverage once for all of its nodes
46
+ def build_results(nodes, filepath)
47
+ index = CoverageIndex.new(coverage_adapter.coverage(filepath))
48
+ nodes.map { |node| Result.new(node, coverage_adapter, filepath, coverage_index: index) }
49
+ end
50
+ end
51
+ end
@@ -2,40 +2,65 @@
2
2
 
3
3
  module Undercover
4
4
  class FilterSet
5
- attr_reader :allow_filters, :reject_filters, :simplecov_filters
5
+ attr_reader :allow_filters, :reject_filters, :simplecov_filters, :coverage_root
6
6
 
7
- def initialize(allow_filters, reject_filters, simplecov_filters)
7
+ def initialize(allow_filters, reject_filters, simplecov_filters, coverage_root: CoverageRoot::NONE)
8
8
  @allow_filters = allow_filters || []
9
9
  @reject_filters = reject_filters || []
10
10
  @simplecov_filters = simplecov_filters || []
11
+ @coverage_root = coverage_root || CoverageRoot::NONE
11
12
  end
12
13
 
14
+ # @param filepath[String] path relative to the git repository root
13
15
  def include?(filepath)
14
- fnmatch = proc { |glob| File.fnmatch(glob, filepath, File::FNM_EXTGLOB) }
16
+ # Changes outside the coverage root belong to a different project in the same
17
+ # repository and have no coverage data to judge them by.
18
+ return false unless CoverageRoot.under?(filepath, coverage_root)
19
+
20
+ # Every filter below is written relative to SimpleCov.root, so compare there.
21
+ relative = CoverageRoot.strip(filepath, coverage_root)
15
22
 
16
23
  # Check if file was ignored by SimpleCov filters
17
- return false if ignored_by_simplecov?(filepath)
24
+ return false if ignored_by_simplecov?(relative)
18
25
 
19
26
  # Apply Undercover's own filters
20
- allow_filters.any?(fnmatch) && reject_filters.none?(fnmatch)
27
+ matches_globs?(relative)
28
+ end
29
+
30
+ # A file undercover would otherwise have reported on, dropped only by --include-files
31
+ # or --exclude-files. Used to tell a misconfigured glob apart from a clean run.
32
+ # @param filepath[String] path relative to the git repository root
33
+ def rejected_by_globs?(filepath)
34
+ return false unless CoverageRoot.under?(filepath, coverage_root)
35
+
36
+ relative = CoverageRoot.strip(filepath, coverage_root)
37
+ return false if ignored_by_simplecov?(relative)
38
+
39
+ !matches_globs?(relative)
21
40
  end
22
41
 
23
42
  private
24
43
 
44
+ def matches_globs?(relative)
45
+ fnmatch = proc { |glob| File.fnmatch(glob, relative, File::FNM_EXTGLOB) }
46
+ allow_filters.any?(fnmatch) && reject_filters.none?(fnmatch)
47
+ end
48
+
25
49
  def ignored_by_simplecov?(filepath)
26
50
  simplecov_filters.any? do |filter|
27
51
  filter = filter.transform_keys(&:to_sym)
28
52
  if filter[:string]
29
- normalized(filepath).include?(filter[:string])
53
+ normalize_slash(filepath).include?(filter[:string])
30
54
  elsif filter[:regex]
31
- normalized(filepath).match?(Regexp.new(filter[:regex]))
55
+ normalize_slash(filepath).match?(Regexp.new(filter[:regex]))
32
56
  elsif filter[:file]
33
- filepath == filter[:file] # TODO: check if file also gets a slash?
57
+ filepath == filter[:file]
34
58
  end
35
59
  end
36
60
  end
37
61
 
38
- def normalized(filepath)
62
+ # SimpleCov's 'rails' profile adds regex filters that start with a slash by default. Let's be compatible.
63
+ def normalize_slash(filepath)
39
64
  filepath.start_with?('/') ? filepath : "/#{filepath}"
40
65
  end
41
66
  end
@@ -63,7 +63,7 @@ module Undercover
63
63
  end
64
64
 
65
65
  def annotated_branches(result) # rubocop:disable Metrics/AbcSize
66
- ast_info = AstBranchAnnotator.call(result.node.ast_node)
66
+ ast_info = AstBranchAnnotator.call(result.node.prism_node)
67
67
  counts_per_line = result.branches.each_with_object(Hash.new(0)) { |b, h| h[b[:line]] += 1 }
68
68
  result.branches.map do |entry|
69
69
  arm = result.branch_label(result.file_path, entry[:branch]) if counts_per_line[entry[:line]] > 1
@@ -1,25 +1,26 @@
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 LcovParseError < StandardError
7
7
  end
8
8
 
9
9
  class LcovParser
10
- include RootToRelativePaths
11
-
12
10
  attr_reader :io, :source_files
11
+ attr_accessor :coverage_root
13
12
 
14
- def initialize(lcov_io, opts)
13
+ def initialize(lcov_io, _opts = nil, only_files: nil)
15
14
  @io = lcov_io
16
15
  @source_files = {}
17
- @code_dir = opts&.path
16
+ @only_files = only_files&.to_set
17
+ @coverage_root = CoverageRoot::NONE
18
18
  end
19
19
 
20
- def self.parse(lcov_report_path, opts = nil)
21
- lcov_io = File.open(lcov_report_path)
22
- new(lcov_io, opts).parse
20
+ def self.parse(lcov_report_path, opts = nil, only_files: nil)
21
+ File.open(lcov_report_path) do |lcov_io|
22
+ return new(lcov_io, opts, only_files: only_files).parse
23
+ end
23
24
  end
24
25
 
25
26
  def parse
@@ -28,11 +29,24 @@ module Undercover
28
29
  self
29
30
  end
30
31
 
32
+ # @return Array paths relative to SimpleCov.root
33
+ def coverage_keys
34
+ source_files.keys
35
+ end
36
+
37
+ # @return Boolean whether the report covers any ERB template, before only_files filtering
38
+ def erb_coverage?
39
+ @erb_coverage || false
40
+ end
41
+
42
+ # LCOV carries no SimpleCov.root, so coverage is always taken to sit at the repository
43
+ # root. Reports covering a subdirectory need the SimpleCov JSON format.
44
+ def simplecov_root
45
+ nil
46
+ end
47
+
31
48
  def coverage(filepath)
32
- _filename, coverage = source_files.find do |relative_path, _|
33
- relative_path == fix_relative_filepath(filepath)
34
- end
35
- coverage || []
49
+ source_files[CoverageRoot.strip(filepath, coverage_root)] || []
36
50
  end
37
51
 
38
52
  def total_coverage
@@ -69,19 +83,25 @@ module Undercover
69
83
 
70
84
  private
71
85
 
72
- # rubocop:disable Metrics/MethodLength, Style/SpecialGlobalVars, Metrics/AbcSize
86
+ # rubocop:disable-next Metrics/MethodLength, Metrics/CyclomaticComplexity, Style/SpecialGlobalVars, Metrics/AbcSize
73
87
  def parse_line(line)
74
88
  case line
75
89
  when /^SF:(.+)/
76
- @current_filename = $~[1].gsub(/^\.\//, '')
77
- source_files[@current_filename] = []
90
+ filename = $~[1].gsub(/^\.\//, '')
91
+ @erb_coverage ||= filename.end_with?('.erb')
92
+ @current_filename = @only_files.nil? || @only_files.include?(filename) ? filename : nil
93
+ source_files[@current_filename] = [] if @current_filename
78
94
  when /^DA:(\d+),(\d+)/
95
+ return unless @current_filename
96
+
79
97
  line_no = $~[1]
80
98
  covered = $~[2]
81
99
  source_files[@current_filename] << [line_no.to_i, covered.to_i]
82
100
  when /^(BRF|BRH):(\d+)/
83
101
  # branches found/hit; no-op
84
102
  when /^BRDA:(\d+),(\d+),(\d+),(-|\d+)/
103
+ return unless @current_filename
104
+
85
105
  line_no = $~[1]
86
106
  block_no = $~[2]
87
107
  branch_no = $~[3]
@@ -94,6 +114,5 @@ module Undercover
94
114
  raise LcovParseError, "could not recognise '#{line}' as valid LCOV"
95
115
  end
96
116
  end
97
- # rubocop:enable Metrics/MethodLength, Style/SpecialGlobalVars, Metrics/AbcSize
98
117
  end
99
118
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Undercover
6
+ # A class, module, method or block parsed from a Ruby file, with the line range
7
+ # undercover checks coverage for. Blocks span their whole method call, receiver included.
8
+ class Node
9
+ class ParseError < StandardError; end
10
+
11
+ HUMAN_NAMES = {
12
+ class: 'class',
13
+ module: 'module',
14
+ instance_method: 'instance method',
15
+ class_method: 'class method',
16
+ block: 'block'
17
+ }.freeze
18
+
19
+ attr_reader :kind, :name, :prism_node, :file_path, :children
20
+
21
+ # @param source_lines [Array<String>] the lines nodes print, when not the parsed source's own
22
+ # @return [Array<Node>] top-level nodes, each with its nested nodes as children
23
+ def self.parse(source, file_path, visitor_class: NodeVisitor, source_lines: source.lines.map(&:chomp),
24
+ partial_script: false)
25
+ result = Prism.parse(source, filepath: file_path, partial_script:)
26
+ if result.failure?
27
+ error = result.errors.first
28
+ raise ParseError, "#{error.message} on line #{error.location.start_line}"
29
+ end
30
+
31
+ visitor_class.new(file_path, source_lines).tap { result.value.accept(_1) }.roots
32
+ end
33
+
34
+ def self.parse_file(path)
35
+ parse(File.read(path), path)
36
+ end
37
+
38
+ def initialize(kind, name, prism_node, file_path, source_lines, empty:) # rubocop:disable Metrics/ParameterLists
39
+ @kind = kind
40
+ @name = name
41
+ @prism_node = prism_node
42
+ @file_path = file_path
43
+ @file_lines = source_lines
44
+ @empty = empty
45
+ @children = []
46
+ end
47
+
48
+ def human_name
49
+ HUMAN_NAMES.fetch(kind)
50
+ end
51
+
52
+ def first_line
53
+ prism_node.location.start_line
54
+ end
55
+
56
+ def last_line
57
+ prism_node.location.end_line
58
+ end
59
+
60
+ def empty_def?
61
+ @empty
62
+ end
63
+
64
+ def source_lines
65
+ @file_lines[(first_line - 1)..(last_line - 1)]
66
+ end
67
+
68
+ def source_lines_with_numbers
69
+ (first_line..last_line).zip(source_lines)
70
+ end
71
+
72
+ def find_all(matcher, ret = [])
73
+ ret << self if matcher.call(self)
74
+ children.each { |child| child.find_all(matcher, ret) }
75
+ ret
76
+ end
77
+ end
78
+ end
@@ -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