condition_coverage 0.0.2 → 0.0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b23a1bb5174201acad8b95876d179f5d7e818bea50eeca8799e189e41ae4014
4
- data.tar.gz: 0527c568699e2fb994438308eeeafaf7b1183a3cbbb632d85efe60e7c482acbd
3
+ metadata.gz: eb36ad570a0050db876bba37548a59c929604eb35b88ed4b8ac4a155dde4db6e
4
+ data.tar.gz: 77f2dd1b7da7815a216fac0bee73f251f1c8d2c9490ad416d26125ac1adcfbe8
5
5
  SHA512:
6
- metadata.gz: 0d7593c42bfeb6e8a319e92740786829d7be675e5c2c66a9b96ced249cc811c5a8dc3ae0cae05d40afec05d650fc0d11e75db4171924a25435c88750967d1a41
7
- data.tar.gz: 7831a347fae127eb253f7d806eb2d7ba3594718266d952da88e0030fd66027ab09d4936598efb04a23eef53c0ed070c082216aa4491964ae4ea96c3991652839
6
+ metadata.gz: 5571a2753478ab9ed18b6a1e04b47df6f4f96b03f10637ac9bf8e0ad4149b8a02d6b6abf754b45e533c0eb37a417fffe9a7a937c3c86a6bf15b51fe13d2bed03
7
+ data.tar.gz: 5d97b90e7c1bff46242a833f234ef1a5ef62e8732bd2a2fe3f9b1f65fe441b0bcad5da1eeaf2ad0912ae7406ce3baf1c77067571b0751e6a9c6f602e831eca62
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Condition coverage for Ruby, to run alongside SimpleCov's line and branch
4
4
  coverage.
5
5
 
6
- Status: 0.0.2 covers `&&`, `||`, `and` and `or`: each leaf operand of a
6
+ Status: 0.0.4 covers `&&`, `||`, `and` and `or`: each leaf operand of a
7
7
  condition, however its operators are chained, nested or grouped. Decisions
8
8
  hidden inside calls come next (see Roadmap).
9
9
 
@@ -75,6 +75,15 @@ A chain of one operator is flat; a change of operator, by precedence or by
75
75
  parentheses, nests. Parentheses are not part of the tree, since they decide
76
76
  nothing.
77
77
 
78
+ An operand whose form allows one outcome only needs only to be evaluated:
79
+ its other outcome is impossible, not a gap. `Foo.new`, literals (`'s'`,
80
+ `1`, `:k`, `[]`, `{}`, `true`) and lambdas can only be truthy; `nil` and
81
+ `false` only falsy:
82
+
83
+ ```ruby
84
+ @clock = options[:clock] || MonotonicClock.new # operand 1: never evaluated, or no gap
85
+ ```
86
+
78
87
  - `:html` writes `index.html` to `coverage_dir`: the covered files with
79
88
  their sites, outcomes seen (with a percent) and outcomes missing, each
80
89
  column sortable. Each file name opens `files/<path>.html`, the file's
@@ -96,11 +105,15 @@ the file:
96
105
  ```ruby
97
106
  ok?(id) && new?(id)
98
107
  # compiles as
99
- ConditionCoverage.record("k.rb:2:2", 0, (ok?(id))) && ConditionCoverage.record("k.rb:2:2", 1, (new?(id)))
108
+ ConditionCoverage.record("k.rb:2:2", 0, ok?(id)) && ConditionCoverage.record("k.rb:2:2", 1, new?(id))
100
109
  ```
101
110
 
102
- Each operand gets parentheses of its own, since one such as `ok? id` (after
103
- `and`) is not valid as a bare argument.
111
+ A command call such as `ok? id` (after `and`) is not valid as a bare
112
+ argument, so that operand alone gets parentheses of its own. No other operand
113
+ does: parentheses make a statement, and a statement on a continuation line
114
+ gives that line a line-coverage count of its own, which would change
115
+ SimpleCov's line totals. With that one exception, an instrumented file has
116
+ the same relevant lines as an uninstrumented one.
104
117
 
105
118
  `record` counts whether the operand was truthy or falsy and returns it
106
119
  unchanged, so short-circuiting and the value of the expression are
@@ -1,8 +1,16 @@
1
1
  module ConditionCoverage
2
2
  # One site's condition as the manifest found it: its operator
3
3
  # and its operands, each the byte range of a leaf or a nested
4
- # Condition.
5
- Condition = Data.define(:operator, :operands) do
4
+ # Condition. A site's commands are the byte ranges of its
5
+ # leaves that are command calls, eg `ok? id`; its fixed are
6
+ # { byte range => :truthy } for leaves that can have that one
7
+ # outcome only, eg `Clock.new`.
8
+ Condition = Data.define(:operator, :operands, :commands, :fixed) do
9
+ # A condition with no command-call or fixed leaves by default.
10
+ def initialize(operator:, operands:, commands: [], fixed: {})
11
+ super
12
+ end
13
+
6
14
  # The byte range of every leaf operand, left to right; a
