path_coverage 0.0.1 → 0.1.0

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