path_coverage 0.0.1 → 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 +4 -4
- data/LICENSE.md +22 -0
- data/README.md +353 -0
- data/exe/path_coverage_collector +37 -0
- data/lib/path_coverage/auto.rb +5 -0
- data/lib/path_coverage/calls.rb +67 -0
- data/lib/path_coverage/collector.rb +112 -0
- data/lib/path_coverage/condition.rb +96 -0
- data/lib/path_coverage/configuration.rb +130 -0
- data/lib/path_coverage/gaps.rb +60 -0
- data/lib/path_coverage/html/file.html +33 -0
- data/lib/path_coverage/html/index.html +41 -0
- data/lib/path_coverage/html/side.js +542 -0
- data/lib/path_coverage/html/sort.js +31 -0
- data/lib/path_coverage/html/style.css +338 -0
- data/lib/path_coverage/html/toggle.js +12 -0
- data/lib/path_coverage/html_file.rb +109 -0
- data/lib/path_coverage/html_index.rb +74 -0
- data/lib/path_coverage/html_nav.rb +67 -0
- data/lib/path_coverage/html_paths.rb +138 -0
- data/lib/path_coverage/html_report.rb +118 -0
- data/lib/path_coverage/html_template.rb +20 -0
- data/lib/path_coverage/html_tree.rb +146 -0
- data/lib/path_coverage/json_writer.rb +25 -0
- data/lib/path_coverage/kernel.rb +165 -0
- data/lib/path_coverage/manifest.rb +205 -0
- data/lib/path_coverage/manifest_blocks.rb +57 -0
- data/lib/path_coverage/manifest_case.rb +54 -0
- data/lib/path_coverage/manifest_choices.rb +132 -0
- data/lib/path_coverage/merge.rb +35 -0
- data/lib/path_coverage/path_gaps.rb +41 -0
- data/lib/path_coverage/path_manifest.rb +110 -0
- data/lib/path_coverage/paths_report.rb +55 -0
- data/lib/path_coverage/post.rb +26 -0
- data/lib/path_coverage/recorder.rb +182 -0
- data/lib/path_coverage/render.rb +92 -0
- data/lib/path_coverage/result.rb +59 -0
- data/lib/path_coverage/rewriter.rb +166 -0
- data/lib/path_coverage/site.rb +16 -0
- data/lib/path_coverage/site_paths.rb +86 -0
- data/lib/path_coverage/site_tree.rb +134 -0
- data/lib/path_coverage/source_lines.rb +79 -0
- data/lib/path_coverage/summary.rb +52 -0
- data/lib/path_coverage/totals.rb +29 -0
- data/lib/path_coverage/version.rb +3 -0
- data/lib/path_coverage.rb +2 -5
- metadata +73 -14
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# The operators whose predicate picks an arm.
|
|
3
|
+
CHOICES = %w[?: if unless].freeze
|
|
4
|
+
|
|
5
|
+
# The choices whose arms are statements, so whose path is
|
|
6
|
+
# decided by their predicate alone.
|
|
7
|
+
STATEMENT_CHOICES = %w[if unless].freeze
|
|
8
|
+
|
|
9
|
+
# The predicate outcome on which each choice takes its then
|
|
10
|
+
# arm: an unless's is false.
|
|
11
|
+
THEN_ON = Hash.new(true).merge('unless' => false).freeze
|
|
12
|
+
|
|
13
|
+
# The methods that call their block an item at a time until
|
|
14
|
+
# it decides, its last answer deciding the result.
|
|
15
|
+
STOP_EARLY = %w[
|
|
16
|
+
any? all? none? find detect find_index take_while
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# How many items a block method's block was called with, as
|
|
20
|
+
# its receiver's leaf outcome: none, one, or two or more.
|
|
21
|
+
ITEMS = ['0 items', '1 item', '2+ items'].freeze
|
|
22
|
+
|
|
23
|
+
# Every path a stop-early block method's call can take: how
|
|
24
|
+
# many items its block was called with, and its last answer,
|
|
25
|
+
# nil when it was never called.
|
|
26
|
+
STOP_EARLY_PATHS = [
|
|
27
|
+
[ITEMS[0], nil],
|
|
28
|
+
[ITEMS[1], true], [ITEMS[1], false],
|
|
29
|
+
[ITEMS[2], true], [ITEMS[2], false]
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# The methods that call their block with every item.
|
|
33
|
+
EVERY_ITEM = %w[
|
|
34
|
+
select filter reject partition count filter_map
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
# A block's answers over every item it was called with.
|
|
38
|
+
ANSWERS = ['all true', 'all false', 'both'].freeze
|
|
39
|
+
|
|
40
|
+
# Every path an every-item block method's call can take: how
|
|
41
|
+
# many items its block was called with, and its answers,
|
|
42
|
+
# which can be both only with two or more items.
|
|
43
|
+
EVERY_ITEM_PATHS = [
|
|
44
|
+
[ITEMS[0], nil],
|
|
45
|
+
[ITEMS[1], ANSWERS[0]], [ITEMS[1], ANSWERS[1]],
|
|
46
|
+
[ITEMS[2], ANSWERS[0]], [ITEMS[2], ANSWERS[1]],
|
|
47
|
+
[ITEMS[2], ANSWERS[2]]
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# Every block method whose call is a site, by its paths.
|
|
51
|
+
BLOCK_PATHS = [
|
|
52
|
+
*STOP_EARLY.map { |name| [name, STOP_EARLY_PATHS] },
|
|
53
|
+
*EVERY_ITEM.map { |name| [name, EVERY_ITEM_PATHS] }
|
|
54
|
+
].to_h.freeze
|
|
55
|
+
|
|
56
|
+
# One arm of a ternary, an if or an unless: kind is :then or
|
|
57
|
+
# :else, extent its byte range, nil for an arm with no
|
|
58
|
+
# statements, and body the Condition it is, when it is one,
|
|
59
|
+
# else nil for a plain value or statements, which decide
|
|
60
|
+
# nothing.
|
|
61
|
+
Arm = Data.define(:kind, :extent, :body)
|
|
62
|
+
|
|
63
|
+
# One site's condition as the manifest found it: its operator
|
|
64
|
+
# and its operands, each the byte range of a leaf or a nested
|
|
65
|
+
# Condition. A site's commands are the byte ranges of its
|
|
66
|
+
# leaves that are command calls, eg `ok? id`; its fixed are
|
|
67
|
+
# { byte range => :truthy } for leaves that can have that one
|
|
68
|
+
# outcome only, eg `Clock.new`. Its extent is the byte range
|
|
69
|
+
# of the whole condition as written, eg a ternary's from its
|
|
70
|
+
# predicate to its else arm, which its leaves alone cannot
|
|
71
|
+
# give.
|
|
72
|
+
Condition = Data.define(
|
|
73
|
+
:operator, :operands, :commands, :fixed, :extent
|
|
74
|
+
) do
|
|
75
|
+
# A condition with no command-call or fixed leaves, and no
|
|
76
|
+
# extent, by default.
|
|
77
|
+
def initialize(
|
|
78
|
+
operator:, operands:, commands: [], fixed: {}, extent: nil
|
|
79
|
+
)
|
|
80
|
+
super
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The byte range of every leaf operand, left to right, a
|
|
84
|
+
# ternary's arms' after its predicate's; a leaf's index here
|
|
85
|
+
# is the index it is recorded under.
|
|
86
|
+
def leaves
|
|
87
|
+
operands.flat_map do |operand|
|
|
88
|
+
case operand
|
|
89
|
+
when Condition then operand.leaves
|
|
90
|
+
when Arm then operand.body&.leaves || []
|
|
91
|
+
else [operand]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# Raised for a start block that cannot be applied.
|
|
3
|
+
class ConfigurationError < StandardError; end
|
|
4
|
+
|
|
5
|
+
# The settings given in a PathCoverage.start block.
|
|
6
|
+
class Configuration
|
|
7
|
+
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
8
|
+
SETTINGS = %w[
|
|
9
|
+
collector cover coverage_dir measure report root
|
|
10
|
+
].freeze
|
|
11
|
+
REPORTERS = %w[gaps html post].freeze
|
|
12
|
+
MEASURES = %w[decisions methods].freeze
|
|
13
|
+
|
|
14
|
+
# Evaluates the settings block against a new configuration.
|
|
15
|
+
def initialize(&)
|
|
16
|
+
@globs = []
|
|
17
|
+
@reporters = [:gaps]
|
|
18
|
+
@measures = [:decisions]
|
|
19
|
+
instance_eval(&)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The reporters chosen with report, :gaps when none were.
|
|
23
|
+
attr_reader :reporters
|
|
24
|
+
|
|
25
|
+
# What is measured, chosen with measure.
|
|
26
|
+
# :decisions, the paths within each decision, when nothing
|
|
27
|
+
# was chosen.
|
|
28
|
+
attr_reader :measures
|
|
29
|
+
|
|
30
|
+
# Chooses what is measured.
|
|
31
|
+
# Eg measure :decisions, :methods also measures the paths
|
|
32
|
+
# across each method's decisions.
|
|
33
|
+
# Refuses a name there is no measure for.
|
|
34
|
+
def measure(*names)
|
|
35
|
+
check('measure', names, MEASURES)
|
|
36
|
+
@measures = names.map(&:to_sym)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The dir the reports are written to.
|
|
40
|
+
def output_dir
|
|
41
|
+
@coverage_dir
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The url the :post reporter sends the JSON report to.
|
|
45
|
+
attr_reader :collector_url
|
|
46
|
+
|
|
47
|
+
# Sets the url the :post reporter sends the JSON report to.
|
|
48
|
+
def collector(url)
|
|
49
|
+
@collector_url = url
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Sets the dir that cover globs and site names are
|
|
53
|
+
# relative to.
|
|
54
|
+
def root(dir)
|
|
55
|
+
@root = dir
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Adds a glob, relative to root, of files to instrument.
|
|
59
|
+
def cover(glob)
|
|
60
|
+
@globs << glob
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Chooses the reporters run at exit, eg report :gaps;
|
|
64
|
+
# refuses a name there is no reporter for.
|
|
65
|
+
def report(*names)
|
|
66
|
+
check('reporter', names, REPORTERS)
|
|
67
|
+
@reporters = names.map(&:to_sym)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Refuses the first of names that is not in known, naming
|
|
71
|
+
# it as a kind and listing the known ones.
|
|
72
|
+
def check(kind, names, known)
|
|
73
|
+
names.each do |name|
|
|
74
|
+
next if known.include?(name.to_s)
|
|
75
|
+
|
|
76
|
+
raise ConfigurationError, [
|
|
77
|
+
"unknown #{kind} #{name} in PathCoverage.start",
|
|
78
|
+
"the #{kind}s are: #{known.join(', ')}"
|
|
79
|
+
].join("\n")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Sets the dir the path_coverage.json report is
|
|
84
|
+
# written to.
|
|
85
|
+
def coverage_dir(dir)
|
|
86
|
+
@coverage_dir = dir
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Every file the cover globs match under root, as full
|
|
90
|
+
# paths, whether or not the run loads it.
|
|
91
|
+
def files
|
|
92
|
+
Dir.glob(@globs, base: @root).sort.map { |f| "#{@root}/#{f}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Where the JSON report goes.
|
|
96
|
+
def json_path
|
|
97
|
+
"#{output_dir}/path_coverage.json"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# True when path is under root and matches a cover glob.
|
|
101
|
+
def covers?(path)
|
|
102
|
+
path.start_with?("#{@root}/") && matches?(relative(path))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# True when the root-relative path matches a cover glob.
|
|
106
|
+
def matches?(relative_path)
|
|
107
|
+
@globs.any? do |glob|
|
|
108
|
+
File.fnmatch?(glob, relative_path, GLOB_FLAGS)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# path relative to root, eg "source/kata.rb".
|
|
113
|
+
def relative(path)
|
|
114
|
+
path.delete_prefix("#{@root}/")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Refuses a setting that does not exist, naming it.
|
|
118
|
+
def method_missing(name, *)
|
|
119
|
+
raise ConfigurationError, [
|
|
120
|
+
"unknown setting #{name} in PathCoverage.start",
|
|
121
|
+
"the settings are: #{SETTINGS.join(', ')}"
|
|
122
|
+
].join("\n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Answers only for the settings there are.
|
|
126
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# The terminal worklist: one line per path a site never
|
|
3
|
+
# took, and nothing for a site that took them all. Every
|
|
4
|
+
# count stays in the operands and paths for reporters that
|
|
5
|
+
# show them all.
|
|
6
|
+
module Gaps
|
|
7
|
+
# The gap lines of operands and paths, as Result gives
|
|
8
|
+
# them, in manifest order; texts is { site => each leaf's
|
|
9
|
+
# source text }.
|
|
10
|
+
def self.lines(operands, paths: {}, texts: {})
|
|
11
|
+
operands.flat_map do |site, counts|
|
|
12
|
+
if counts.all? { |each| never_ran?(each) }
|
|
13
|
+
next ["#{site} never reached"]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
path_gaps(site, paths.fetch(site, []), texts[site])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One line per path site never took, as Result#paths gives
|
|
21
|
+
# them, naming the outcome of each leaf the path reaches,
|
|
22
|
+
# texts being each leaf's source text.
|
|
23
|
+
def self.path_gaps(site, paths, texts)
|
|
24
|
+
paths.select { |each| each[:count].zero? }.map do |each|
|
|
25
|
+
steps = each[:path].zip(texts).filter_map do |outcome, text|
|
|
26
|
+
"#{code(text)} #{outcome}" unless outcome.nil?
|
|
27
|
+
end
|
|
28
|
+
"#{site} path never taken: #{steps.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# text as a markdown code span on one line: each run of
|
|
33
|
+
# whitespace one space, fenced by a run of backticks one
|
|
34
|
+
# longer than any inside it, with a space inside the fence
|
|
35
|
+
# when text starts or ends with one.
|
|
36
|
+
def self.code(text)
|
|
37
|
+
text = text.gsub(/\s+/, ' ')
|
|
38
|
+
longest = text.scan(/`+/).map(&:size).max || 0
|
|
39
|
+
fence = '`' * (longest + 1)
|
|
40
|
+
pad = text.start_with?('`') || text.end_with?('`') ? ' ' : ''
|
|
41
|
+
"#{fence}#{pad}#{text}#{pad}#{fence}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The one outcome this operand is missing, if any; a fixed
|
|
45
|
+
# leaf, which can have one outcome only, needs only to run.
|
|
46
|
+
def self.operand_gap(counts)
|
|
47
|
+
return ['never evaluated'] if never_ran?(counts)
|
|
48
|
+
return [] if counts[:only]
|
|
49
|
+
return ['never true'] if counts[:truthy].zero?
|
|
50
|
+
return ['never false'] if counts[:falsy].zero?
|
|
51
|
+
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True when the operand was never recorded either way.
|
|
56
|
+
def self.never_ran?(counts)
|
|
57
|
+
counts[:truthy].zero? && counts[:falsy].zero?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
6
|
+
<title>{{path}} · Path coverage</title>
|
|
7
|
+
<style>
|
|
8
|
+
/*<![CDATA[*/
|
|
9
|
+
{{style}}
|
|
10
|
+
/*]]>*/
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<main class="page">
|
|
15
|
+
<header class="head" data-head="">
|
|
16
|
+
<p class="back"><a data-index="" href="{{index}}">Path coverage</a></p>
|
|
17
|
+
<h1 class="path">{{path}}</h1>
|
|
18
|
+
{{jumps}}
|
|
19
|
+
</header>
|
|
20
|
+
<div class="box">
|
|
21
|
+
{{listing}}
|
|
22
|
+
</div>
|
|
23
|
+
<div class="pathgrids" hidden="hidden">
|
|
24
|
+
{{grids}}
|
|
25
|
+
</div>
|
|
26
|
+
</main>
|
|
27
|
+
<script>
|
|
28
|
+
//<![CDATA[
|
|
29
|
+
{{script}}
|
|
30
|
+
//]]>
|
|
31
|
+
</script>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
6
|
+
<title>Path coverage</title>
|
|
7
|
+
<style>
|
|
8
|
+
/*<![CDATA[*/
|
|
9
|
+
{{style}}
|
|
10
|
+
/*]]>*/
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<main class="page">
|
|
15
|
+
<h1>Path coverage</h1>
|
|
16
|
+
<div class="box">
|
|
17
|
+
<table id="files">
|
|
18
|
+
<thead>
|
|
19
|
+
<tr>
|
|
20
|
+
<th aria-sort="none"><button type="button" data-column="0" data-type="text">File</button></th>
|
|
21
|
+
<th class="num" aria-sort="none"><button type="button" data-column="1" data-type="number">Sites</button></th>
|
|
22
|
+
<th class="num" aria-sort="none"><button type="button" data-column="2" data-type="number">Within each decision</button></th>
|
|
23
|
+
{{across}}
|
|
24
|
+
</tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
{{rows}}
|
|
28
|
+
</tbody>
|
|
29
|
+
<tfoot>
|
|
30
|
+
{{total}}
|
|
31
|
+
</tfoot>
|
|
32
|
+
</table>
|
|
33
|
+
</div>
|
|
34
|
+
</main>
|
|
35
|
+
<script>
|
|
36
|
+
//<![CDATA[
|
|
37
|
+
{{script}}
|
|
38
|
+
//]]>
|
|
39
|
+
</script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|