path_coverage 0.1.0 → 0.2.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +287 -78
  3. data/lib/path_coverage/calls.rb +54 -15
  4. data/lib/path_coverage/condition.rb +25 -2
  5. data/lib/path_coverage/configuration.rb +44 -7
  6. data/lib/path_coverage/configuration_paths.rb +56 -0
  7. data/lib/path_coverage/console_summary.rb +77 -0
  8. data/lib/path_coverage/gaps.rb +25 -9
  9. data/lib/path_coverage/html/index.html +3 -17
  10. data/lib/path_coverage/html/side.js +21 -2
  11. data/lib/path_coverage/html/sort.js +24 -21
  12. data/lib/path_coverage/html/style.css +28 -1
  13. data/lib/path_coverage/html/tabs.js +15 -0
  14. data/lib/path_coverage/html_index.rb +86 -31
  15. data/lib/path_coverage/html_index_head.rb +49 -0
  16. data/lib/path_coverage/html_report.rb +30 -13
  17. data/lib/path_coverage/html_tabs.rb +26 -0
  18. data/lib/path_coverage/html_tree.rb +41 -20
  19. data/lib/path_coverage/json_reader.rb +94 -0
  20. data/lib/path_coverage/json_writer.rb +41 -9
  21. data/lib/path_coverage/kernel.rb +42 -20
  22. data/lib/path_coverage/kinds/base.rb +71 -0
  23. data/lib/path_coverage/kinds/block.rb +136 -0
  24. data/lib/path_coverage/kinds/case.rb +89 -0
  25. data/lib/path_coverage/kinds/chain.rb +45 -0
  26. data/lib/path_coverage/kinds/choice.rb +68 -0
  27. data/lib/path_coverage/kinds/contains.rb +111 -0
  28. data/lib/path_coverage/kinds/safe.rb +151 -0
  29. data/lib/path_coverage/kinds/write.rb +54 -0
  30. data/lib/path_coverage/kinds.rb +37 -0
  31. data/lib/path_coverage/manifest.rb +58 -19
  32. data/lib/path_coverage/manifest_blocks.rb +49 -10
  33. data/lib/path_coverage/manifest_chains.rb +76 -0
  34. data/lib/path_coverage/manifest_choices.rb +30 -17
  35. data/lib/path_coverage/manifest_folding.rb +91 -0
  36. data/lib/path_coverage/manifest_ignoring.rb +35 -0
  37. data/lib/path_coverage/manifest_writes.rb +67 -0
  38. data/lib/path_coverage/merge.rb +19 -3
  39. data/lib/path_coverage/post.rb +8 -8
  40. data/lib/path_coverage/recorder.rb +9 -49
  41. data/lib/path_coverage/recorder_blocks.rb +68 -0
  42. data/lib/path_coverage/recorder_cases.rb +23 -0
  43. data/lib/path_coverage/recorder_chains.rb +40 -0
  44. data/lib/path_coverage/recorder_writes.rb +26 -0
  45. data/lib/path_coverage/render.rb +58 -46
  46. data/lib/path_coverage/result.rb +19 -0
  47. data/lib/path_coverage/rewriter.rb +58 -77
  48. data/lib/path_coverage/rewriter_nested.rb +62 -0
  49. data/lib/path_coverage/site_paths.rb +9 -58
  50. data/lib/path_coverage/site_tree.rb +12 -57
  51. data/lib/path_coverage/summary.rb +32 -4
  52. data/lib/path_coverage/totals.rb +1 -1
  53. data/lib/path_coverage/version.rb +1 -1
  54. metadata +30 -6
@@ -2,7 +2,11 @@ require 'prism'
2
2
  require_relative 'condition'
3
3
  require_relative 'manifest_blocks'
4
4
  require_relative 'manifest_case'
5
+ require_relative 'manifest_chains'
5
6
  require_relative 'manifest_choices'
7
+ require_relative 'manifest_folding'
8
+ require_relative 'manifest_ignoring'
9
+ require_relative 'manifest_writes'
6
10
  require_relative 'site'