7
15
  # leaf's index here is the index it is recorded under.
8
16
  def leaves
@@ -25,9 +25,11 @@ module ConditionCoverage
25
25
  end
26
26
  end
27
27
 
28
- # The one outcome this operand is missing, if any.
28
+ # The one outcome this operand is missing, if any; a fixed
29
+ # leaf, which can have one outcome only, needs only to run.
29
30
  def self.operand_gap(counts)
30
31
  return ['never evaluated'] if never_ran?(counts)
32
+ return [] if counts[:only]
31
33
  return ['never true'] if counts[:truthy].zero?
32
34
  return ['never false'] if counts[:falsy].zero?
33
35
 
@@ -1,3 +1,4 @@
1
+ require 'fileutils'
1
2
  require_relative 'configuration'
2
3
  require_relative 'gaps'
3
4
  require_relative 'html_report'
@@ -73,10 +74,12 @@ module ConditionCoverage
73
74
  end
74
75
 
75
76
  # Writes the JSON report, then runs the chosen reporters, for
76
- # every covered file whether or not the run loaded it.
77
+ # every covered file whether or not the run loaded it, into
78
+ # coverage_dir, created first if need be.
77
79
  def self.report(config)
78
80
  files = parsed_files(config)
79
81
  result = Result.new(merged_sites(files), @recorder.counts)
82
+ FileUtils.mkdir_p(config.output_dir)
80
83
  JsonWriter.write(result, config.json_path)
81
84
  run_reporters(result, files, config)
82
85
  end
@@ -19,6 +19,7 @@ module ConditionCoverage
19
19
  def initialize(path)
20
20
  @path = path
21
21
  @sites = {}
22
+ @notes = nil
22
23
  super()
23
24
  end
24
25
 
@@ -43,7 +44,11 @@ module ConditionCoverage
43
44
  def add_site(node)
44
45
  site = Site.of(@path, node)
45
46
  @sites[site] = nil
46
- @sites[site] = condition_of(node)
47
+ outer = @notes
48
+ @notes = { commands: [], fixed: {} }
49
+ condition = condition_of(node)
50
+ @sites[site] = condition.with(**@notes)
51
+ @notes = outer
47
52
  end
48
53
 
49
54
  # node as a Condition, operands and all, named by its
@@ -72,11 +77,43 @@ module ConditionCoverage
72
77
  end
73
78
 
74
79
  # The byte range of a leaf operand, once any site inside
75
- # it is noted too.
80
+ # it is noted too, and the range noted among its site's
81
+ # commands and fixed leaves where it is one.
76
82
  def leaf(operand)
77
83
  operand.accept(self)
78
84
  loc = operand.location
79
- loc.start_offset...loc.end_offset
85
+ range = loc.start_offset...loc.end_offset
86
+ @notes[:commands] << range if command?(operand)
87
+ outcome = fixed_outcome(operand)
88
+ @notes[:fixed][range] = outcome if outcome
89
+ range
90
+ end
91
+
92
+ TRUTHY_TYPES = %i[
93
+ string_node integer_node symbol_node array_node hash_node
94
+ true_node lambda_node
95
+ ].freeze
96
+ FALSY_TYPES = %i[nil_node false_node].freeze
97
+
98
+ # The one outcome a leaf can have, eg :truthy for `Clock.new`
99
+ # or a literal, :falsy for `nil`; nil for a leaf that can
100
+ # have either.
101
+ def fixed_outcome(node)
102
+ return :falsy if FALSY_TYPES.include?(node.type)
103
+
104
+ :truthy if TRUTHY_TYPES.include?(node.type) || new_call?(node)
105
+ end
106
+
107
+ # True for a call to new, eg `Clock.new`.
108
+ def new_call?(node)
109
+ node.type == :call_node && node.name == :new
110
+ end
111
+
112
+ # True for a call to a named method with arguments and no
113
+ # parentheses, eg `id.include? 'N'`, but not `id.size == 6`.
114
+ def command?(node)
115
+ node.type == :call_node && node.arguments &&
116
+ node.opening_loc.nil? && node.name.match?(/\A[a-z_]/i)
80
117
  end
81
118
 
82
119
  # The one statement inside a parenthesised operand, or the
@@ -13,16 +13,26 @@ module ConditionCoverage
13
13
  end
14
14
 
15
15
  # { site => [counts of operand 0, counts of operand 1, ...] },
16
- # with zero counts for an operand that never ran.
16
+ # with zero counts for an operand that never ran, and
17
+ # only: naming the one outcome a fixed leaf can have.
17
18
  def operands
18
19
  @sites.to_h do |site, condition|
