path_coverage 0.0.1 → 0.0.2
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 +316 -0
- data/exe/path_coverage_collector +26 -0
- data/lib/path_coverage/auto.rb +5 -0
- data/lib/path_coverage/collector.rb +46 -0
- data/lib/path_coverage/condition.rb +45 -0
- data/lib/path_coverage/configuration.rb +127 -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 +38 -0
- data/lib/path_coverage/html/side.js +531 -0
- data/lib/path_coverage/html/sort.js +28 -0
- data/lib/path_coverage/html/style.css +334 -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 +43 -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 +103 -0
- data/lib/path_coverage/html_template.rb +20 -0
- data/lib/path_coverage/html_tree.rb +137 -0
- data/lib/path_coverage/json_writer.rb +21 -0
- data/lib/path_coverage/kernel.rb +179 -0
- data/lib/path_coverage/manifest.rb +177 -0
- data/lib/path_coverage/manifest_choices.rb +113 -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 +99 -0
- data/lib/path_coverage/render.rb +92 -0
- data/lib/path_coverage/result.rb +59 -0
- data/lib/path_coverage/rewriter.rb +120 -0
- data/lib/path_coverage/site.rb +11 -0
- data/lib/path_coverage/site_paths.rb +74 -0
- data/lib/path_coverage/site_tree.rb +94 -0
- data/lib/path_coverage/source_lines.rb +79 -0
- data/lib/path_coverage/summary.rb +33 -0
- data/lib/path_coverage/version.rb +3 -0
- data/lib/path_coverage.rb +2 -5
- metadata +68 -13
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require 'prism'
|
|
2
|
+
require_relative 'condition'
|
|
3
|
+
require_relative 'manifest_choices'
|
|
4
|
+
require_relative 'site'
|
|
5
|
+
|
|
6
|
+
module PathCoverage
|
|
7
|
+
# Finds every condition site in source without running it: each
|
|
8
|
+
# outermost &&, ||, and or or, each ternary and each if, with the
|
|
9
|
+
# conditions nested in it.
|
|
10
|
+
module Manifest
|
|
11
|
+
# { site => Condition } for each site in source.
|
|
12
|
+
def self.sites(source, path)
|
|
13
|
+
collector = ConditionSites.new(path)
|
|
14
|
+
Prism.parse(source).value.accept(collector)
|
|
15
|
+
collector.sites
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The source and sites of the covered file at full_path.
|
|
19
|
+
def self.parse(full_path, config)
|
|
20
|
+
source = File.read(full_path)
|
|
21
|
+
{ source:, sites: sites(source, config.relative(full_path)) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# True for a call to a named method with arguments and no
|
|
25
|
+
# parentheses, eg `id.include? 'N'`, but not `id.size == 6`;
|
|
26
|
+
# such a call is no bare argument, so a rewrite wrapping it
|
|
27
|
+
# in another call must parenthesise it.
|
|
28
|
+
def self.command?(node)
|
|
29
|
+
node.type == :call_node && node.arguments &&
|
|
30
|
+
node.opening_loc.nil? && node.name.match?(/\A[a-z_]/i)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Literals and lambdas, whose value is truthy by their form;
|
|
34
|
+
# a constant is not among them, as it can hold nil or false.
|
|
35
|
+
TRUTHY_TYPES = %i[
|
|
36
|
+
string_node integer_node symbol_node array_node hash_node
|
|
37
|
+
true_node lambda_node
|
|
38
|
+
].freeze
|
|
39
|
+
FALSY_TYPES = %i[nil_node false_node].freeze
|
|
40
|
+
|
|
41
|
+
# Ruby raises TypeError on assigning nil or false to these.
|
|
42
|
+
TRUTHY_GLOBALS = %i[$stdout $stderr].freeze
|
|
43
|
+
|
|
44
|
+
# The one outcome a leaf can have, eg :truthy for `Clock.new`
|
|
45
|
+
# or a literal, :falsy for `nil`; nil for a leaf that can
|
|
46
|
+
# have either.
|
|
47
|
+
def self.fixed_outcome(node)
|
|
48
|
+
return :falsy if FALSY_TYPES.include?(node.type)
|
|
49
|
+
|
|
50
|
+
:truthy if truthy_only?(node)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# True for a leaf that can never be falsy.
|
|
54
|
+
def self.truthy_only?(node)
|
|
55
|
+
TRUTHY_TYPES.include?(node.type) || new_call?(node) ||
|
|
56
|
+
truthy_global?(node)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# True for a call to new, eg `Clock.new`.
|
|
60
|
+
def self.new_call?(node)
|
|
61
|
+
node.type == :call_node && node.name == :new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# True for $stdout or $stderr, which can never be falsy.
|
|
65
|
+
def self.truthy_global?(node)
|
|
66
|
+
node.type == :global_variable_read_node &&
|
|
67
|
+
TRUTHY_GLOBALS.include?(node.name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Collects each site and its Condition.
|
|
71
|
+
class ConditionSites < Prism::Visitor
|
|
72
|
+
include ManifestChoices
|
|
73
|
+
|
|
74
|
+
# Starts with no sites found.
|
|
75
|
+
def initialize(path)
|
|
76
|
+
@path = path
|
|
77
|
+
@sites = {}
|
|
78
|
+
@notes = nil
|
|
79
|
+
super()
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The sites found so far.
|
|
83
|
+
attr_reader :sites
|
|
84
|
+
|
|
85
|
+
OPERATOR_TYPES = %i[and_node or_node].freeze
|
|
86
|
+
|
|
87
|
+
# Notes an && or and site.
|
|
88
|
+
def visit_and_node(node)
|
|
89
|
+
add_site(node)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Notes an || or or site.
|
|
93
|
+
def visit_or_node(node)
|
|
94
|
+
add_site(node)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Notes a ternary or an if site, its arms included; a
|
|
98
|
+
# modifier if is no site itself.
|
|
99
|
+
def visit_if_node(node)
|
|
100
|
+
return super if modifier?(node)
|
|
101
|
+
|
|
102
|
+
add_site(node)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Notes node as a site. It claims its place first, so a
|
|
106
|
+
# site found inside one of its leaves follows it, keeping
|
|
107
|
+
# the sites in source order.
|
|
108
|
+
def add_site(node)
|
|
109
|
+
site = Site.of(@path, node)
|
|
110
|
+
@sites[site] = nil
|
|
111
|
+
outer = @notes
|
|
112
|
+
@notes = { commands: [], fixed: {} }
|
|
113
|
+
condition = condition_of(node)
|
|
114
|
+
@sites[site] = condition.with(**@notes)
|
|
115
|
+
@notes = outer
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# node as a Condition, operands and all, named by its
|
|
119
|
+
# operator as written; a ternary is named ?: and an if is
|
|
120
|
+
# named if, their operands being a predicate and arms.
|
|
121
|
+
def condition_of(node)
|
|
122
|
+
return choice_of(node) if node.type == :if_node
|
|
123
|
+
|
|
124
|
+
Condition.new(
|
|
125
|
+
operator: node.operator, operands: operands_of(node),
|
|
126
|
+
extent: extent_of(node)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# node's operands: a chain of the same operator flattened
|
|
131
|
+
# into one list, the other operator nested as a Condition,
|
|
132
|
+
# anything else a leaf's byte range.
|
|
133
|
+
def operands_of(node)
|
|
134
|
+
[node.left, node.right].flat_map do |written|
|
|
135
|
+
operand = grouped(written)
|
|
136
|
+
next operands_of(operand) if operand.type == node.type
|
|
137
|
+
next [condition_of(operand)] if operator?(operand)
|
|
138
|
+
|
|
139
|
+
[leaf(operand)]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The byte range of node as written.
|
|
144
|
+
def extent_of(node)
|
|
145
|
+
loc = node.location
|
|
146
|
+
loc.start_offset...loc.end_offset
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# True for an &&, ||, and or or node.
|
|
150
|
+
def operator?(node)
|
|
151
|
+
OPERATOR_TYPES.include?(node.type)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The byte range of a leaf operand, once any site inside
|
|
155
|
+
# it is noted too, and the range noted among its site's
|
|
156
|
+
# commands and fixed leaves where it is one.
|
|
157
|
+
def leaf(operand)
|
|
158
|
+
operand.accept(self)
|
|
159
|
+
loc = operand.location
|
|
160
|
+
range = loc.start_offset...loc.end_offset
|
|
161
|
+
@notes[:commands] << range if Manifest.command?(operand)
|
|
162
|
+
outcome = Manifest.fixed_outcome(operand)
|
|
163
|
+
@notes[:fixed][range] = outcome if outcome
|
|
164
|
+
range
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The one statement inside a parenthesised operand, or the
|
|
168
|
+
# operand itself.
|
|
169
|
+
def grouped(operand)
|
|
170
|
+
return operand unless operand.type == :parentheses_node
|
|
171
|
+
|
|
172
|
+
statements = operand.body&.body || []
|
|
173
|
+
statements.size == 1 ? statements.first : operand
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require_relative 'condition'
|
|
2
|
+
|
|
3
|
+
module PathCoverage
|
|
4
|
+
# The part of the manifest's visitor that builds the sites
|
|
5
|
+
# whose predicate picks an arm: a ternary, named ?:, and an
|
|
6
|
+
# if, named if. It relies on its includer's condition_of,
|
|
7
|
+
# extent_of, grouped, leaf and operator?.
|
|
8
|
+
module ManifestChoices
|
|
9
|
+
# True for a modifier if, eg `return if done?`, whose
|
|
10
|
+
# statement comes before its if keyword.
|
|
11
|
+
def modifier?(node)
|
|
12
|
+
return false if ternary?(node) || node.statements.nil?
|
|
13
|
+
|
|
14
|
+
node.statements.location.start_offset <
|
|
15
|
+
node.if_keyword_loc.start_offset
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A ternary or an if as a Condition.
|
|
19
|
+
def choice_of(node)
|
|
20
|
+
ternary?(node) ? ternary_condition(node) : if_condition(node)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A ternary as a Condition, its extent the whole of it.
|
|
24
|
+
def ternary_condition(node)
|
|
25
|
+
Condition.new(
|
|
26
|
+
operator: '?:', operands: ternary_of(node),
|
|
27
|
+
extent: extent_of(node)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# An if as a Condition, its extent its if keyword to the end
|
|
32
|
+
# of its predicate, as its arms are statements, not part of
|
|
33
|
+
# the expression.
|
|
34
|
+
def if_condition(node)
|
|
35
|
+
start = node.if_keyword_loc.start_offset
|
|
36
|
+
Condition.new(
|
|
37
|
+
operator: 'if', operands: if_of(node),
|
|
38
|
+
extent: start...node.predicate.location.end_offset
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# An if's predicate, then its then arm and, when it has
|
|
43
|
+
# one, its else arm.
|
|
44
|
+
def if_of(node)
|
|
45
|
+
[
|
|
46
|
+
predicate_of(node),
|
|
47
|
+
statement_arm(:then, node.statements),
|
|
48
|
+
*else_arm(node.subsequent)
|
|
49
|
+
]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# An if's arm, statements its code, which never folds into
|
|
53
|
+
# the if's site; any site inside it is a site of its own.
|
|
54
|
+
# An arm with no statements has no extent.
|
|
55
|
+
def statement_arm(kind, statements)
|
|
56
|
+
statements&.accept(self)
|
|
57
|
+
extent = statements && extent_of(statements)
|
|
58
|
+
Arm.new(kind:, extent:, body: nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# An if's else arm, from subsequent: an else's statements,
|
|
62
|
+
# or an elsif, which is a site of its own; none for none.
|
|
63
|
+
def else_arm(subsequent)
|
|
64
|
+
return [] if subsequent.nil?
|
|
65
|
+
if subsequent.type == :else_node
|
|
66
|
+
return [statement_arm(:else, subsequent.statements)]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
subsequent.accept(self)
|
|
70
|
+
extent = extent_of(subsequent)
|
|
71
|
+
[Arm.new(kind: :else, extent:, body: nil)]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A ternary's predicate, then its then and else arms.
|
|
75
|
+
def ternary_of(node)
|
|
76
|
+
[
|
|
77
|
+
predicate_of(node),
|
|
78
|
+
arm_of(:then, node.statements),
|
|
79
|
+
arm_of(:else, node.subsequent.statements)
|
|
80
|
+
]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# One arm of a ternary, statements its code: a condition
|
|
84
|
+
# folds in as its body, being part of the one expression;
|
|
85
|
+
# any site inside a plain arm is a site of its own.
|
|
86
|
+
def arm_of(kind, statements)
|
|
87
|
+
written = grouped(statements.body.first)
|
|
88
|
+
body = condition_of(written) if foldable?(written)
|
|
89
|
+
statements.accept(self) if body.nil?
|
|
90
|
+
Arm.new(kind:, extent: extent_of(statements), body:)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True for a condition that folds into a ternary's site:
|
|
94
|
+
# an &&, ||, and, or, or a ternary.
|
|
95
|
+
def foldable?(node)
|
|
96
|
+
operator?(node) || ternary?(node)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True for a ternary, an if with no if keyword.
|
|
100
|
+
def ternary?(node)
|
|
101
|
+
node.type == :if_node && node.if_keyword_loc.nil?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A ternary's or an if's predicate as its operand: an && or
|
|
105
|
+
# || nested as a Condition, anything else a leaf.
|
|
106
|
+
def predicate_of(node)
|
|
107
|
+
predicate = node.predicate
|
|
108
|
+
return condition_of(predicate) if operator?(predicate)
|
|
109
|
+
|
|
110
|
+
leaf(predicate)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# Combines the operands of several runs of the same source.
|
|
3
|
+
module Merge
|
|
4
|
+
# One operands hash, as Result#operands gives them, whose
|
|
5
|
+
# counts are the sums of every run's.
|
|
6
|
+
def self.operands(runs)
|
|
7
|
+
runs.reduce do |total, run|
|
|
8
|
+
total.merge(run) do |_site, mine, theirs|
|
|
9
|
+
mine.zip(theirs).map { |a, b| sum(a, b) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# One site paths hash, as Result#paths gives them, each
|
|
15
|
+
# path's count the sum of every run's; runs of the same
|
|
16
|
+
# source list the same paths in the same order.
|
|
17
|
+
def self.paths(runs)
|
|
18
|
+
runs.reduce do |total, run|
|
|
19
|
+
total.merge(run) do |_site, mine, theirs|
|
|
20
|
+
mine.zip(theirs).map do |a, b|
|
|
21
|
+
{ path: a[:path], count: a[:count] + b[:count] }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# One operand's counts from two runs, added; a fixed leaf's
|
|
28
|
+
# only comes from the source, so either run's will do.
|
|
29
|
+
def self.sum(mine, theirs)
|
|
30
|
+
mine.merge(theirs) do |key, a, b|
|
|
31
|
+
key == :only ? a : a + b
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# The terminal worklist for paths: one line per path a method
|
|
3
|
+
# never took, and nothing for a method that took them all.
|
|
4
|
+
module PathGaps
|
|
5
|
+
OUTCOMES = [true, false].freeze
|
|
6
|
+
|
|
7
|
+
# The gap lines of methods, as PathManifest.methods gives
|
|
8
|
+
# them, for the paths the recorder counted, eg
|
|
9
|
+
# "k.rb:1:0 fee path never taken: line 3 true, line 6 true".
|
|
10
|
+
def self.lines(methods, paths)
|
|
11
|
+
methods.flat_map do |site, method|
|
|
12
|
+
taken = paths.fetch(site, {})
|
|
13
|
+
every = possible(method.decisions)
|
|
14
|
+
every.reject { |path| taken.key?(path) }.map do |path|
|
|
15
|
+
gap = "#{site} #{method.name} path never taken"
|
|
16
|
+
"#{gap}: #{steps(method, path)}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Every path through decisions, true before false at each:
|
|
22
|
+
# one taking a decision's leaving outcome ends there.
|
|
23
|
+
def self.possible(decisions)
|
|
24
|
+
return [[]] if decisions.empty?
|
|
25
|
+
|
|
26
|
+
first, *rest = decisions
|
|
27
|
+
OUTCOMES.flat_map do |outcome|
|
|
28
|
+
next [[outcome]] if outcome == first.exits
|
|
29
|
+
|
|
30
|
+
possible(rest).map { |tail| [outcome] + tail }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# path's outcomes, each named by its decision's line.
|
|
35
|
+
def self.steps(method, path)
|
|
36
|
+
path.each_with_index.map do |outcome, index|
|
|
37
|
+
"line #{method.decisions[index].line} #{outcome}"
|
|
38
|
+
end.join(', ')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require 'prism'
|
|
2
|
+
require_relative 'manifest'
|
|
3
|
+
require_relative 'site'
|
|
4
|
+
|
|
5
|
+
module PathCoverage
|
|
6
|
+
# One if a method's path passes: its line, the byte range of
|
|
7
|
+
# its predicate, and whether that predicate is a command call.
|
|
8
|
+
# exits is the outcome whose arm always leaves the method,
|
|
9
|
+
# ending the path there, or nil when neither arm does.
|
|
10
|
+
Decision = Data.define(:line, :range, :command, :exits) do
|
|
11
|
+
# A decision with no arm that leaves by default.
|
|
12
|
+
def initialize(line:, range:, command:, exits: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A method whose calls each take one path through its
|
|
18
|
+
# decisions, in source order. entry is the byte offset of its
|
|
19
|
+
# first decision's statement, where each call's path starts.
|
|
20
|
+
PathMethod = Data.define(:name, :entry, :decisions)
|
|
21
|
+
|
|
22
|
+
# Finds, without running it, the decisions of each method in
|
|
23
|
+
# source whose paths are to be counted.
|
|
24
|
+
module PathManifest
|
|
25
|
+
# { def site => PathMethod } for each def in source.
|
|
26
|
+
def self.methods(source, path)
|
|
27
|
+
collector = PathMethods.new(path)
|
|
28
|
+
Prism.parse(source).value.accept(collector)
|
|
29
|
+
collector.methods
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Collects each def and the ifs at the top level of its body.
|
|
33
|
+
class PathMethods < Prism::Visitor
|
|
34
|
+
# Starts with no methods found.
|
|
35
|
+
def initialize(path)
|
|
36
|
+
@path = path
|
|
37
|
+
@methods = {}
|
|
38
|
+
super()
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The methods found so far.
|
|
42
|
+
attr_reader :methods
|
|
43
|
+
|
|
44
|
+
# Notes node's top-level ifs as its decisions, when it has
|
|
45
|
+
# at least two (with fewer its paths are its branches),
|
|
46
|
+
# then any def inside it, each a method of its own.
|
|
47
|
+
def visit_def_node(node)
|
|
48
|
+
ifs = statements_of(node.body).select do |statement|
|
|
49
|
+
statement.type == :if_node
|
|
50
|
+
end
|
|
51
|
+
note(node, ifs) if ifs.size >= 2
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Notes node as a method whose decisions are ifs.
|
|
56
|
+
def note(node, ifs)
|
|
57
|
+
@methods[Site.of(@path, node)] = PathMethod.new(
|
|
58
|
+
name: node.name,
|
|
59
|
+
entry: ifs.first.location.start_offset,
|
|
60
|
+
decisions: ifs.map { |statement| decision(statement) }
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The top-level statements of a def's body: those before
|
|
65
|
+
# any rescue, and none for an empty or endless def.
|
|
66
|
+
def statements_of(body)
|
|
67
|
+
body = body.statements if body&.type == :begin_node
|
|
68
|
+
body&.type == :statements_node ? body.body : []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The Decision of an if statement.
|
|
72
|
+
def decision(statement)
|
|
73
|
+
predicate = statement.predicate
|
|
74
|
+
loc = predicate.location
|
|
75
|
+
Decision.new(
|
|
76
|
+
line: loc.start_line,
|
|
77
|
+
range: loc.start_offset...loc.end_offset,
|
|
78
|
+
command: Manifest.command?(predicate) || false,
|
|
79
|
+
exits: exits(statement)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
LEAVING_CALLS = %i[fail raise].freeze
|
|
84
|
+
|
|
85
|
+
# The outcome of an if statement whose arm always leaves
|
|
86
|
+
# the method: true for its then arm, false for its else
|
|
87
|
+
# arm, nil for neither.
|
|
88
|
+
def exits(statement)
|
|
89
|
+
return true if leaves?(statement.statements)
|
|
90
|
+
|
|
91
|
+
otherwise = statement.subsequent
|
|
92
|
+
false if otherwise&.type == :else_node &&
|
|
93
|
+
leaves?(otherwise.statements)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True when the last of statements is a return, or a
|
|
97
|
+
# fail or raise of the method's own; false for an empty
|
|
98
|
+
# arm, which has no statements.
|
|
99
|
+
def leaves?(statements)
|
|
100
|
+
return false if statements.nil?
|
|
101
|
+
|
|
102
|
+
last = statements.body.last
|
|
103
|
+
return true if last.type == :return_node
|
|
104
|
+
|
|
105
|
+
last.type == :call_node && last.receiver.nil? &&
|
|
106
|
+
LEAVING_CALLS.include?(last.name)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# What the reports say about each site's paths beyond their
|
|
3
|
+
# counts, worked out from each covered file's source: where
|
|
4
|
+
# each leaf is, and what it says.
|
|
5
|
+
module PathsReport
|
|
6
|
+
# { site => { leaves:, taken: } }: taken is each path and
|
|
7
|
+
# its count, as Result#paths gives them, and leaves each
|
|
8
|
+
# leaf's ["line:column", "line:column"] span, so a tool can
|
|
9
|
+
# recover each leaf exactly. files is { full path =>
|
|
10
|
+
# {source:, sites:} }.
|
|
11
|
+
def self.of(site_paths, files)
|
|
12
|
+
spans = leaf_spans(files)
|
|
13
|
+
site_paths.to_h do |site, taken|
|
|
14
|
+
[site, { leaves: spans.fetch(site), taken: }]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# { site => [each leaf's source text] } for every site in
|
|
19
|
+
# files.
|
|
20
|
+
def self.texts(files)
|
|
21
|
+
each_site(files) do |source, condition|
|
|
22
|
+
condition.leaves.map { |range| source.byteslice(range) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# { site => [each leaf's span] } for every site in files.
|
|
27
|
+
def self.leaf_spans(files)
|
|
28
|
+
each_site(files) do |source, condition|
|
|
29
|
+
condition.leaves.map do |range|
|
|
30
|
+
[at(source, range.begin), at(source, range.end)]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# { site => what the block makes of its file's source and
|
|
36
|
+
# its condition } for every site in files.
|
|
37
|
+
def self.each_site(files)
|
|
38
|
+
files.values.each_with_object({}) do |parsed, all|
|
|
39
|
+
parsed[:sites].each do |site, condition|
|
|
40
|
+
all[site] = yield(parsed[:source], condition)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "line:column" of a byte offset in source, the line from
|
|
46
|
+
# 1 and the column, in characters, from 0, as site names
|
|
47
|
+
# count them.
|
|
48
|
+
def self.at(source, offset)
|
|
49
|
+
before = source.byteslice(0, offset)
|
|
50
|
+
line = before.count("\n") + 1
|
|
51
|
+
start = before.rindex("\n")
|
|
52
|
+
"#{line}:#{before.length - (start ? start + 1 : 0)}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require_relative 'json_writer'
|
|
3
|
+
|
|
4
|
+
module PathCoverage
|
|
5
|
+
# The :post reporter: sends the JSON report to a collector,
|
|
6
|
+
# for runs whose own files are thrown away, eg a failing
|
|
7
|
+
# docker build step.
|
|
8
|
+
module Post
|
|
9
|
+
# POSTs operands and paths, as the JSON report, to url,
|
|
10
|
+
# never through an http_proxy, which could not reach a
|
|
11
|
+
# collector local to the run. A collector that cannot be
|
|
12
|
+
# reached, or does not answer within a second, is passed
|
|
13
|
+
# over in silence: the run's own output and exit status
|
|
14
|
+
# must stay as they are.
|
|
15
|
+
def self.deliver(operands, url, paths = {})
|
|
16
|
+
uri = URI(url)
|
|
17
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
18
|
+
http.open_timeout = http.read_timeout = 1
|
|
19
|
+
headers = { 'Content-Type' => 'application/json' }
|
|
20
|
+
body = JsonWriter.json(operands, paths)
|
|
21
|
+
http.post(uri.path, body, headers)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module PathCoverage
|
|
2
|
+
# Records the outcome of each instrumented operand.
|
|
3
|
+
class Recorder
|
|
4
|
+
# One call's way through its method: the outcome of each
|
|
5
|
+
# decision made so far, of the number of decisions its
|
|
6
|
+
# method has.
|
|
7
|
+
Path = Struct.new(:site, :decisions, :outcomes)
|
|
8
|
+
|
|
9
|
+
# One evaluation's way through its site: the outcome of
|
|
10
|
+
# each leaf, nil for one not yet, or never, reached.
|
|
11
|
+
Trail = Struct.new(:site, :outcomes)
|
|
12
|
+
|
|
13
|
+
# Starts with no outcomes, paths or site paths counted.
|
|
14
|
+
def initialize
|
|
15
|
+
@counts = Hash.new do |counts, key|
|
|
16
|
+
counts[key] = { truthy: 0, falsy: 0 }
|
|
17
|
+
end
|
|
18
|
+
@paths = Hash.new do |paths, site|
|
|
19
|
+
paths[site] = Hash.new(0)
|
|
20
|
+
end
|
|
21
|
+
@site_paths = Hash.new do |site_paths, site|
|
|
22
|
+
site_paths[site] = Hash.new(0)
|
|
23
|
+
end
|
|
24
|
+
@trails_key = :"path_coverage_trails_#{object_id}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The stack of site's evaluations under way in the current
|
|
28
|
+
# thread (fiber), innermost last, so another thread's never
|
|
29
|
+
# mix with its.
|
|
30
|
+
def trails(site)
|
|
31
|
+
stacks = Thread.current[@trails_key] ||=
|
|
32
|
+
Hash.new { |all, key| all[key] = [] }
|
|
33
|
+
stacks[site]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# { site => { [outcome, ...] => evaluations } } for every
|
|
37
|
+
# evaluation of a site entered and left so far.
|
|
38
|
+
attr_reader :site_paths
|
|
39
|
+
|
|
40
|
+
# Starts an evaluation of site, which has size leaves, as
|
|
41
|
+
# the trail its leaves' records fill in.
|
|
42
|
+
def enter(site, size)
|
|
43
|
+
trail = Trail.new(site, Array.new(size))
|
|
44
|
+
trails(site).push(trail)
|
|
45
|
+
trail
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Ends trail's evaluation, counting its path, and returns
|
|
49
|
+
# value, the site's, unchanged. An evaluation of the same
|
|
50
|
+
# site still entered above trail, as one that raised
|
|
51
|
+
# leaves, took trail's later outcomes, so trail's path is
|
|
52
|
+
# not counted; the stack is cleared down to trail either
|
|
53
|
+
# way.
|
|
54
|
+
def leave(trail, value)
|
|
55
|
+
stack = trails(trail.site)
|
|
56
|
+
clean = stack.last.equal?(trail)
|
|
57
|
+
nil until stack.empty? || stack.pop.equal?(trail)
|
|
58
|
+
@site_paths[trail.site][trail.outcomes] += 1 if clean
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# { method site => { [outcome, ...] => calls } } for every
|
|
63
|
+
# path taken all the way through so far.
|
|
64
|
+
attr_reader :paths
|
|
65
|
+
|
|
66
|
+
# A new call's path through the method at site, which has
|
|
67
|
+
# size decisions.
|
|
68
|
+
def path(site, size)
|
|
69
|
+
Path.new(site, size, [])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Notes value's outcome as path's index-th decision, counts
|
|
73
|
+
# path once that is its last, or once it is exits, the
|
|
74
|
+
# outcome whose arm leaves the method, and returns value
|
|
75
|
+
# unchanged.
|
|
76
|
+
def decide(path, index, value, exits = nil)
|
|
77
|
+
outcome = value ? true : false
|
|
78
|
+
path.outcomes[index] = outcome
|
|
79
|
+
last = index == path.decisions - 1 || outcome == exits
|
|
80
|
+
@paths[path.site][path.outcomes] += 1 if last
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The truthy and falsy counts of every operand recorded so
|
|
85
|
+
# far, keyed by [site, index]; an operand that never ran
|
|
86
|
+
# has no entry.
|
|
87
|
+
attr_reader :counts
|
|
88
|
+
|
|
89
|
+
# Counts value's outcome and returns it unchanged, so the
|
|
90
|
+
# condition around it behaves exactly as uninstrumented.
|
|
91
|
+
def record(site, index, value)
|
|
92
|
+
outcome = value ? :truthy : :falsy
|
|
93
|
+
@counts[[site, index]][outcome] += 1
|
|
94
|
+
trail = trails(site).last
|
|
95
|
+
trail.outcomes[index] = outcome == :truthy if trail
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|