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,134 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The tree shown under a site's line on its file's page: a
5
+ # grid whose columns are the site's paths, never taken first.
6
+ module SiteTree
7
+ # { operator:, counts:, cells:, operands: } for one site,
8
+ # from its paths, [{path:, count:}, ...]. counts are the
9
+ # columns' counts. Every node has cells, one per column: a
10
+ # leaf { text:, cells: } its outcome, nil where unreached;
11
+ # a condition { operator:, cells:, operands: } its value;
12
+ # an arm { arm:, text:, cells:, body: } whether the column
13
+ # took it, body being its condition's tree, nil for a plain
14
+ # arm. Texts are cut from source at the manifest's ranges.
15
+ def self.of(source, condition, paths)
16
+ columns = paths.sort_by.with_index do |taken, i|
17
+ [taken[:count].zero? ? 0 : 1, i]
18
+ end
19
+ outcomes = columns.map { |taken| taken[:path] }
20
+ tree = node(source, condition, outcomes, (0..).each)
21
+ tree.merge(counts: columns.map { |taken| taken[:count] })
22
+ end
23
+
24
+ # The tree of condition, each leaf taking the next index of
25
+ # leaves, so leaves pair with path entries left to right.
26
+ def self.node(source, condition, outcomes, leaves)
27
+ if condition.operator == 'case'
28
+ return case_arms(source, condition.operands, outcomes, leaves)
29
+ end
30
+
31
+ operands = operands_of(source, condition, outcomes, leaves)
32
+ arms(source, condition, operands, outcomes, leaves)
33
+ end
34
+
35
+ # condition's operands' nodes, leaves and nested conditions
36
+ # built in order, its arms left for arms to build after.
37
+ def self.operands_of(source, condition, outcomes, leaves)
38
+ condition.operands.map do |operand|
39
+ case operand
40
+ when Condition then node(source, operand, outcomes, leaves)
41
+ when Arm then operand
42
+ else leaf(source, operand, outcomes, leaves.next)
43
+ end
44
+ end
45
+ end
46
+
47
+ # condition's node from its operands' nodes, its ternary
48
+ # arms built in order after its predicate, as each arm's
49
+ # cells follow from the predicate's.
50
+ def self.arms(source, condition, operands, outcomes, leaves)
51
+ predicate = operands.first
52
+ operands = operands.map do |operand|
53
+ next operand unless operand.is_a?(Arm)
54
+
55
+ decided = [predicate[:cells], THEN_ON[condition.operator]]
56
+ arm(source, operand, decided, outcomes, leaves)
57
+ end
58
+ cells = value(condition.operator, operands)
59
+ { operator: condition.operator, cells:, operands: }
60
+ end
61
+
62
+ # A case's node from its arms, built in order so its whens'
63
+ # patterns pair with path entries left to right: a when is
64
+ # taken on each path one of its patterns matched, and the
65
+ # else on each none did. A case's value is that of no
66
+ # choice, so is nil.
67
+ def self.case_arms(source, arms, outcomes, leaves)
68
+ matched = []
69
+ operands = arms.map do |arm|
70
+ body = arm.body && node(source, arm.body, outcomes, leaves)
71
+ matched << body[:cells].map { |cell| cell == true } if body
72
+ cells = body ? matched.last : unmatched(matched, outcomes)
73
+ { arm: arm.kind, text: text_of(source, arm), cells:, body: }
74
+ end
75
+ { operator: 'case', cells: Array.new(outcomes.size), operands: }
76
+ end
77
+
78
+ # An arm's text, nil for one with no statements.
79
+ def self.text_of(source, arm)
80
+ arm.extent && source.byteslice(arm.extent)
81
+ end
82
+
83
+ # Whether each column matched none of its whens' patterns.
84
+ def self.unmatched(matched, outcomes)
85
+ outcomes.each_index.map do |column|
86
+ matched.none? { |taken| taken[column] }
87
+ end
88
+ end
89
+
90
+ # One arm of a ternary, an if or an unless: its text, nil
91
+ # for an arm with no statements, whether each column took
92
+ # it, from the predicate's cells and the outcome on which
93
+ # its choice takes its then arm, and the tree of its
94
+ # condition.
95
+ def self.arm(source, arm, (decided, then_on), outcomes, leaves)
96
+ picked = arm.kind == :then ? then_on : !then_on
97
+ body = arm.body && node(source, arm.body, outcomes, leaves)
98
+ {
99
+ arm: arm.kind, text: text_of(source, arm),
100
+ cells: decided.map { |outcome| outcome == picked }, body:
101
+ }
102
+ end
103
+
104
+ # A condition's value in each column: a ternary's or an
105
+ # if's is its taken arm's body's, nil for a plain arm; a
106
+ # chain's is that of the last operand it reached.
107
+ def self.value(operator, operands)
108
+ if CHOICES.include?(operator)
109
+ _, *arms = operands
110
+ arms.map { |arm| arm[:body]&.fetch(:cells) }
111
+ .then { |bodies| ternary_value(arms, bodies) }
112
+ else
113
+ operands.map { |operand| operand[:cells] }
114
+ .transpose.map { |column| column.compact.last }
115
+ end
116
+ end
117
+
118
+ # Each column's value from the arm it took.
119
+ def self.ternary_value(arms, bodies)
120
+ arms.first[:cells].each_index.map do |column|
121
+ taken = arms.index { |arm| arm[:cells][column] }
122
+ taken && bodies[taken]&.fetch(column)
123
+ end
124
+ end
125
+
126
+ # One leaf's text and its outcome in each column.
127
+ def self.leaf(source, range, outcomes, index)
128
+ {
129
+ text: source.byteslice(range),
130
+ cells: outcomes.map { |path| path[index] }
131
+ }
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,79 @@
1
+ require_relative 'gaps'
2
+
3
+ module PathCoverage
4
+ # The lines of one file's page, each with the status of the
5
+ # conditions on it.
6
+ module SourceLines
7
+ # [{number:, text:, status:, span:}] for each line of
8
+ # source. A line in a site's span shares one button with
9
+ # the span's other lines; status and span are nil on a line
10
+ # with no condition.
11
+ def self.of(source, sites, operands)
12
+ marks = marks(source, sites, operands)
13
+ source.lines.each_with_index.map do |text, index|
14
+ number = index + 1
15
+ mark = marks.fetch(number, {})
16
+ {
17
+ number:, text: text.chomp,
18
+ status: mark[:status], span: mark[:span]
19
+ }
20
+ end
21
+ end
22
+
23
+ # { line number => {status:, span:} } for each line a site
24
+ # covers, every line of a merged span sharing that span.
25
+ def self.marks(source, sites, operands)
26
+ blocks = merged(spans_of(source, sites, operands))
27
+ blocks.each_with_object({}) do |(span, status), marks|
28
+ span.each { |number| marks[number] = { status:, span: } }
29
+ end
30
+ end
31
+
32
+ # [span, status] for each site: from the line it starts on
33
+ # to the line its last operand ends on.
34
+ def self.spans_of(source, sites, operands)
35
+ sites.map do |site, condition|
36
+ last = condition.leaves.last
37
+ span = line_of(site)..end_line(source, last)
38
+ [span, status_of(operands.fetch(site))]
39
+ end
40
+ end
41
+
42
+ # The spans merged wherever they overlap, in line order. A
43
+ # merged span has :gaps if any of its sites has, so a
44
+ # covered site never hides another's gaps.
45
+ def self.merged(spans)
46
+ sorted = spans.sort_by { |span, _| span.begin }
47
+ sorted.each_with_object([]) do |(span, status), blocks|
48
+ last_span, last_status = blocks.last
49
+ if last_span && last_span.end >= span.begin
50
+ joined = last_span.begin..[last_span.end, span.end].max
51
+ blocks[-1] = [joined, worse(last_status, status)]
52
+ else
53
+ blocks << [span, status]
54
+ end
55
+ end
56
+ end
57
+
58
+ # :gaps if either status is :gaps, else :covered.
59
+ def self.worse(one, other)
60
+ [one, other].include?(:gaps) ? :gaps : :covered
61
+ end
62
+
63
+ # :covered when no operand has a gap, else :gaps.
64
+ def self.status_of(counts)
65
+ gaps = counts.flat_map { |operand| Gaps.operand_gap(operand) }
66
+ gaps.empty? ? :covered : :gaps
67
+ end
68
+
69
+ # The line a site starts on, from its path:line:column name.
70
+ def self.line_of(site)
71
+ site[/:(\d+):\d+\z/, 1].to_i
72
+ end
73
+
74
+ # The line of source on which byte range ends.
75
+ def self.end_line(source, range)
76
+ source.byteslice(0, range.end).count("\n") + 1
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,52 @@
1
+ require_relative 'totals'
2
+
3
+ module PathCoverage
4
+ # The per-file numbers the HTML index page shows, and their
5
+ # total over every file.
6
+ module Summary
7
+ # One row per file that has sites in site_paths, as
8
+ # Result#paths gives them, in path order: its sites, and
9
+ # its decisions total. Its methods total is nil unless
10
+ # methods, as PathManifest.methods gives them, and paths, as
11
+ # the recorder counts them, are given, as they are when
12
+ # methods are measured.
13
+ def self.files(site_paths, methods = {}, paths = nil)
14
+ by_path = site_paths.group_by { |site, _| path_of(site) }
15
+ by_path.sort.map do |path, sites|
16
+ {
17
+ path:, sites: sites.size,
18
+ decisions: Totals.decisions(sites.to_h),
19
+ methods: paths && methods_of(path, methods, paths)
20
+ }
21
+ end
22
+ end
23
+
24
+ # The methods total of the file at path; [0, 0] for a file
25
+ # with no method of two or more decisions.
26
+ def self.methods_of(path, methods, paths)
27
+ own = methods.select { |site, _| path_of(site) == path }
28
+ Totals.methods(own, paths)
29
+ end
30
+
31
+ # The sum of each column over rows, a methods total being
32
+ # nil when the rows' are.
33
+ def self.total(rows)
34
+ methods = rows.map { |row| row[:methods] }
35
+ {
36
+ sites: rows.sum { |row| row[:sites] },
37
+ decisions: add(rows.map { |row| row[:decisions] }),
38
+ methods: methods.first && add(methods)
39
+ }
40
+ end
41
+
42
+ # [taken, possible] pairs added together.
43
+ def self.add(pairs)
44
+ pairs.transpose.map(&:sum)
45
+ end
46
+
47
+ # The file a site is in: its name minus :line:column.
48
+ def self.path_of(site)
49
+ site.sub(/:\d+:\d+\z/, '')
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,29 @@
1
+ require_relative 'path_gaps'
2
+
3
+ module PathCoverage
4
+ # The two coverage totals, each [taken, possible] paths over
5
+ # every covered file. They count the very paths the gap
6
+ # lines list as never taken.
7
+ module Totals
8
+ # Paths within each decision: site_paths, as Result#paths
9
+ # gives them, lists every possible path of each site with
10
+ # its count; one with a count above zero was taken.
11
+ def self.decisions(site_paths)
12
+ every = site_paths.values.flatten
13
+ [every.count { |each| each[:count].positive? }, every.size]
14
+ end
15
+
16
+ # Paths across each method's decisions: methods, as
17
+ # PathManifest.methods gives them, each with every path
18
+ # through its decisions; paths, as the recorder counts
19
+ # them, holds the ones taken.
20
+ def self.methods(methods, paths)
21
+ methods.reduce([0, 0]) do |(taken, possible), (site, method)|
22
+ every = PathGaps.possible(method.decisions)
23
+ seen = paths.fetch(site, {})
24
+ [taken + every.count { |path| seen.key?(path) },
25
+ possible + every.size]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ module PathCoverage
2
+ VERSION = '0.1.0'.freeze
3
+ end
data/lib/path_coverage.rb CHANGED
@@ -1,5 +1,2 @@
1
- # A placeholder holding the gem name path_coverage for the
2
- # successor of condition_coverage; it does nothing yet.
3
- module PathCoverage
4
- VERSION = '0.0.1'.freeze
5
- end
1
+ require_relative 'path_coverage/version'
2
+ require_relative 'path_coverage/kernel'
metadata CHANGED
@@ -1,29 +1,88 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: path_coverage
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Jagger
8
- autorequire:
9
- bindir: bin
8
+ autorequire:
9
+ bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-02 00:00:00.000000000 Z
12
- dependencies: []
13
- description: Reserves the name for the successor of condition_coverage, which records
14
- the paths taken through each condition and method.
15
- email:
16
- executables: []
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: prism
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ description: Records which paths each condition and each method took, and reports
28
+ the paths never taken in the terminal and as HTML.
29
+ email:
30
+ executables:
31
+ - path_coverage_collector
17
32
  extensions: []
