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,92 @@
1
+ require 'json'
2
+ require_relative 'gaps'
3
+ require_relative 'html_report'
4
+ require_relative 'manifest'
5
+ require_relative 'merge'
6
+ require_relative 'paths_report'
7
+ require_relative 'post'
8
+ require_relative 'site_paths'
9
+
10
+ module PathCoverage
11
+ # The reporters, run from operands alone: needing nothing the
12
+ # run recorded beyond them, they can run in another process.
13
+ module Render
14
+ # Runs each reporter chosen in config. operands and
15
+ # site_paths are as Result gives them; files is { full path
16
+ # => {source:, sites:} } for each covered file.
17
+ def self.run(operands, files, config, paths = nil, site_paths: {})
18
+ site_paths = every_site(site_paths, files)
19
+ reporters = config.reporters
20
+ gaps(operands, files, site_paths) if reporters.include?(:gaps)
21
+ if reporters.include?(:html)
22
+ HtmlReport.write(operands, files, config, paths, site_paths:)
23
+ end
24
+ return unless reporters.include?(:post)
25
+
26
+ report = PathsReport.of(site_paths, files)
27
+ Post.deliver(operands, config.collector_url, report)
28
+ end
29
+
30
+ # site_paths with every site in files that it lacks, eg
31
+ # from a JSON report with no paths, given each possible
32
+ # path at a count of zero: unable to say which paths such
33
+ # a site took, the report claims none.
34
+ def self.every_site(site_paths, files)
35
+ none = files.values.each_with_object({}) do |parsed, all|
36
+ parsed[:sites].each do |site, condition|
37
+ all[site] = SitePaths.possible(condition).map do |path|
38
+ { path:, count: 0 }
39
+ end
40
+ end
41
+ end
42
+ none.merge(site_paths)
43
+ end
44
+
45
+ # Prints the gap lines: each path a site never took, each
46
+ # leaf named by its text in files.
47
+ def self.gaps(operands, files, site_paths)
48
+ texts = PathsReport.texts(files)
49
+ lines = Gaps.lines(operands, paths: site_paths, texts:)
50
+ lines.each { |line| puts line }
51
+ end
52
+
53
+ # Runs the reporters chosen in config from the JSON reports
54
+ # at paths, their counts merged, parsing each covered file
55
+ # afresh. paths is config.json_path alone by default.
56
+ def self.from_json(config, paths = [config.json_path])
57
+ operands = Merge.operands(paths.map { |path| read(path) })
58
+ site_paths = Merge.paths(paths.map { |path| paths_of(path) })
59
+ files = config.files.to_h do |path|
60
+ [path, Manifest.parse(path, config)]
61
+ end
62
+ run(operands, files, config, site_paths:)
63
+ end
64
+
65
+ # The site paths of the JSON report at path, as
66
+ # Result#paths gives them; none for a report that has none.
67
+ def self.paths_of(path)
68
+ json = JSON.parse(File.read(path))
69
+ json.fetch('paths', {}).transform_values do |site|
70
+ site['taken'].map do |each|
71
+ { path: each['path'], count: each['count'] }
72
+ end
73
+ end
74
+ end
75
+
76
+ # The operands of the JSON report at path.
77
+ def self.read(path)
78
+ json = JSON.parse(File.read(path))
79
+ json['sites'].transform_values do |leaves|
80
+ leaves.map { |leaf| counts(leaf) }
81
+ end
82
+ end
83
+
84
+ # One operand's counts as JSON holds them, with the keys,
85
+ # and any only: value, as the symbols Result#operands uses.
86
+ def self.counts(leaf)
87
+ counts = { truthy: leaf['truthy'], falsy: leaf['falsy'] }
88
+ only = leaf['only']
89
+ only ? counts.merge(only: only.to_sym) : counts
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,59 @@
1
+ require_relative 'site_paths'
2
+
3
+ module PathCoverage
4
+ # What a run found: every site the manifest knows of, with
5
+ # the counts its operands were recorded with. Every
6
+ # reporter builds on this.
7
+ class Result
8
+ NEVER_RAN = { truthy: 0, falsy: 0 }.freeze
9
+
10
+ # sites is { site => Condition }, from the manifest;
11
+ # counts is { [site, index] => counts } and site_paths is
12
+ # { site => { path => evaluations } }, both from the
13
+ # recorder.
14
+ def initialize(sites, counts, site_paths = {})
15
+ @sites = sites
16
+ @counts = counts
17
+ @site_paths = site_paths
18
+ end
19
+
20
+ # { site => [{ path:, count: }, ...] }: every possible path
21
+ # of each site, as SitePaths gives them, with the number of
22
+ # evaluations that took it, zero for one never taken.
23
+ def paths
24
+ @sites.to_h do |site, condition|
25
+ taken = @site_paths.fetch(site, {})
26
+ all = SitePaths.possible(condition).map do |path|
27
+ { path:, count: taken.fetch(path, 0) }
28
+ end
29
+ [site, all]
30
+ end
31
+ end
32
+
33
+ # { site => [counts of operand 0, counts of operand 1, ...] },
34
+ # with zero counts for an operand that never ran, and
35
+ # only: naming the one outcome a fixed leaf can have.
36
+ def operands
37
+ @sites.to_h do |site, condition|
38
+ leaves = condition.leaves.each_with_index.map do |range, i|
39
+ leaf(site, condition, range, i)
40
+ end
41
+ [site, leaves]
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # The counts of one leaf, with only: when it is fixed.
48
+ def leaf(site, condition, range, index)
49
+ counts = counts_of(site, index)
50
+ only = condition.fixed[range]
51
+ only ? counts.merge(only:) : counts
52
+ end
53
+
54
+ # The recorded counts of one operand, or zeros.
55
+ def counts_of(site, index)
56
+ @counts.fetch([site, index], NEVER_RAN)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,120 @@
1
+ module PathCoverage
2
+ # Instruments source text so that each leaf operand records its
3
+ # outcome when the code runs. It inserts text at the byte
4
+ # ranges the manifest found, and never parses.
5
+ module Rewriter
6
+ PATH_NAME = '__path_coverage_path'.freeze
7
+
8
+ # Returns source with each operand in sites wrapped in a
9
+ # record call, each site as a whole in a leave call that
10
+ # enters it first, and each method in methods starting a
11
+ # path that its decisions are wrapped in decide calls to add
12
+ # to. Only text is inserted, never a newline, so backtrace
13
+ # line numbers still match the file on disk.
14
+ def self.rewrite(source, sites, methods = {})
15
+ inserts = sites.flat_map do |site, condition|
16
+ record_inserts(site, condition) +
17
+ trail_inserts(site, condition)
18
+ end
19
+ apply(source.dup, inserts + path_inserts(methods))
20
+ end
21
+
22
+ # The inserts that wrap each of site's leaves in a record
23
+ # call, innermost of all.
24
+ def self.record_inserts(site, condition)
25
+ condition.leaves.each_with_index.flat_map do |range, index|
26
+ command = condition.commands.include?(range)
27
+ wrap(record_call(site, index), range, command, 0)
28
+ end
29
+ end
30
+
31
+ WORD_OPERATORS = %w[and or].freeze
32
+
33
+ # The inserts that wrap site as a whole in a leave call,
34
+ # outside its leaves' record calls, whose first argument
35
+ # enters the site before any leaf runs, so each evaluation
36
+ # fills in a trail of its own. An and or or is held in
37
+ # parentheses, being, like a command call, no bare argument.
38
+ def self.trail_inserts(site, condition)
39
+ enter = "PathCoverage.enter(#{site.inspect}, " \
40
+ "#{condition.leaves.size})"
41
+ call = "PathCoverage.leave(#{enter}, "
42
+ wrapped = trailed(condition)
43
+ nested = wrapped.is_a?(Condition)
44
+ word = nested && WORD_OPERATORS.include?(wrapped.operator)
45
+ wrap(call, nested ? wrapped.extent : wrapped, word, 1)
46
+ end
47
+
48
+ # What a site's leave call wraps: an if's predicate, a leaf's
49
+ # byte range or a nested Condition, as its path is decided
50
+ # there and its arms are statements that may return or
51
+ # raise; any other site as a whole.
52
+ def self.trailed(condition)
53
+ return condition unless condition.operator == 'if'
54
+
55
+ condition.operands.first
56
+ end
57
+
58
+ # The opening of the record call around operand index of
59
+ # the site.
60
+ def self.record_call(site, index)
61
+ "PathCoverage.record(#{site.inspect}, #{index}, "
62
+ end
63
+
64
+ # The inserts that start each method's path at its entry,
65
+ # outermost of all there, and wrap each of its decisions'
66
+ # predicates in a decide call, outside any record or
67
+ # outcome call.
68
+ def self.path_inserts(methods)
69
+ methods.flat_map do |site, method|
70
+ decisions = method.decisions
71
+ start = path_start(site, decisions.size)
72
+ decides = decisions.each_with_index.flat_map do |decision, i|
73
+ call = "PathCoverage.decide(#{PATH_NAME}, #{i}, "
74
+ tail = decision.exits.nil? ? '' : ", #{decision.exits}"
75
+ wrap(call, decision.range, decision.command, 2, tail)
76
+ end
77
+ [[method.entry, start, 3]] + decides
78
+ end
79
+ end
80
+
81
+ # The statement that starts a call's path through the
82
+ # method at site, which has size decisions.
83
+ def self.path_start(site, size)
84
+ call = "PathCoverage.path(#{site.inspect}, #{size})"
85
+ "#{PATH_NAME} = #{call}; "
86
+ end
87
+
88
+ # The two inserts that wrap the code at range in call, of
89
+ # rank among inserts at the same offset, with tail as the
90
+ # call's further arguments. A command call is parenthesised,
91
+ # as it is no bare argument; nothing else is, since
92
+ # parentheses make a statement, which gives its line a
93
+ # line-coverage count of its own.
94
+ def self.wrap(call, range, command, rank, tail = '')
95
+ open, close = command ? ['(', ")#{tail})"] : ['', "#{tail})"]
96
+ [
97
+ [range.begin, call + open, rank],
98
+ [range.end, close, rank, :close]
99
+ ]
100
+ end
101
+
102
+ # Splices each [offset, text, rank] insert into source, last
103
+ # offset first so earlier offsets stay valid; a closing
104
+ # insert is marked :close. Text spliced in later at an
105
+ # offset lands before text already there, so at one offset
106
+ # opening inserts go in lowest rank first, leaving the
107
+ # highest outermost, then closing ones highest rank first,
108
+ # so the innermost call closes first and each tail stays
109
+ # with its own call.
110
+ def self.apply(source, inserts)
111
+ sorted = inserts.sort_by do |offset, _, rank, close|
112
+ close ? [-offset, 1, -rank] : [-offset, 0, rank]
113
+ end
114
+ sorted.each do |offset, text, _|
115
+ source.bytesplice(offset, 0, text)
116
+ end
117
+ source
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,11 @@
1
+ module PathCoverage
2
+ # Names a condition by where it starts in its file, so the
3
+ # rewriter and the manifest agree on which site is which.
4
+ module Site
5
+ # "path:line:column" of node, eg "k.rb:2:2".
6
+ def self.of(path, node)
7
+ loc = node.location
8
+ "#{path}:#{loc.start_line}:#{loc.start_column}"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,74 @@
1
+ require_relative 'condition'
2
+
3
+ module PathCoverage
4
+ # The paths an evaluation of a site can take, worked out
5
+ # from its condition alone: the outcome of each leaf, in leaf
6
+ # order, nil for a leaf that evaluation never reaches.
7
+ module SitePaths
8
+ # Every possible path through site's condition, true
9
+ # before false.
10
+ def self.possible(site)
11
+ walk(site, site.fixed).map do |outcomes, _value|
12
+ site.leaves.map { |range| outcomes[range] }
13
+ end
14
+ end
15
+
16
+ # The outcome of an operand on which each operator goes on
17
+ # to its next operand; any other ends the chain there.
18
+ CONTINUES_ON = {
19
+ '&&' => true, 'and' => true, '||' => false, 'or' => false
20
+ }.freeze
21
+
22
+ # [[{ leaf range => outcome }, value], ...] for each way
23
+ # node can be evaluated, value being the outcome of node as
24
+ # a whole; fixed is the site's { leaf range => its one
25
+ # outcome }.
26
+ def self.walk(node, fixed)
27
+ return leaf(node, fixed) unless node.is_a?(Condition)
28
+ return ternary(node, fixed) if CHOICES.include?(node.operator)
29
+
30
+ chain(node, fixed)
31
+ end
32
+
33
+ # The ways a chain of one operator can be evaluated: each
34
+ # way of its first operand, going on to the next while each
35
+ # outcome is the one its operator goes on with.
36
+ def self.chain(node, fixed)
37
+ first, *rest = node.operands
38
+ goes_on = CONTINUES_ON.fetch(node.operator)
39
+ rest.reduce(walk(first, fixed)) do |ways, operand|
40
+ ways.flat_map do |outcomes, value|
41
+ next [[outcomes, value]] unless value == goes_on
42
+
43
+ walk(operand, fixed).map do |more, after|
44
+ [outcomes.merge(more), after]
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ # The ways a ternary or an if can be evaluated: each way of
51
+ # its predicate, then each way of the condition the arm it
52
+ # picks is, if that arm is one; an if with no else arm
53
+ # picks none. Its value is that of no choice, so is nil.
54
+ def self.ternary(node, fixed)
55
+ predicate, then_arm, else_arm = node.operands
56
+ walk(predicate, fixed).flat_map do |outcomes, value|
57
+ body = (value ? then_arm : else_arm)&.body
58
+ next [[outcomes, nil]] if body.nil?
59
+
60
+ walk(body, fixed).map do |more, _|
61
+ [outcomes.merge(more), nil]
62
+ end
63
+ end
64
+ end
65
+
66
+ # The ways a leaf can be evaluated: true, then false, or
67
+ # its one outcome alone when it is fixed.
68
+ def self.leaf(range, fixed)
69
+ only = fixed[range]
70
+ outcomes = only ? [only == :truthy] : [true, false]
71
+ outcomes.map { |outcome| [{ range => outcome }, outcome] }
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,94 @@
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
+ operands = condition.operands.map do |operand|
28
+ case operand
29
+ when Condition then node(source, operand, outcomes, leaves)
30
+ when Arm then operand
31
+ else leaf(source, operand, outcomes, leaves.next)
32
+ end
33
+ end
34
+ arms(source, condition, operands, outcomes, leaves)
35
+ end
36
+
37
+ # condition's node from its operands' nodes, its ternary
38
+ # arms built in order after its predicate, as each arm's
39
+ # cells follow from the predicate's.
40
+ def self.arms(source, condition, operands, outcomes, leaves)
41
+ predicate = operands.first
42
+ operands = operands.map do |operand|
43
+ next operand unless operand.is_a?(Arm)
44
+
45
+ arm(source, operand, predicate[:cells], outcomes, leaves)
46
+ end
47
+ cells = value(condition.operator, operands)
48
+ { operator: condition.operator, cells:, operands: }
49
+ end
50
+
51
+ # One arm of a ternary or an if: its text, nil for an arm
52
+ # with no statements, whether each column took it, from the
53
+ # predicate's cells, and the tree of its condition.
54
+ def self.arm(source, arm, decided, outcomes, leaves)
55
+ picked = arm.kind == :then
56
+ body = arm.body && node(source, arm.body, outcomes, leaves)
57
+ text = arm.extent && source.byteslice(arm.extent)
58
+ {
59
+ arm: arm.kind, text:,
60
+ cells: decided.map { |outcome| outcome == picked }, body:
61
+ }
62
+ end
63
+
64
+ # A condition's value in each column: a ternary's or an
65
+ # if's is its taken arm's body's, nil for a plain arm; a
66
+ # chain's is that of the last operand it reached.
67
+ def self.value(operator, operands)
68
+ if CHOICES.include?(operator)
69
+ _, *arms = operands
70
+ arms.map { |arm| arm[:body]&.fetch(:cells) }
71
+ .then { |bodies| ternary_value(arms, bodies) }
72
+ else
73
+ operands.map { |operand| operand[:cells] }
74
+ .transpose.map { |column| column.compact.last }
75
+ end
76
+ end
77
+
78
+ # Each column's value from the arm it took.
79
+ def self.ternary_value(arms, bodies)
80
+ arms.first[:cells].each_index.map do |column|
81
+ taken = arms.index { |arm| arm[:cells][column] }
82
+ taken && bodies[taken]&.fetch(column)
83
+ end
84
+ end
85
+
86
+ # One leaf's text and its outcome in each column.
87
+ def self.leaf(source, range, outcomes, index)
88
+ {
89
+ text: source.byteslice(range),
90
+ cells: outcomes.map { |path| path[index] }
91
+ }
92
+ end
93
+ end
94
+ 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,33 @@
1
+ module PathCoverage
2
+ # The per-file numbers the HTML index page shows.
3
+ module Summary
4
+ # One row per file that has sites in operands, as
5
+ # Result#operands gives them, in path order.
6
+ def self.files(operands)
7
+ by_path = operands.group_by { |site, _| path_of(site) }
8
+ by_path.sort.map { |path, sites| row(path, sites) }
9
+ end
10
+
11
+ # The summary row of one file's sites.
12
+ def self.row(path, sites)
13
+ operands = sites.flat_map { |_, counts| counts }
14
+ outcomes = operands.sum { |counts| counts[:only] ? 1 : 2 }
15
+ seen = operands.sum { |counts| seen_in(counts) }
16
+ {
17
+ path:, sites: sites.size, seen:, outcomes:,
18
+ percent: (100.0 * seen / outcomes).round,
19
+ missing: outcomes - seen
20
+ }
21
+ end
22
+
23
+ # How many of an operand's two outcomes were seen.
24
+ def self.seen_in(counts)
25
+ [counts[:truthy], counts[:falsy]].count(&:positive?)
26
+ end
27
+
28
+ # The file a site is in: its name minus :line:column.
29
+ def self.path_of(site)
30
+ site.sub(/:\d+:\d+\z/, '')
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ module PathCoverage
2
+ VERSION = '0.0.2'.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,84 @@
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.0.2
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
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: []
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/collector.rb
41
+ - lib/path_coverage/condition.rb
42
+ - lib/path_coverage/configuration.rb
43
+ - lib/path_coverage/gaps.rb
44
+ - lib/path_coverage/html/file.html
45
+ - lib/path_coverage/html/index.html
46
+ - lib/path_coverage/html/side.js
47
+ - lib/path_coverage/html/sort.js
48
+ - lib/path_coverage/html/style.css
49
+ - lib/path_coverage/html/toggle.js
50
+ - lib/path_coverage/html_file.rb
51
+ - lib/path_coverage/html_index.rb
52
+ - lib/path_coverage/html_nav.rb
53
+ - lib/path_coverage/html_paths.rb
54
+ - lib/path_coverage/html_report.rb
55
+ - lib/path_coverage/html_template.rb
56
+ - lib/path_coverage/html_tree.rb
57
+ - lib/path_coverage/json_writer.rb
58
+ - lib/path_coverage/kernel.rb
59
+ - lib/path_coverage/manifest.rb
60
+ - lib/path_coverage/manifest_choices.rb
61
+ - lib/path_coverage/merge.rb
62
+ - lib/path_coverage/path_gaps.rb
63
+ - lib/path_coverage/path_manifest.rb
64
+ - lib/path_coverage/paths_report.rb
65
+ - lib/path_coverage/post.rb
66
+ - lib/path_coverage/recorder.rb
67
+ - lib/path_coverage/render.rb
68
+ - lib/path_coverage/result.rb
69
+ - lib/path_coverage/rewriter.rb
70
+ - lib/path_coverage/site.rb
71
+ - lib/path_coverage/site_paths.rb
72
+ - lib/path_coverage/site_tree.rb
73
+ - lib/path_coverage/source_lines.rb
74
+ - lib/path_coverage/summary.rb
75
+ - lib/path_coverage/version.rb
76
+ homepage: https://github.com/cyber-dojo/path_coverage
22
77
  licenses:
23
78
  - BSD-2-Clause
24
79
  metadata:
25
80
  rubygems_mfa_required: 'true'
26
- post_install_message:
81
+ post_install_message:
27
82
  rdoc_options: []
28
83
  require_paths:
29
84
  - lib
@@ -38,8 +93,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
38
93
  - !ruby/object:Gem::Version
39
94
  version: '0'
40
95
  requirements: []
41
- rubygems_version: 3.0.3.1
42
- signing_key:
96
+ rubygems_version: 3.5.22
97
+ signing_key:
43
98
  specification_version: 4
44
- summary: Path coverage for Ruby, alongside SimpleCov
99
+ summary: Path coverage for Ruby, alongside SimpleCov branch coverage
45
100
  test_files: []