7
11
 
8
12
  module PathCoverage
@@ -10,9 +14,15 @@ module PathCoverage
10
14
  # outermost &&, ||, and or or, each ternary, if, unless and case
11
15
  # with a subject, with the conditions nested in it.
12
16
  module Manifest
13
- # { site => Condition } for each site in source.
14
- def self.sites(source, path)
15
- collector = ConditionSites.new(path)
17
+ # { site => Condition } for each site in source, none of
18
+ # them named by an operator in ignored; a call to a method
19
+ # named in present, as a &.'s receiver, is never nil, and a
20
+ # call to one named in nonempty, eg any?, is true exactly
21
+ # when its receiver has items.
22
+ def self.sites(source, path, ignored = [], present = [],
23
+ nonempty = [])
24
+ collector = ConditionSites.new(path, ignored,
25
+ [present, nonempty])
16
26
  Prism.parse(source).value.accept(collector)
17
27
  collector.sites
18
28
  end
@@ -20,7 +30,10 @@ module PathCoverage
20
30
  # The source and sites of the covered file at full_path.
21
31
  def self.parse(full_path, config)
22
32
  source = File.read(full_path)
23
- { source:, sites: sites(source, config.relative(full_path)) }
33
+ path = config.relative(full_path)
34
+ treated = %w[present nonempty].map { config.treated(_1, path) }
35
+ ignored = config.ignored
36
+ { source:, sites: sites(source, path, ignored, *treated) }
24
37
  end
25
38
 
26
39
  # True for a call to a named method with arguments and no
@@ -73,13 +86,24 @@ module PathCoverage
73
86
  class ConditionSites < Prism::Visitor
74
87
  include ManifestBlocks
75
88
  include ManifestCase
89
+ include ManifestChains
76
90
  include ManifestChoices
77
-
78
- # Starts with no sites found.
79
- def initialize(path)
91
+ include ManifestFolding
92
+ include ManifestIgnoring
93
+ include ManifestWrites
94
+
95
+ # Starts with no sites found, to find none named by an
96
+ # operator in ignored, and to take a call to a method named
97
+ # in present as never nil, and to one in nonempty as true
98
+ # exactly when its receiver has items.
99
+ def initialize(path, ignored, (present, nonempty))
80
100
  @path = path
101
+ @ignored = ignored
102
+ @present = present
103
+ @nonempty = nonempty
81
104
  @sites = {}
82
105
  @notes = nil
106
+ @inner = nil
83
107
  super()
84
108
  end
85
109
 
@@ -118,10 +142,11 @@ module PathCoverage
118
142
  add_site(node)
119
143
  end
120
144
 
121
- # Notes a block method's call as a site, when its block's
122
- # every call can be seen; any other call is no site, though
123
- # a condition inside it may be.
145
+ # Notes a &. chain as a site, or a block method's call, when
146
+ # its block's every call can be seen; any other call is no
147
+ # site, though a condition inside it may be.
124
148
  def visit_call_node(node)
149
+ return add_site(node) if safe_chain?(node)
125
150
  return super unless block_site?(node)
126
151
 
127
152
  add_site(node)
@@ -130,12 +155,17 @@ module PathCoverage
130
155
  # Notes node as a site, named where it starts: an if or an
131
156
  # unless at its keyword. It claims its place first, so a
132
157
  # site found inside one of its leaves or arms follows it.
158
+ # While a leaf is being visited, any site is collected as
159
+ # part of that leaf instead, as it is part of one expression.
133
160
  def add_site(node)
161
+ return visit_child_nodes(node) if ignored?(node)
162
+ return @inner << nested_condition_of(node) if @inner
163
+
134
164
  site = Site.at(@path, site_loc(node))
135
165
  @sites[site] = nil
136
166
  outer = @notes
137
167
  @notes = { commands: [], fixed: {} }
138
- condition = condition_of(node)
168
+ condition = scoped { condition_of(node) }
139
169
  @sites[site] = condition.with(**@notes)