18
33
  extra_rdoc_files: []
19
34
  files:
35
+ - LICENSE.md
36
+ - README.md
37
+ - exe/path_coverage_collector
20
38
  - lib/path_coverage.rb
21
- homepage: https://github.com/cyber-dojo/condition_coverage
39
+ - lib/path_coverage/auto.rb
40
+ - lib/path_coverage/calls.rb
41
+ - lib/path_coverage/collector.rb
42
+ - lib/path_coverage/condition.rb
43
+ - lib/path_coverage/configuration.rb
44
+ - lib/path_coverage/gaps.rb
45
+ - lib/path_coverage/html/file.html
46
+ - lib/path_coverage/html/index.html
47
+ - lib/path_coverage/html/side.js
48
+ - lib/path_coverage/html/sort.js
49
+ - lib/path_coverage/html/style.css
50
+ - lib/path_coverage/html/toggle.js
51
+ - lib/path_coverage/html_file.rb
52
+ - lib/path_coverage/html_index.rb
53
+ - lib/path_coverage/html_nav.rb
54
+ - lib/path_coverage/html_paths.rb
55
+ - lib/path_coverage/html_report.rb
56
+ - lib/path_coverage/html_template.rb
57
+ - lib/path_coverage/html_tree.rb
58
+ - lib/path_coverage/json_writer.rb
59
+ - lib/path_coverage/kernel.rb
60
+ - lib/path_coverage/manifest.rb
61
+ - lib/path_coverage/manifest_blocks.rb
62
+ - lib/path_coverage/manifest_case.rb
63
+ - lib/path_coverage/manifest_choices.rb
64
+ - lib/path_coverage/merge.rb
65
+ - lib/path_coverage/path_gaps.rb
66
+ - lib/path_coverage/path_manifest.rb
67
+ - lib/path_coverage/paths_report.rb
68
+ - lib/path_coverage/post.rb
69
+ - lib/path_coverage/recorder.rb
70
+ - lib/path_coverage/render.rb
71
+ - lib/path_coverage/result.rb
72
+ - lib/path_coverage/rewriter.rb
73
+ - lib/path_coverage/site.rb
74
+ - lib/path_coverage/site_paths.rb
75
+ - lib/path_coverage/site_tree.rb
76
+ - lib/path_coverage/source_lines.rb
77
+ - lib/path_coverage/summary.rb
78
+ - lib/path_coverage/totals.rb
79
+ - lib/path_coverage/version.rb
80
+ homepage: https://github.com/cyber-dojo/path_coverage
22
81
  licenses:
23
82
  - BSD-2-Clause
24
83
  metadata:
25
84
  rubygems_mfa_required: 'true'
26
- post_install_message:
85
+ post_install_message:
27
86
  rdoc_options: []
28
87
  require_paths:
29
88
  - lib
@@ -38,8 +97,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
38
97
  - !ruby/object:Gem::Version
39
98
  version: '0'
40
99
  requirements: []
41
- rubygems_version: 3.0.3.1
42
- signing_key:
100
+ rubygems_version: 3.5.22
101
+ signing_key:
43
102
  specification_version: 4
44
- summary: Path coverage for Ruby, alongside SimpleCov
103
+ summary: Path coverage for Ruby, alongside SimpleCov branch coverage
45
104
  test_files: []