path_coverage 0.0.2 → 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.
- checksums.yaml +4 -4
- data/README.md +57 -20
- data/exe/path_coverage_collector +12 -1
- data/lib/path_coverage/calls.rb +67 -0
- data/lib/path_coverage/collector.rb +88 -22
- data/lib/path_coverage/condition.rb +54 -3
- data/lib/path_coverage/configuration.rb +9 -6
- data/lib/path_coverage/html/index.html +5 -2
- data/lib/path_coverage/html/side.js +15 -4
- data/lib/path_coverage/html/sort.js +4 -1
- data/lib/path_coverage/html/style.css +4 -0
- data/lib/path_coverage/html_index.rb +48 -17
- data/lib/path_coverage/html_report.rb +18 -3
- data/lib/path_coverage/html_tree.rb +11 -2
- data/lib/path_coverage/json_writer.rb +11 -7
- data/lib/path_coverage/kernel.rb +21 -35
- data/lib/path_coverage/manifest.rb +40 -12
- data/lib/path_coverage/manifest_blocks.rb +57 -0
- data/lib/path_coverage/manifest_case.rb +54 -0
- data/lib/path_coverage/manifest_choices.rb +37 -18
- data/lib/path_coverage/recorder.rb +83 -0
- data/lib/path_coverage/rewriter.rb +53 -7
- data/lib/path_coverage/site.rb +6 -1
- data/lib/path_coverage/site_paths.rb +21 -9
- data/lib/path_coverage/site_tree.rb +50 -10
- data/lib/path_coverage/summary.rb +36 -17
- data/lib/path_coverage/totals.rb +29 -0
- data/lib/path_coverage/version.rb +1 -1
- metadata +6 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative 'condition'
|
|
2
|
+
|
|
1
3
|
module PathCoverage
|
|
2
4
|
# Instruments source text so that each leaf operand records its
|
|
3
5
|
# outcome when the code runs. It inserts text at the byte
|
|
@@ -13,12 +15,54 @@ module PathCoverage
|
|
|
13
15
|
# line numbers still match the file on disk.
|
|
14
16
|
def self.rewrite(source, sites, methods = {})
|
|
15
17
|
inserts = sites.flat_map do |site, condition|
|
|
16
|
-
|
|
17
|
-
trail_inserts(site, condition)
|
|
18
|
+
site_inserts(site, condition)
|
|
18
19
|
end
|
|
19
20
|
apply(source.dup, inserts + path_inserts(methods))
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
# The inserts for one site, by its kind: a case's patterns'
|
|
24
|
+
# stand-ins, a block method's call and block, or any other
|
|
25
|
+
# site's leaves' records and its leave.
|
|
26
|
+
def self.site_inserts(site, condition)
|
|
27
|
+
kind = condition.operator
|
|
28
|
+
return case_inserts(site, condition) if kind == 'case'
|
|
29
|
+
|
|
30
|
+
blocks = BLOCK_PATHS.key?(kind)
|
|
31
|
+
return block_inserts(site, condition) if blocks
|
|
32
|
+
|
|
33
|
+
record_inserts(site, condition) + trail_inserts(site, condition)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The inserts that wrap a block method's call whole in a
|
|
37
|
+
# leave_items call that enters it first, inside any leave of
|
|
38
|
+
# a site it is the predicate of, and its block's expression,
|
|
39
|
+
# outside any site's leave there, in an item call, noting its
|
|
40
|
+
# last answer, or for a method that calls it with every item
|
|
41
|
+
# an answer call, noting every answer.
|
|
42
|
+
def self.block_inserts(site, condition)
|
|
43
|
+
_receiver, body = condition.leaves
|
|
44
|
+
enter = "PathCoverage.enter_items(#{site.inspect})"
|
|
45
|
+
call = "PathCoverage.leave_items(#{enter}, "
|
|
46
|
+
every = EVERY_ITEM.include?(condition.operator)
|
|
47
|
+
noter = every ? 'answer' : 'item'
|
|
48
|
+
item = "PathCoverage.#{noter}(#{site.inspect}, "
|
|
49
|
+
command = condition.commands.include?(body)
|
|
50
|
+
wrap(call, condition.extent, false, 0.5) +
|
|
51
|
+
wrap(item, body, command, 1.5)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The inserts that wrap each pattern of a case site in a
|
|
55
|
+
# when_match call, its stand-in, which enters and leaves the
|
|
56
|
+
# site itself, as the case tries its patterns one by one.
|
|
57
|
+
def self.case_inserts(site, condition)
|
|
58
|
+
size = condition.leaves.size
|
|
59
|
+
condition.leaves.each_with_index.flat_map do |range, index|
|
|
60
|
+
call = "PathCoverage.when_match(#{site.inspect}, " \
|
|
61
|
+
"#{index}, #{size}, "
|
|
62
|
+
wrap(call, range, false, 0)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
22
66
|
# The inserts that wrap each of site's leaves in a record
|
|
23
67
|
# call, innermost of all.
|
|
24
68
|
def self.record_inserts(site, condition)
|
|
@@ -45,12 +89,14 @@ module PathCoverage
|
|
|
45
89
|
wrap(call, nested ? wrapped.extent : wrapped, word, 1)
|
|
46
90
|
end
|
|
47
91
|
|
|
48
|
-
# What a site's leave call wraps: an if's
|
|
49
|
-
# byte range or a nested Condition, as
|
|
50
|
-
# there and its arms are statements that
|
|
51
|
-
# raise; any other site as a whole.
|
|
92
|
+
# What a site's leave call wraps: an if's or an unless's
|
|
93
|
+
# predicate, a leaf's byte range or a nested Condition, as
|
|
94
|
+
# its path is decided there and its arms are statements that
|
|
95
|
+
# may return or raise; any other site as a whole.
|
|
52
96
|
def self.trailed(condition)
|
|
53
|
-
|
|
97
|
+
unless STATEMENT_CHOICES.include?(condition.operator)
|
|
98
|
+
return condition
|
|
99
|
+
end
|
|
54
100
|
|
|
55
101
|
condition.operands.first
|
|
56
102
|
end
|
data/lib/path_coverage/site.rb
CHANGED
|
@@ -4,7 +4,12 @@ module PathCoverage
|
|
|
4
4
|
module Site
|
|
5
5
|
# "path:line:column" of node, eg "k.rb:2:2".
|
|
6
6
|
def self.of(path, node)
|
|
7
|
-
|
|
7
|
+
at(path, node.location)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# "path:line:column" of where loc starts, eg an if's keyword,
|
|
11
|
+
# which a modifier if's statement comes before.
|
|
12
|
+
def self.at(path, loc)
|
|
8
13
|
"#{path}:#{loc.start_line}:#{loc.start_column}"
|
|
9
14
|
end
|
|
10
15
|
end
|
|
@@ -6,27 +6,38 @@ module PathCoverage
|
|
|
6
6
|
# order, nil for a leaf that evaluation never reaches.
|
|
7
7
|
module SitePaths
|
|
8
8
|
# Every possible path through site's condition, true
|
|
9
|
-
# before false
|
|
9
|
+
# before false; a stop-early block method's are fixed by its
|
|
10
|
+
# shape alone.
|
|
10
11
|
def self.possible(site)
|
|
12
|
+
shape = BLOCK_PATHS[site.operator]
|
|
13
|
+
return shape if shape
|
|
14
|
+
|
|
11
15
|
walk(site, site.fixed).map do |outcomes, _value|
|
|
12
16
|
site.leaves.map { |range| outcomes[range] }
|
|
13
17
|
end
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
# The outcome of an operand on which each operator goes on
|
|
17
|
-
# to its next operand; any other ends the chain there.
|
|
21
|
+
# to its next operand; any other ends the chain there. A
|
|
22
|
+
# when's patterns, and a case's whens, stop at a match.
|
|
18
23
|
CONTINUES_ON = {
|
|
19
|
-
'&&' => true, 'and' => true, '||' => false, 'or' => false
|
|
24
|
+
'&&' => true, 'and' => true, '||' => false, 'or' => false,
|
|
25
|
+
'when' => false, 'case' => false
|
|
20
26
|
}.freeze
|
|
21
27
|
|
|
22
28
|
# [[{ leaf range => outcome }, value], ...] for each way
|
|
23
29
|
# node can be evaluated, value being the outcome of node as
|
|
24
30
|
# a whole; fixed is the site's { leaf range => its one
|
|
25
|
-
# outcome }.
|
|
31
|
+
# outcome }. A case is the chain of its whens' conditions.
|
|
26
32
|
def self.walk(node, fixed)
|
|
27
33
|
return leaf(node, fixed) unless node.is_a?(Condition)
|
|
28
34
|
return ternary(node, fixed) if CHOICES.include?(node.operator)
|
|
29
35
|
|
|
36
|
+
if node.operator == 'case'
|
|
37
|
+
whens = node.operands.filter_map(&:body)
|
|
38
|
+
return chain(node.with(operands: whens), fixed)
|
|
39
|
+
end
|
|
40
|
+
|
|
30
41
|
chain(node, fixed)
|
|
31
42
|
end
|
|
32
43
|
|
|
@@ -47,14 +58,15 @@ module PathCoverage
|
|
|
47
58
|
end
|
|
48
59
|
end
|
|
49
60
|
|
|
50
|
-
# The ways a ternary or an
|
|
51
|
-
# its predicate, then each way of the condition
|
|
52
|
-
# picks is, if that arm is one;
|
|
53
|
-
# picks none. Its value is that of no choice, so is nil.
|
|
61
|
+
# The ways a ternary, an if or an unless can be evaluated:
|
|
62
|
+
# each way of its predicate, then each way of the condition
|
|
63
|
+
# the arm it picks is, if that arm is one; one with no else
|
|
64
|
+
# arm picks none. Its value is that of no choice, so is nil.
|
|
54
65
|
def self.ternary(node, fixed)
|
|
55
66
|
predicate, then_arm, else_arm = node.operands
|
|
67
|
+
then_on = THEN_ON[node.operator]
|
|
56
68
|
walk(predicate, fixed).flat_map do |outcomes, value|
|
|
57
|
-
body = (value ? then_arm : else_arm)&.body
|
|
69
|
+
body = (value == then_on ? then_arm : else_arm)&.body
|
|
58
70
|
next [[outcomes, nil]] if body.nil?
|
|
59
71
|
|
|
60
72
|
walk(body, fixed).map do |more, _|
|
|
@@ -24,14 +24,24 @@ module PathCoverage
|
|
|
24
24
|
# The tree of condition, each leaf taking the next index of
|
|
25
25
|
# leaves, so leaves pair with path entries left to right.
|
|
26
26
|
def self.node(source, condition, outcomes, leaves)
|
|
27
|
-
|
|
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|
|
|
28
39
|
case operand
|
|
29
40
|
when Condition then node(source, operand, outcomes, leaves)
|
|
30
41
|
when Arm then operand
|
|
31
42
|
else leaf(source, operand, outcomes, leaves.next)
|
|
32
43
|
end
|
|
33
44
|
end
|
|
34
|
-
arms(source, condition, operands, outcomes, leaves)
|
|
35
45
|
end
|
|
36
46
|
|
|
37
47
|
# condition's node from its operands' nodes, its ternary
|
|
@@ -42,21 +52,51 @@ module PathCoverage
|
|
|
42
52
|
operands = operands.map do |operand|
|
|
43
53
|
next operand unless operand.is_a?(Arm)
|
|
44
54
|
|
|
45
|
-
|
|
55
|
+
decided = [predicate[:cells], THEN_ON[condition.operator]]
|
|
56
|
+
arm(source, operand, decided, outcomes, leaves)
|
|
46
57
|
end
|
|
47
58
|
cells = value(condition.operator, operands)
|
|
48
59
|
{ operator: condition.operator, cells:, operands: }
|
|
49
60
|
end
|
|
50
61
|
|
|
51
|
-
#
|
|
52
|
-
# with
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
56
97
|
body = arm.body && node(source, arm.body, outcomes, leaves)
|
|
57
|
-
text = arm.extent && source.byteslice(arm.extent)
|
|
58
98
|
{
|
|
59
|
-
arm: arm.kind, text
|
|
99
|
+
arm: arm.kind, text: text_of(source, arm),
|
|
60
100
|
cells: decided.map { |outcome| outcome == picked }, body:
|
|
61
101
|
}
|
|
62
102
|
end
|
|
@@ -1,28 +1,47 @@
|
|
|
1
|
+
require_relative 'totals'
|
|
2
|
+
|
|
1
3
|
module PathCoverage
|
|
2
|
-
# The per-file numbers the HTML index page shows
|
|
4
|
+
# The per-file numbers the HTML index page shows, and their
|
|
5
|
+
# total over every file.
|
|
3
6
|
module Summary
|
|
4
|
-
# One row per file that has sites in
|
|
5
|
-
# Result#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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)
|
|
9
29
|
end
|
|
10
30
|
|
|
11
|
-
# The
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
seen = operands.sum { |counts| seen_in(counts) }
|
|
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] }
|
|
16
35
|
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
36
|
+
sites: rows.sum { |row| row[:sites] },
|
|
37
|
+
decisions: add(rows.map { |row| row[:decisions] }),
|
|
38
|
+
methods: methods.first && add(methods)
|
|
20
39
|
}
|
|
21
40
|
end
|
|
22
41
|
|
|
23
|
-
#
|
|
24
|
-
def self.
|
|
25
|
-
|
|
42
|
+
# [taken, possible] pairs added together.
|
|
43
|
+
def self.add(pairs)
|
|
44
|
+
pairs.transpose.map(&:sum)
|
|
26
45
|
end
|
|
27
46
|
|
|
28
47
|
# The file a site is in: its name minus :line:column.
|
|
@@ -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
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon Jagger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -37,6 +37,7 @@ files:
|
|
|
37
37
|
- exe/path_coverage_collector
|
|
38
38
|
- lib/path_coverage.rb
|
|
39
39
|
- lib/path_coverage/auto.rb
|
|
40
|
+
- lib/path_coverage/calls.rb
|
|
40
41
|
- lib/path_coverage/collector.rb
|
|
41
42
|
- lib/path_coverage/condition.rb
|
|
42
43
|
- lib/path_coverage/configuration.rb
|
|
@@ -57,6 +58,8 @@ files:
|
|
|
57
58
|
- lib/path_coverage/json_writer.rb
|
|
58
59
|
- lib/path_coverage/kernel.rb
|
|
59
60
|
- lib/path_coverage/manifest.rb
|
|
61
|
+
- lib/path_coverage/manifest_blocks.rb
|
|
62
|
+
- lib/path_coverage/manifest_case.rb
|
|
60
63
|
- lib/path_coverage/manifest_choices.rb
|
|
61
64
|
- lib/path_coverage/merge.rb
|
|
62
65
|
- lib/path_coverage/path_gaps.rb
|
|
@@ -72,6 +75,7 @@ files:
|
|
|
72
75
|
- lib/path_coverage/site_tree.rb
|
|
73
76
|
- lib/path_coverage/source_lines.rb
|
|
74
77
|
- lib/path_coverage/summary.rb
|
|
78
|
+
- lib/path_coverage/totals.rb
|
|
75
79
|
- lib/path_coverage/version.rb
|
|
76
80
|
homepage: https://github.com/cyber-dojo/path_coverage
|
|
77
81
|
licenses:
|