19
- size = condition.leaves.size
20
- [site, Array.new(size) { |i| counts_of(site, i) }]
20
+ leaves = condition.leaves.each_with_index.map do |range, i|
21
+ leaf(site, condition, range, i)
22
+ end
23
+ [site, leaves]
21
24
  end
22
25
  end
23
26
 
24
27
  private
25
28
 
29
+ # The counts of one leaf, with only: when it is fixed.
30
+ def leaf(site, condition, range, index)
31
+ counts = counts_of(site, index)
32
+ only = condition.fixed[range]
33
+ only ? counts.merge(only:) : counts
34
+ end
35
+
26
36
  # The recorded counts of one operand, or zeros.
27
37
  def counts_of(site, index)
28
38
  @counts.fetch([site, index], NEVER_RAN)
@@ -9,18 +9,21 @@ module ConditionCoverage
9
9
  def self.rewrite(source, sites)
10
10
  inserts = sites.flat_map do |site, condition|
11
11
  condition.leaves.each_with_index.flat_map do |range, index|
12
- wrap(site, index, range)
12
+ command = condition.commands.include?(range)
13
+ wrap(site, index, range, command)
13
14
  end
14
15
  end
15
16
  apply(source.dup, inserts)
16
17
  end
17
18
 
18
- # The two inserts that wrap one operand in a record call.
19
- # The operand gets parentheses of its own, since one such
20
- # as `ok? id` is not valid as a bare argument.
21
- def self.wrap(site, index, range)
22
- call = "ConditionCoverage.record(#{site.inspect}, #{index}, ("
23
- [[range.begin, call], [range.end, '))']]
19
+ # The two inserts that wrap one operand in a record call. A
20
+ # command call is parenthesised, as it is no bare argument;
21
+ # nothing else is, since parentheses make a statement, which
22
+ # gives its line a line-coverage count of its own.
23
+ def self.wrap(site, index, range, command)
24
+ open, close = command ? ['(', '))'] : ['', ')']
25
+ call = "ConditionCoverage.record(#{site.inspect}, #{index}, "
26
+ [[range.begin, call + open], [range.end, close]]
24
27
  end
25
28
 
26
29
  # Splices each [offset, text] insert into source,
@@ -1,3 +1,5 @@
1
+ require_relative 'gaps'
2
+
1
3
  module ConditionCoverage
2
4
  # The lines of one file's page, each with the status of the
3
5
  # conditions on it.
@@ -58,14 +60,10 @@ module ConditionCoverage
58
60
  [one, other].include?(:gaps) ? :gaps : :covered
59
61
  end
60
62
 
61
- # :covered when every operand was seen both ways, else :gaps.
63
+ # :covered when no operand has a gap, else :gaps.
62
64
  def self.status_of(counts)
63
- counts.all? { |operand| seen_both?(operand) } ? :covered : :gaps
64
- end
65
-
66
- # True when the operand was seen both true and false.
67
- def self.seen_both?(counts)
68
- counts[:truthy].positive? && counts[:falsy].positive?
65
+ gaps = counts.flat_map { |operand| Gaps.operand_gap(operand) }
66
+ gaps.empty? ? :covered : :gaps
69
67
  end
70
68
 
71
69
  # The line a site starts on, from its path:line:column name.
@@ -10,7 +10,7 @@ module ConditionCoverage
10
10
  # The summary row of one file's sites.
11
11
  def self.row(path, sites)
12
12
  operands = sites.flat_map { |_, counts| counts }
13
- outcomes = operands.size * 2
13
+ outcomes = operands.sum { |counts| counts[:only] ? 1 : 2 }
14
14
  seen = operands.sum { |counts| seen_in(counts) }
15
15
  {
16
16
  path:, sites: sites.size, seen:, outcomes:,
@@ -1,3 +1,3 @@
1
1
  module ConditionCoverage
2
- VERSION = '0.0.2'.freeze
2
+ VERSION = '0.0.4'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: condition_coverage
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Jagger
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-10-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: prism
@@ -26,7 +25,6 @@ dependencies:
26
25
  version: '1.0'
27
26
  description: Records whether each operand of every &&, ||, and and or condition was
28
27
  seen both true and false, and reports the gaps in the terminal and as HTML.
29
- email:
30
28
  executables: []
31
29
  extensions: []
32
30
  extra_rdoc_files: []
@@ -62,7 +60,6 @@ licenses:
62
60
  - BSD-2-Clause
63
61
  metadata:
64
62
  rubygems_mfa_required: 'true'
65
- post_install_message:
66
63
  rdoc_options: []
67
64
  require_paths:
68
65
  - lib
@@ -77,8 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
74
  - !ruby/object:Gem::Version
78
75
  version: '0'
79
76
  requirements: []
80
- rubygems_version: 3.5.22
81
- signing_key:
77
+ rubygems_version: 4.0.23
82
78
  specification_version: 4
83
79
  summary: Condition coverage for Ruby, alongside SimpleCov branch coverage
84
80
  test_files: []