140
170
  @notes = outer
141
171
  end
@@ -143,11 +173,14 @@ module PathCoverage
143
173
  # node as a Condition, operands and all, named by its
144
174
  # operator as written; a ternary is named ?:, an if if and
145
175
  # an unless unless, their operands a predicate and arms; a
146
- # case case, its operands its arms.
176
+ # case case, its operands its arms; a write ||= or &&=, its
177
+ # operands its target and its right side; a &. chain &., its
178
+ # operands its receivers.
147
179
  def condition_of(node)
180
+ return write_condition(node) if write?(node)
148
181
  return choice_of(node) if CHOICE_TYPES.include?(node.type)
149
182
  return case_condition(node) if node.type == :case_node
150
- return block_condition(node) if node.type == :call_node
183
+ return call_condition(node) if node.type == :call_node
151
184
 
152
185
  Condition.new(
153
186
  operator: node.operator, operands: operands_of(node),
@@ -157,12 +190,17 @@ module PathCoverage
157
190
 
158
191
  # node's operands: a chain of the same operator flattened
159
192
  # into one list, the other operator nested as a Condition,
160
- # anything else a leaf's byte range.
193
+ # as is a &. chain, its value its last leaf, anything else a
194
+ # leaf's byte range.
161
195
  def operands_of(node)
162
196
  [node.left, node.right].flat_map do |written|
163
197
  operand = grouped(written)
198
+ next [leaf(operand)] if ignored?(operand)
164
199
  next operands_of(operand) if operand.type == node.type
165
200
  next [condition_of(operand)] if operator?(operand)
201
+ if safe_chain?(operand)
202
+ next [chain_condition(operand, value: true)]
203
+ end
166
204
 
167
205
  [leaf(operand)]
168
206
  end
@@ -179,17 +217,18 @@ module PathCoverage
179
217
  OPERATOR_TYPES.include?(node.type)
180
218
  end
181
219
 
182
- # The byte range of a leaf operand, once any site inside
183
- # it is noted too, and the range noted among its site's
184
- # commands and fixed leaves where it is one.
220
+ # The byte range of a leaf operand, noted among its site's
221
+ # commands and fixed leaves where it is one; or, when sites
222
+ # that fold are found inside it, the leaf with them nested.
185
223
  def leaf(operand)
186
- operand.accept(self)
224
+ inner = collected { operand.accept(self) }
187
225
  loc = operand.location
188
226
  range = loc.start_offset...loc.end_offset
189
227
  @notes[:commands] << range if Manifest.command?(operand)
190
228
  outcome = Manifest.fixed_outcome(operand)
191
229
  @notes[:fixed][range] = outcome if outcome
192
- range
230
+ nonempty = nonempty_call?(operand, inner)
231
+ contained(inner, range, nonempty:)
193
232
  end
194
233
 
195
234
  # The one statement inside a parenthesised operand, or the
@@ -12,13 +12,31 @@ module PathCoverage
12
12
  # True for a call whose block's every call can be seen: to a
13
13
  # block method, on a receiver, with no arguments, which can
14
14
  # make it ignore its block, not by &., which skips it for
15
- # nil, and with a block of one expression.
15
+ # nil, and with a block of one expression that is a value,
16
+ # so can be wrapped: not break, next or return.
16
17
  def block_site?(node)
17
- BLOCK_PATHS.key?(node.name.to_s) && !node.receiver.nil? &&
18
- node.arguments.nil? && !node.safe_navigation? &&
19
- !block_expression(node.block).nil?
18
+ return false unless BLOCK_PATHS.key?(node.name.to_s)
19
+
20
+ plain_call?(node) && value?(block_expression(node.block))
21
+ end
22
+
23
+ # True for a call on a receiver, with no arguments, and not
24
+ # by &.
25
+ def plain_call?(node)
26
+ !node.receiver.nil? && node.arguments.nil? &&
27
+ !node.safe_navigation?
28
+ end
29
+
30
+ # True for a block's one expression that is a value, so can
31
+ # be wrapped: nil, for no one expression, is not, nor is a
32
+ # jump.
33
+ def value?(body)
34
+ !body.nil? && !JUMPS.include?(body.type)
20
35
  end
21
36
 
37
+ # The expressions that leave a block, so are no value.
38
+ JUMPS = %i[break_node next_node return_node].freeze
39
+
22
40
  # The one expression of block, nil for no block, a block
23
41
  # passed as an argument, or one of other than one expression.
24
42
  def block_expression(block)
@@ -35,23 +53,44 @@ module PathCoverage
35
53
  # among the commands when it is no bare argument.
36
54
  def block_condition(node)
37
55
  node.receiver.accept(self)
38
- body = block_expression(node.block)
39
- body.accept(self)
40
- @notes[:commands] << extent_of(body) if bare_less?(body)
56
+ body = block_body(node.block)
41
57
  Condition.new(
42
58
  operator: node.name.to_s,
43
- operands: [extent_of(node.receiver), extent_of(body)],
59
+ operands: [extent_of(node.receiver), body],
44
60
  extent: extent_of(node)
45
61
  )
46
62
  end
47
63
 
64
+ # The byte range of block's one expression, any site in it
65
+ # visited as one of its own, as the block runs once per item;
66
+ # noted among the commands when it is no bare argument.
67
+ def block_body(block)
68
+ body = block_expression(block)
69
+ scoped { body.accept(self) }
70
+ range = extent_of(body)
71
+ @notes[:commands] << range if bare_less?(body)
72
+ range
73
+ end
74
+
48
75
  # True for an expression that is no bare argument: a command
49
- # call, or an and or an or.
76
+ # call, an and or an or, or a modifier, eg x unless y.
50
77
  def bare_less?(node)
51
- return true if Manifest.command?(node)
78
+ return true if Manifest.command?(node) || modifier?(node)
52
79
 
53
80
  %i[and_node or_node].include?(node.type) &&
54
81
  %w[and or].include?(node.operator)
55
82
  end
83
+
84
+ # True for a modifier if, unless or rescue, eg x if y: one
85
+ # with no end keyword, a ternary being no modifier.
86
+ def modifier?(node)
87
+ case node.type
88
+ when :rescue_modifier_node then true
89
+ when :unless_node then node.end_keyword_loc.nil?
90
+ when :if_node
91
+ !node.if_keyword_loc.nil? && node.end_keyword_loc.nil?
92
+ else false
93
+ end
94
+ end
56
95
  end
57
96
  end
@@ -0,0 +1,76 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The part of the manifest's visitor that builds the sites of
5
+ # safe-navigation chains, eg user&.account&.owner. It relies
6
+ # on its includer's extent_of.
7
+ module ManifestChains
8
+ # The calls of node's chain, outermost first, eg owner then
9
+ # account for user&.account&.owner; none for a node that is
10
+ # no call.
11
+ def chain_calls(node)
12
+ calls = []
13
+ while node&.type == :call_node
14
+ calls << node
15
+ node = node.receiver
16
+ end
17
+ calls
18
+ end
19
+
20
+ # True for a call whose chain has a &. in it, eg
21
+ # user&.name.upcase.
22
+ def safe_chain?(node)
23
+ chain_calls(node).any?(&:safe_navigation?)
24
+ end
25
+
26
+ # A call that is a site as a Condition: a &. chain's, or a
27
+ # block method call's. It relies on its includer's
28
+ # block_condition.
29
+ def call_condition(node)
30
+ return block_condition(node) unless safe_chain?(node)
31
+
32
+ chain_condition(node, value: false)
33
+ end
34
+
35
+ # A &. chain as a Condition named &.: its leaves are the
36
+ # receivers of its &. calls, innermost first, each nil or
37
+ # not nil, and, with value, the whole chain's value last, as
38
+ # when it is an operand of an && or an if's predicate. Any
39
+ # site in a call's arguments or block, or in the chain's
40
+ # first receiver, is a site of its own. A chain that is no
41
+ # bare argument, eg a command call, is noted among the
42
+ # site's commands, to be parenthesised.
43
+ def chain_condition(node, value:)
44
+ calls = chain_calls(node)
45
+ visit_chain(calls)
46
+ safe = calls.select(&:safe_navigation?).reverse
47
+ receivers = safe.map { |call| receiver_of(call) }
48
+ extent = extent_of(node)
49
+ @notes[:commands] << extent if Manifest.command?(node)
50
+ operands = value ? [*receivers, extent] : receivers
51
+ Condition.new(operator: SAFE, operands:, extent:)
52
+ end
53
+
54
+ # The byte range of a &. call's receiver, noted among its
55
+ # site's fixed leaves as :present, never nil, when it is a
56
+ # call to a method treated as present.
57
+ def receiver_of(call)
58
+ receiver = call.receiver
59
+ range = extent_of(receiver)
60
+ named = receiver.type == :call_node &&
61
+ @present.include?(receiver.name.to_s)
62
+ @notes[:fixed][range] = :present if named
63
+ range
64
+ end
65
+
66
+ # Visits the sites inside a chain's calls' arguments and
67
+ # blocks, and inside its first receiver, eg (a || b) in
68
+ # (a || b)&.size, but never a call of the chain itself.
69
+ def visit_chain(calls)
70
+ calls.each do |call|
71
+ [call.arguments, call.block].compact.each { _1.accept(self) }
72
+ end
73
+ calls.last.receiver&.accept(self)
74
+ end
75
+ end
76
+ end
@@ -11,10 +11,12 @@ module PathCoverage
11
11
  # being an if_node with no if keyword.
12
12
  CHOICE_TYPES = %i[if_node unless_node].freeze
13
13
 
14
- # Where node's site starts: an if's or an unless's keyword,
15
- # which a modifier's statement comes before; anything
16
- # else's own start.
14
+ # Where node's site starts: a &. chain's start; a block
15
+ # method's call at its method; an if's or an unless's
16
+ # keyword, which a modifier's statement comes before;
17
+ # anything else's own start.
17
18
  def site_loc(node)
19
+ return node.location if safe_chain?(node)
18
20
  return node.message_loc if node.type == :call_node
19
21
  return node.location unless CHOICE_TYPES.include?(node.type)
20
22
  return node.location if ternary?(node)
@@ -69,11 +71,11 @@ module PathCoverage
69
71
  end
70
72
 
71
73
  # An if's arm, statements its code, which never folds into
72
- # the if's site; any site inside it is a site of its own.
73
- # An arm with no statements has no extent.
74
+ # the if's site; as a new scope, any site inside it is a site
75
+ # of its own. An arm with no statements has no extent.
74
76
  def statement_arm(kind, statements)
75
- statements&.accept(self)
76
- extent = statements && extent_of(statements)
77
+ scoped { statements&.accept(self) }
78
+ extent = statements ? extent_of(statements) : nil
77
79
  Arm.new(kind:, extent:, body: nil)
78
80
  end
79
81
 
@@ -85,7 +87,7 @@ module PathCoverage
85
87
  return [statement_arm(:else, subsequent.statements)]
86
88
  end
87
89
 
88
- subsequent.accept(self)
90
+ scoped { subsequent.accept(self) }
89
91
  extent = extent_of(subsequent)
90
92
  [Arm.new(kind: :else, extent:, body: nil)]
91
93
  end
@@ -100,19 +102,25 @@ module PathCoverage
100
102
  end
101
103
 
102
104
  # One arm of a ternary, statements its code: a condition
103
- # folds in as its body, being part of the one expression;
104
- # any site inside a plain arm is a site of its own.
105
+ # folds in as its body, being part of the one expression, as
106
+ # does a plain arm with sites inside it, its value its last
107
+ # leaf; a plain arm with none has no body.
105
108
  def arm_of(kind, statements)
106
109
  written = grouped(statements.body.first)
107
- body = condition_of(written) if foldable?(written)
108
- statements.accept(self) if body.nil?
109
- Arm.new(kind:, extent: extent_of(statements), body:)
110
+ extent = extent_of(statements)
111
+ body = if foldable?(written)
112
+ condition_of(written)
113
+ else
114
+ inner = collected { statements.accept(self) }
115
+ inner.empty? ? nil : contained(inner, extent)
116
+ end
117
+ Arm.new(kind:, extent:, body:)
110
118
  end
111
119
 
112
120
  # True for a condition that folds into a ternary's site:
113
- # an &&, ||, and, or, or a ternary.
121
+ # an &&, ||, and, or, or a ternary, none of them ignored.
114
122
  def foldable?(node)
115
- operator?(node) || ternary?(node)
123
+ (operator?(node) || ternary?(node)) && !ignored?(node)
116
124
  end
117
125
 
118
126
  # True for a ternary, an if with no if keyword.
@@ -121,10 +129,15 @@ module PathCoverage
121
129
  end
122
130
 
123
131
  # A ternary's or an if's predicate as its operand: an && or
124
- # || nested as a Condition, anything else a leaf.
132
+ # || nested as a Condition, as is a &. chain, its value its
133
+ # last leaf, anything else a leaf.
125
134
  def predicate_of(node)
126
- predicate = node.predicate
135
+ predicate = grouped(node.predicate)
136
+ return leaf(predicate) if ignored?(predicate)
127
137
  return condition_of(predicate) if operator?(predicate)
138
+ if safe_chain?(predicate)
139
+ return chain_condition(predicate, value: true)
140
+ end
128
141
 
129
142
  leaf(predicate)
130
143
  end
@@ -0,0 +1,91 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The part of the manifest's visitor that folds every site in one
5
+ # expression into one site. While a leaf is visited, each site
6
+ # found in it, the leaf itself included, is collected as a nested
7
+ # Condition of that leaf, rather than noted as a site of its own.
8
+ # A new scope, eg a block's body or an if's arm, stops the
9
+ # collecting, so a site in it is a site of its own. It relies on
10
+ # its includer's condition_of, chain_condition, safe_chain? and
11
+ # extent_of.
12
+ module ManifestFolding
13
+ # node's Condition as a site nested in a leaf: a &. chain's
14
+ # with its value as its last leaf, as its leaf's outcome
15
+ # follows from it.
16
+ def nested_condition_of(node)
17
+ return chain_condition(node, value: true) if safe_chain?(node)
18
+
19
+ condition_of(node)
20
+ end
21
+
22
+ # The Conditions of the sites found while yielding, each one
23
+ # collected rather than noted as a site of its own.
24
+ def collected
25
+ outer = @inner
26
+ @inner = []
27
+ yield
28
+ @inner
29
+ ensure
30
+ @inner = outer
31
+ end
32
+
33
+ # Yields with no collecting, so a site found is one of its own:
34
+ # for a new scope, eg a block's body or an if's arm.
35
+ def scoped
36
+ outer = @inner
37
+ @inner = nil
38
+ yield
39
+ ensure
40
+ @inner = outer
41
+ end
42
+
43
+ # range as a leaf, or, with inner sites found in it, a Condition
44
+ # named contains: those sites, then range, the leaf's outcome,
45
+ # fixed as :nonempty when nonempty. A leaf that is wholly one
46
+ # block method's call whose value follows from its path is
47
+ # that call alone, needing no outcome of its own.
48
+ def contained(inner, range, nonempty: false)
49
+ return range if inner.empty?
50
+
51
+ only = inner.first
52
+ if inner.size == 1 && only.extent == range &&
53
+ VALUED.include?(only.operator)
54
+ return only
55
+ end
56
+
57
+ fixed = nonempty ? { range => :nonempty } : {}
58
+ Condition.new(operator: CONTAINS, operands: [*inner, range],
59
+ extent: range, fixed:)
60
+ end
61
+
62
+ # True for a call, with no arguments or block, to a method
63
+ # treated as nonempty, eg any?, whose receiver, parenthesised
64
+ # or not, is one of inner, the sites found in it: its outcome
65
+ # then follows from that site's path. It relies on its
66
+ # includer's grouped.
67
+ def nonempty_call?(node, inner)
68
+ return false unless node.type == :call_node
69
+ return false if node.arguments || node.block || !node.receiver
70
+
71
+ receiver = extent_of(grouped(node.receiver))
72
+ @nonempty.include?(node.name.to_s) &&
73
+ inner.any? { |site| site.extent == receiver }
74
+ end
75
+
76
+ # A block's body is a new scope: it runs once per item.
77
+ def visit_block_node(node)
78
+ scoped { super }
79
+ end
80
+
81
+ # A lambda's body is a new scope: it runs when called.
82
+ def visit_lambda_node(node)
83
+ scoped { super }
84
+ end
85
+
86
+ # A def's body is a new scope: it runs when called.
87
+ def visit_def_node(node)
88
+ scoped { super }
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,35 @@
1
+ module PathCoverage
2
+ # The part of the manifest's visitor that leaves out the sites
3
+ # of ignored operators, eg ignore '&&='. An ignored node is no
4
+ # site, though a site inside it may be; nested in a site, it
5
+ # is a leaf. It relies on its includer's @ignored, ternary?
6
+ # and safe_chain?.
7
+ module ManifestIgnoring
8
+ # The operator of each node type that always names one kind
9
+ # of site.
10
+ NAMED = { unless_node: 'unless', case_node: 'case' }.freeze
11
+
12
+ # True for a node whose operator is ignored.
13
+ def ignored?(node)
14
+ @ignored.include?(operator_of(node))
15
+ end
16
+
17
+ # The operator naming node's kind of site, eg ?: for a
18
+ # ternary or any? for a block method's call; nil for a
19
+ # node with none.
20
+ def operator_of(node)
21
+ type = node.type
22
+ return NAMED[type] if NAMED.key?(type)
23
+ return ternary?(node) ? '?:' : 'if' if type == :if_node
24
+ return call_operator(node) if type == :call_node
25
+
26
+ node.respond_to?(:operator) ? node.operator : nil
27
+ end
28
+
29
+ # The operator of a call: &. for a &. chain, else its
30
+ # method's name, eg any?.
31
+ def call_operator(node)
32
+ safe_chain?(node) ? '&.' : node.name.to_s
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,67 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The part of the manifest's visitor that builds the sites of
5
+ # ||= and &&= writes, eg @clock ||= Clock.new. It relies on
6
+ # its includer's add_site, bare_less? and extent_of.
7
+ module ManifestWrites
8
+ # Every node type of a ||= or &&= write, by its target: a
9
+ # variable of each kind, a constant, a constant path, an
10
+ # attribute, eg self.size, and an index, eg store[key].
11
+ WRITE_TYPES = %w[
12
+ local_variable instance_variable class_variable
13
+ global_variable constant constant_path call index
14
+ ].flat_map do |target|
15
+ %W[#{target}_or_write_node #{target}_and_write_node]
16
+ end.map(&:to_sym).freeze
17
+
18
+ # Notes each kind of write as a site, eg
19
+ # visit_index_or_write_node for store[key] ||= 0.
20
+ WRITE_TYPES.each do |type|
21
+ define_method(:"visit_#{type}") do |node|
22
+ write?(node) ? add_site(node) : visit_child_nodes(node)
23
+ end
24
+ end
25
+
26
+ # True for a ||= or &&= write that is a site: any but one
27
+ # through &., eg box&.size ||= 3, which a nil box skips whole,
28
+ # its target never looked at, so neither of its paths taken.
29
+ def write?(node)
30
+ WRITE_TYPES.include?(node.type) && !safe_write?(node)
31
+ end
32
+
33
+ # True for a write through &., eg box&.size ||= 3.
34
+ def safe_write?(node)
35
+ node.respond_to?(:safe_navigation?) && node.safe_navigation?
36
+ end
37
+
38
+ # A write as a Condition, named by its operator: its one leaf
39
+ # its target, then its right side as an arm, taken when the
40
+ # target is false for ||=, true for &&=. The target is a
41
+ # leaf the rewriter never reads again, as reading it could
42
+ # call a method twice; its outcome follows from whether the
43
+ # arm ran. Any site inside the write, eg in an index or on
44
+ # the right side, is a site of its own. A right side that is
45
+ # no bare argument, eg a command call, is noted among the
46
+ # site's commands, to be parenthesised.
47
+ def write_condition(node)
48
+ visit_child_nodes(node)
49
+ value = extent_of(node.value)
50
+ @notes[:commands] << value if bare_less?(node.value)
51
+ arm = Arm.new(kind: :then, extent: value, body: nil)
52
+ Condition.new(
53
+ operator: node.operator, operands: [target_of(node), arm],
54
+ extent: extent_of(node)
55
+ )
56
+ end
57
+
58
+ # The byte range of a write's target: from its start to its
59
+ # operator, less any space between, eg `store[key]`.
60
+ def target_of(node)
61
+ start = node.location.start_offset
62
+ before = node.operator_loc.start_offset - start
63
+ text = node.location.slice.byteslice(0, before)
64
+ start...(start + text.rstrip.bytesize)
65
+ end
66
+ end
67
+ end
@@ -12,11 +12,15 @@ module PathCoverage
12
12
  end
13
13
 
14
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.
15
+ # path's count the sum of every run's. Runs that list a
16
+ # site's paths differently raise, as adding their counts by
17
+ # position would credit one path's count to another.
17
18
  def self.paths(runs)
18
19
  runs.reduce do |total, run|
19
- total.merge(run) do |_site, mine, theirs|
20
+ total.merge(run) do |site, mine, theirs|
21
+ same = mine.map { _1[:path] } == theirs.map { _1[:path] }
22
+ raise ArgumentError, "runs differ at #{site}" unless same
23
+
20
24
  mine.zip(theirs).map do |a, b|
21
25
  { path: a[:path], count: a[:count] + b[:count] }
22
26
  end
@@ -24,6 +28,18 @@ module PathCoverage
24
28
  end
25
29
  end
26
30
 
31
+ # One method paths hash, { site => { path => count } }, as
32
+ # the recorder counts them, each path's count the sum of
33
+ # every run's, eg [true, false] taken once in each of two
34
+ # runs is taken twice.
35
+ def self.method_paths(runs)
36
+ runs.reduce do |total, run|
37
+ total.merge(run) do |_site, mine, theirs|
38
+ mine.merge(theirs) { |_path, a, b| a + b }
39
+ end
40
+ end
41
+ end
42
+
27
43
  # One operand's counts from two runs, added; a fixed leaf's
28
44
  # only comes from the source, so either run's will do.
29
45
  def self.sum(mine, theirs)
@@ -6,18 +6,18 @@ module PathCoverage
6
6
  # for runs whose own files are thrown away, eg a failing
7
7
  # docker build step.
8
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 = {})
9
+ # POSTs operands and paths, as the JSON report with its
10
+ # fingerprint, to url, never through an http_proxy,
11
+ # which could not reach a collector local to the run. A
12
+ # collector that cannot be reached, or does not answer
13
+ # within a second, is passed over in silence: the run's own
14
+ # output and exit status must stay as they are.
15
+ def self.deliver(operands, url, paths = {}, fingerprint: {})
16
16
  uri = URI(url)
17
17
  http = Net::HTTP.new(uri.host, uri.port, nil)
18
18
  http.open_timeout = http.read_timeout = 1
19
19
  headers = { 'Content-Type' => 'application/json' }
20
- body = JsonWriter.json(operands, paths)
20
+ body = JsonWriter.json(operands, paths, fingerprint:)
21
21
  http.post(uri.path, body, headers)
22
22
  rescue StandardError
23
23
  nil