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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +22 -0
  3. data/README.md +316 -0
  4. data/exe/path_coverage_collector +26 -0
  5. data/lib/path_coverage/auto.rb +5 -0
  6. data/lib/path_coverage/collector.rb +46 -0
  7. data/lib/path_coverage/condition.rb +45 -0
  8. data/lib/path_coverage/configuration.rb +127 -0
  9. data/lib/path_coverage/gaps.rb +60 -0
  10. data/lib/path_coverage/html/file.html +33 -0
  11. data/lib/path_coverage/html/index.html +38 -0
  12. data/lib/path_coverage/html/side.js +531 -0
  13. data/lib/path_coverage/html/sort.js +28 -0
  14. data/lib/path_coverage/html/style.css +334 -0
  15. data/lib/path_coverage/html/toggle.js +12 -0
  16. data/lib/path_coverage/html_file.rb +109 -0
  17. data/lib/path_coverage/html_index.rb +43 -0
  18. data/lib/path_coverage/html_nav.rb +67 -0
  19. data/lib/path_coverage/html_paths.rb +138 -0
  20. data/lib/path_coverage/html_report.rb +103 -0
  21. data/lib/path_coverage/html_template.rb +20 -0
  22. data/lib/path_coverage/html_tree.rb +137 -0
  23. data/lib/path_coverage/json_writer.rb +21 -0
  24. data/lib/path_coverage/kernel.rb +179 -0
  25. data/lib/path_coverage/manifest.rb +177 -0
  26. data/lib/path_coverage/manifest_choices.rb +113 -0
  27. data/lib/path_coverage/merge.rb +35 -0
  28. data/lib/path_coverage/path_gaps.rb +41 -0
  29. data/lib/path_coverage/path_manifest.rb +110 -0
  30. data/lib/path_coverage/paths_report.rb +55 -0
  31. data/lib/path_coverage/post.rb +26 -0
  32. data/lib/path_coverage/recorder.rb +99 -0
  33. data/lib/path_coverage/render.rb +92 -0
  34. data/lib/path_coverage/result.rb +59 -0
  35. data/lib/path_coverage/rewriter.rb +120 -0
  36. data/lib/path_coverage/site.rb +11 -0
  37. data/lib/path_coverage/site_paths.rb +74 -0
  38. data/lib/path_coverage/site_tree.rb +94 -0
  39. data/lib/path_coverage/source_lines.rb +79 -0
  40. data/lib/path_coverage/summary.rb +33 -0
  41. data/lib/path_coverage/version.rb +3 -0
  42. data/lib/path_coverage.rb +2 -5
  43. 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