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.
- checksums.yaml +4 -4
- data/LICENSE.md +22 -0
- data/README.md +316 -0
- data/exe/path_coverage_collector +26 -0
- data/lib/path_coverage/auto.rb +5 -0
- data/lib/path_coverage/collector.rb +46 -0
- data/lib/path_coverage/condition.rb +45 -0
- data/lib/path_coverage/configuration.rb +127 -0
- data/lib/path_coverage/gaps.rb +60 -0
- data/lib/path_coverage/html/file.html +33 -0
- data/lib/path_coverage/html/index.html +38 -0
- data/lib/path_coverage/html/side.js +531 -0
- data/lib/path_coverage/html/sort.js +28 -0
- data/lib/path_coverage/html/style.css +334 -0
- data/lib/path_coverage/html/toggle.js +12 -0
- data/lib/path_coverage/html_file.rb +109 -0
- data/lib/path_coverage/html_index.rb +43 -0
- data/lib/path_coverage/html_nav.rb +67 -0
- data/lib/path_coverage/html_paths.rb +138 -0
- data/lib/path_coverage/html_report.rb +103 -0
- data/lib/path_coverage/html_template.rb +20 -0
- data/lib/path_coverage/html_tree.rb +137 -0
- data/lib/path_coverage/json_writer.rb +21 -0
- data/lib/path_coverage/kernel.rb +179 -0
- data/lib/path_coverage/manifest.rb +177 -0
- data/lib/path_coverage/manifest_choices.rb +113 -0
- data/lib/path_coverage/merge.rb +35 -0
- data/lib/path_coverage/path_gaps.rb +41 -0
- data/lib/path_coverage/path_manifest.rb +110 -0
- data/lib/path_coverage/paths_report.rb +55 -0
- data/lib/path_coverage/post.rb +26 -0
- data/lib/path_coverage/recorder.rb +99 -0
- data/lib/path_coverage/render.rb +92 -0
- data/lib/path_coverage/result.rb +59 -0
- data/lib/path_coverage/rewriter.rb +120 -0
- data/lib/path_coverage/site.rb +11 -0
- data/lib/path_coverage/site_paths.rb +74 -0
- data/lib/path_coverage/site_tree.rb +94 -0
- data/lib/path_coverage/source_lines.rb +79 -0
- data/lib/path_coverage/summary.rb +33 -0
- data/lib/path_coverage/version.rb +3 -0
- data/lib/path_coverage.rb +2 -5
- 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
|
data/lib/path_coverage.rb
CHANGED
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.
|
|
4
|
+
version: 0.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon Jagger
|
|
8
|
-
autorequire:
|
|
9
|
-
bindir:
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
11
|
date: 2026-11-02 00:00:00.000000000 Z
|
|
12
|
-
dependencies:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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.
|
|
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: []
|