piggly-nsd 2.3.3 → 2.3.4
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 +13 -1
- data/lib/piggly/command/report.rb +40 -6
- data/lib/piggly/compiler/line_coverage.rb +213 -0
- data/lib/piggly/compiler.rb +1 -0
- data/lib/piggly/reporter/base.rb +17 -1
- data/lib/piggly/reporter/index.rb +43 -1
- data/lib/piggly/reporter/procedure.rb +11 -1
- data/lib/piggly/reporter/resources/piggly.css +21 -20
- data/lib/piggly/reporter/sonar.rb +94 -0
- data/lib/piggly/reporter.rb +1 -0
- data/lib/piggly/tags.rb +1 -1
- data/lib/piggly/version.rb +2 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3aed72758cfe5ef63b8635586386845b2b3766a742cb398a9fc8121bd70e966f
|
|
4
|
+
data.tar.gz: 98e694a96ccff107b670de06d747b3a015a361a1e806c190e7bc6f9c36c0ec5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 889952ad1338856df2c539e199a18805236921b647add649937a9cfcec99578504915a000fd8174edeef13e485cab5cdda8a3ed03aa8e1a2273fe99bd5049235
|
|
7
|
+
data.tar.gz: 1de357aa82135b51d0c31b7515c102aa251bd489495f290aeaf71db302c2acc2bd19fee7dcc3410d7f1bf5e587b8852874994b02e61df6c03684114e5be53f9d
|
data/README.md
CHANGED
|
@@ -51,6 +51,10 @@ these events and generates prettified source code that is annotated with coverag
|
|
|
51
51
|
This fork continues the version numbering from the original piggly project:
|
|
52
52
|
- **2.3.1** - Last version of original piggly by Kyle Putnam
|
|
53
53
|
- **2.3.2** - NSD fork with UTF-8 encoding support and updated dependencies
|
|
54
|
+
- **2.3.3** - Fixed issues
|
|
55
|
+
- **2.3.4** - Added SonarQube integration and line coverage metrics:
|
|
56
|
+
- New `--sonar-report-path PATH` option to generate SonarQube generic coverage XML
|
|
57
|
+
- Added LINES and LINE COVERAGE columns to HTML reports
|
|
54
58
|
|
|
55
59
|
## How to Install
|
|
56
60
|
|
|
@@ -122,11 +126,19 @@ might run:
|
|
|
122
126
|
etc.
|
|
123
127
|
|
|
124
128
|
To build the coverage report, have piggly read that file in by executing `piggly report < messages.txt`,
|
|
125
|
-
|
|
129
|
+
or `piggly report -f messages.txt`. You don't actually need the intermediate file, you can pipe your
|
|
126
130
|
test suite directly in like `ant test 2>&1 | piggly report`.
|
|
127
131
|
|
|
128
132
|
Once the report is built you can open it in `piggly/reports/index.html`.
|
|
129
133
|
|
|
134
|
+
### SonarQube Integration
|
|
135
|
+
|
|
136
|
+
To generate a SonarQube-compatible coverage report, use the `--sonar-report-path` (or `-x`) option:
|
|
137
|
+
|
|
138
|
+
$ piggly report -f messages.txt --sonar-report-path piggly/sonar/coverage.xml
|
|
139
|
+
|
|
140
|
+
This generates an XML file in SonarQube's [generic test coverage format](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/test-coverage/generic-test-data/).
|
|
141
|
+
|
|
130
142
|
## Running the Examples
|
|
131
143
|
|
|
132
144
|
$ cd piggly
|
|
@@ -13,7 +13,7 @@ module Piggly
|
|
|
13
13
|
class << Report
|
|
14
14
|
def main(argv)
|
|
15
15
|
require "pp"
|
|
16
|
-
io, config = configure(argv)
|
|
16
|
+
io, config, sonar_path, html_report_requested = configure(argv)
|
|
17
17
|
|
|
18
18
|
profile = Profile.new
|
|
19
19
|
index = Dumper::Index.new(config)
|
|
@@ -33,12 +33,23 @@ module Piggly
|
|
|
33
33
|
|
|
34
34
|
profile_procedures(config, procedures, profile)
|
|
35
35
|
clear_coverage(config, profile)
|
|
36
|
-
|
|
37
36
|
read_profile(config, io, profile)
|
|
38
37
|
store_coverage(profile)
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
# Generate HTML coverage report if requested
|
|
40
|
+
if html_report_requested
|
|
41
|
+
create_index(config, index, procedures, profile)
|
|
42
|
+
create_reports(config, procedures, profile)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Generate Sonar coverage report if requested
|
|
46
|
+
if sonar_path
|
|
47
|
+
create_sonar_report(config, procedures, profile, sonar_path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless html_report_requested || sonar_path
|
|
51
|
+
puts "Warning: No report output specified. Use -o for HTML reports or -x for Sonar report."
|
|
52
|
+
end
|
|
42
53
|
end
|
|
43
54
|
|
|
44
55
|
# Adds the given procedures to Profile
|
|
@@ -118,14 +129,32 @@ module Piggly
|
|
|
118
129
|
queue.execute
|
|
119
130
|
end
|
|
120
131
|
|
|
132
|
+
# Create Sonar coverage XML report
|
|
133
|
+
#
|
|
134
|
+
def create_sonar_report(config, procedures, profile, output_path)
|
|
135
|
+
puts "creating Sonar coverage report"
|
|
136
|
+
reporter = Reporter::Sonar.new(config, profile, output_path)
|
|
137
|
+
path = reporter.report(procedures)
|
|
138
|
+
puts "Sonar coverage report written to: #{path}"
|
|
139
|
+
end
|
|
140
|
+
|
|
121
141
|
def configure(argv, config = Config.new)
|
|
122
142
|
io = $stdin
|
|
143
|
+
sonar_path = nil
|
|
144
|
+
html_report_requested = false
|
|
145
|
+
|
|
123
146
|
p = OptionParser.new do |o|
|
|
124
147
|
o.on("-t", "--dry-run", "only print the names of matching procedures", &o_dry_run(config))
|
|
125
148
|
o.on("-s", "--select PATTERN", "select procedures matching PATTERN", &o_select(config))
|
|
126
149
|
o.on("-r", "--reject PATTERN", "ignore procedures matching PATTERN", &o_reject(config))
|
|
127
150
|
o.on("-c", "--cache-root PATH", "local cache directory", &o_cache_root(config))
|
|
128
|
-
o.on("-o", "--report-root PATH", "report output directory"
|
|
151
|
+
o.on("-o", "--report-root PATH", "report output directory") do |path|
|
|
152
|
+
config.report_root = path
|
|
153
|
+
html_report_requested = true
|
|
154
|
+
end
|
|
155
|
+
o.on("-x", "--sonar-report-path PATH", "generate Sonar coverage XML report at PATH") do |path|
|
|
156
|
+
sonar_path = path
|
|
157
|
+
end
|
|
129
158
|
o.on("-a", "--accumulate", "accumulate data from the previous run", &o_accumulate(config))
|
|
130
159
|
o.on("-V", "--version", "show version", &o_version(config))
|
|
131
160
|
o.on("-h", "--help", "show this message") { abort o.to_s }
|
|
@@ -140,13 +169,18 @@ module Piggly
|
|
|
140
169
|
|
|
141
170
|
begin
|
|
142
171
|
p.parse! argv
|
|
172
|
+
|
|
173
|
+
unless html_report_requested || sonar_path
|
|
174
|
+
raise OptionParser::MissingArgument,
|
|
175
|
+
"at least one report type required: use -o for HTML reports or -x for Sonar report"
|
|
176
|
+
end
|
|
143
177
|
|
|
144
178
|
if io.eql?($stdin) and $stdin.tty?
|
|
145
179
|
raise OptionParser::MissingArgument,
|
|
146
180
|
"stdin must be a pipe, or use --input PATH"
|
|
147
181
|
end
|
|
148
182
|
|
|
149
|
-
return io, config
|
|
183
|
+
return io, config, sonar_path, html_report_requested
|
|
150
184
|
rescue OptionParser::InvalidOption,
|
|
151
185
|
OptionParser::InvalidArgument,
|
|
152
186
|
OptionParser::MissingArgument
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
module Piggly
|
|
2
|
+
module Compiler
|
|
3
|
+
|
|
4
|
+
#
|
|
5
|
+
# Calculates line-level coverage from tagged parse tree nodes.
|
|
6
|
+
#
|
|
7
|
+
class LineCoverage
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Calculate line coverage for a procedure
|
|
13
|
+
# @param procedure [Dumper::ReifiedProcedure, Dumper::SkeletonProcedure]
|
|
14
|
+
# @param profile [Profile]
|
|
15
|
+
# @return [Hash] { line_number => { covered: bool } }
|
|
16
|
+
def calculate(procedure, profile)
|
|
17
|
+
Parser.parser
|
|
18
|
+
|
|
19
|
+
compiler = TraceCompiler.new(@config)
|
|
20
|
+
|
|
21
|
+
# Let compile() handle staleness - it will recompile if needed
|
|
22
|
+
data = compiler.compile(procedure)
|
|
23
|
+
|
|
24
|
+
# Return empty if compilation failed (tree is nil)
|
|
25
|
+
tree = data[:tree]
|
|
26
|
+
return {} if tree.nil?
|
|
27
|
+
|
|
28
|
+
source = procedure.source(@config)
|
|
29
|
+
|
|
30
|
+
coverage = {}
|
|
31
|
+
traverse(tree, profile, source, coverage)
|
|
32
|
+
coverage
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Calculate summary statistics from coverage data
|
|
36
|
+
# @param coverage [Hash] line coverage data from calculate()
|
|
37
|
+
# @return [Hash] { count: Integer, percent: Float }
|
|
38
|
+
def summary(coverage)
|
|
39
|
+
return { count: 0, percent: nil } if coverage.empty?
|
|
40
|
+
|
|
41
|
+
total_lines = coverage.size
|
|
42
|
+
covered_lines = coverage.count { |_, v| v[:covered] }
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
count: total_lines,
|
|
46
|
+
percent: total_lines > 0 ? (covered_lines.to_f / total_lines * 100) : nil
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
# Traverse the parse tree and collect coverage data for each line
|
|
54
|
+
# @param node [NodeClass] parse tree node
|
|
55
|
+
# @param profile [Profile] coverage profile with tags
|
|
56
|
+
# @param source [String] source code text
|
|
57
|
+
# @param coverage [Hash] accumulator for line coverage data
|
|
58
|
+
def traverse(node, profile, source, coverage)
|
|
59
|
+
if node.tagged?
|
|
60
|
+
begin
|
|
61
|
+
tag = profile[node.tag_id]
|
|
62
|
+
|
|
63
|
+
# Get line numbers for this node
|
|
64
|
+
start_line, end_line = node_line_range(node, source)
|
|
65
|
+
|
|
66
|
+
# Record coverage for each line spanned by this node
|
|
67
|
+
# Skip lines containing only structural keywords (begin, end, declare, etc.)
|
|
68
|
+
if start_line && end_line && start_line > 0 && end_line >= start_line
|
|
69
|
+
(start_line..end_line).each do |line|
|
|
70
|
+
next if excluded_line?(source, line)
|
|
71
|
+
record_line_coverage(coverage, line, tag)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
rescue RuntimeError => e
|
|
75
|
+
# Skip nodes where tag lookup fails (expected for some nodes)
|
|
76
|
+
rescue => e
|
|
77
|
+
# Skip nodes with unexpected errors
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Recurse into child nodes
|
|
82
|
+
if node.respond_to?(:elements) && node.elements
|
|
83
|
+
node.elements.each { |child| traverse(child, profile, source, coverage) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
coverage
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check if a source line should be excluded from coverage
|
|
90
|
+
# Lines containing only structural keywords or comments are not executable
|
|
91
|
+
# @param source [String] full source code
|
|
92
|
+
# @param line_number [Integer] 1-based line number
|
|
93
|
+
# @return [Boolean] true if line should be excluded
|
|
94
|
+
def excluded_line?(source, line_number)
|
|
95
|
+
lines = source.split("\n")
|
|
96
|
+
return true if line_number < 1 || line_number > lines.length
|
|
97
|
+
|
|
98
|
+
line_content = lines[line_number - 1].strip.downcase
|
|
99
|
+
|
|
100
|
+
# Exclude lines containing only structural keywords or comments
|
|
101
|
+
excluded_patterns = [
|
|
102
|
+
/\A\s*end\s*;\s*\z/i, # end;
|
|
103
|
+
/\A\s*begin\s*\z/i, # begin
|
|
104
|
+
/\A\s*declare\s*\z/i, # declare
|
|
105
|
+
/\A\s*\$\$\s*\z/, # $$ (dollar quoting)
|
|
106
|
+
/\A\s*\z/, # empty lines
|
|
107
|
+
/\A\s*--/, # single-line comments (-- ...)
|
|
108
|
+
/\A\s*\/\*.*\*\/\s*\z/ # single-line block comments (/* ... */)
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
excluded_patterns.any? { |pattern| line_content =~ pattern }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Calculate the line range for a node
|
|
115
|
+
# @param node [NodeClass] parse tree node
|
|
116
|
+
# @param source [String] source code text
|
|
117
|
+
# @return [Array<Integer, Integer>] start_line and end_line, or [nil, nil] if unable to calculate
|
|
118
|
+
def node_line_range(node, source)
|
|
119
|
+
return [nil, nil] unless node.respond_to?(:interval)
|
|
120
|
+
return [nil, nil] unless source.is_a?(String) && !source.empty?
|
|
121
|
+
|
|
122
|
+
interval = node.interval
|
|
123
|
+
return [nil, nil] unless interval.is_a?(Range)
|
|
124
|
+
|
|
125
|
+
start_pos = interval.first.to_i
|
|
126
|
+
return [nil, nil] if start_pos < 0
|
|
127
|
+
|
|
128
|
+
# Handle exclusive ranges (most common in Treetop)
|
|
129
|
+
if interval.exclude_end?
|
|
130
|
+
end_pos = [interval.end.to_i - 1, start_pos].max
|
|
131
|
+
else
|
|
132
|
+
end_pos = interval.end.to_i
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Clamp to source bounds
|
|
136
|
+
source_len = source.length
|
|
137
|
+
start_pos = [start_pos, source_len - 1].min if source_len > 0
|
|
138
|
+
end_pos = [end_pos, source_len - 1].min if source_len > 0
|
|
139
|
+
|
|
140
|
+
# Calculate line numbers by counting newlines
|
|
141
|
+
# Line numbers are 1-based
|
|
142
|
+
start_line = source[0...start_pos].count("\n") + 1
|
|
143
|
+
end_line = source[0..end_pos].count("\n") + 1
|
|
144
|
+
|
|
145
|
+
[start_line, end_line]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Record coverage data for a specific line
|
|
149
|
+
# @param coverage [Hash] accumulator
|
|
150
|
+
# @param line [Integer] line number
|
|
151
|
+
# @param tag [Tags::AbstractTag] the tag for this node
|
|
152
|
+
def record_line_coverage(coverage, line, tag)
|
|
153
|
+
coverage[line] ||= {
|
|
154
|
+
covered: nil, # nil = no block/loop tags yet, will be determined by branches if any
|
|
155
|
+
has_block_or_loop: false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case tag.type
|
|
159
|
+
when :block
|
|
160
|
+
# Block tags affect line coverage - line is covered if block was executed
|
|
161
|
+
coverage[line][:has_block_or_loop] = true
|
|
162
|
+
if coverage[line][:covered].nil?
|
|
163
|
+
coverage[line][:covered] = tag.complete?
|
|
164
|
+
else
|
|
165
|
+
coverage[line][:covered] = coverage[line][:covered] && tag.complete?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
when :loop
|
|
169
|
+
# A loop is covered if it was executed at least once (any iteration pattern)
|
|
170
|
+
coverage[line][:has_block_or_loop] = true
|
|
171
|
+
loop_executed = loop_was_executed?(tag)
|
|
172
|
+
if coverage[line][:covered].nil?
|
|
173
|
+
coverage[line][:covered] = loop_executed
|
|
174
|
+
else
|
|
175
|
+
coverage[line][:covered] = coverage[line][:covered] && loop_executed
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
when :branch
|
|
179
|
+
# For lines with only branches, mark as covered if at least one branch was taken
|
|
180
|
+
unless coverage[line][:has_block_or_loop]
|
|
181
|
+
if tag.is_a?(Tags::ConditionalBranchTag)
|
|
182
|
+
branch_taken = tag.true || tag.false
|
|
183
|
+
if coverage[line][:covered].nil?
|
|
184
|
+
coverage[line][:covered] = branch_taken
|
|
185
|
+
else
|
|
186
|
+
coverage[line][:covered] = coverage[line][:covered] || branch_taken
|
|
187
|
+
end
|
|
188
|
+
else
|
|
189
|
+
# Unconditional branches (return, exit, etc.)
|
|
190
|
+
if coverage[line][:covered].nil?
|
|
191
|
+
coverage[line][:covered] = tag.complete?
|
|
192
|
+
else
|
|
193
|
+
coverage[line][:covered] = coverage[line][:covered] || tag.complete?
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Ensure covered has a boolean value (default to false if still nil)
|
|
200
|
+
coverage[line][:covered] = false if coverage[line][:covered].nil?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Check if a loop tag indicates the loop was executed at least once
|
|
204
|
+
# For Sonar, partial loop coverage counts as covered
|
|
205
|
+
# @param tag [Tags::AbstractLoopTag] the loop tag
|
|
206
|
+
# @return [Boolean] true if loop was executed (any iteration pattern)
|
|
207
|
+
def loop_was_executed?(tag)
|
|
208
|
+
tag.pass || tag.once || tag.twice || tag.ends
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
end
|
|
213
|
+
end
|
data/lib/piggly/compiler.rb
CHANGED
data/lib/piggly/reporter/base.rb
CHANGED
|
@@ -31,13 +31,15 @@ module Piggly
|
|
|
31
31
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
|
-
def aggregate(label, summary)
|
|
34
|
+
def aggregate(label, summary, line_summary = nil)
|
|
35
35
|
tag :p, label, :class => "summary"
|
|
36
36
|
tag :table, :class => "summary" do
|
|
37
37
|
tag :tr do
|
|
38
|
+
tag :th, "Lines"
|
|
38
39
|
tag :th, "Blocks"
|
|
39
40
|
tag :th, "Loops"
|
|
40
41
|
tag :th, "Branches"
|
|
42
|
+
tag :th, "Line Coverage"
|
|
41
43
|
tag :th, "Block Coverage"
|
|
42
44
|
tag :th, "Loop Coverage"
|
|
43
45
|
tag :th, "Branch Coverage"
|
|
@@ -49,13 +51,27 @@ module Piggly
|
|
|
49
51
|
tag(:td, :class => "count") { tag :span, -1 }
|
|
50
52
|
tag(:td, :class => "count") { tag :span, -1 }
|
|
51
53
|
tag(:td, :class => "count") { tag :span, -1 }
|
|
54
|
+
tag(:td, :class => "count") { tag :span, -1 }
|
|
55
|
+
tag(:td, :class => "pct") { tag :span, -1 }
|
|
52
56
|
tag(:td, :class => "pct") { tag :span, -1 }
|
|
53
57
|
tag(:td, :class => "pct") { tag :span, -1 }
|
|
54
58
|
tag(:td, :class => "pct") { tag :span, -1 }
|
|
55
59
|
else
|
|
60
|
+
# Line coverage (from LineCoverage module)
|
|
61
|
+
if line_summary
|
|
62
|
+
tag(:td, (line_summary[:count] || 0), :class => "count")
|
|
63
|
+
else
|
|
64
|
+
tag(:td, :class => "count") { tag :span, -1 }
|
|
65
|
+
end
|
|
56
66
|
tag(:td, (summary[:block][:count] || 0), :class => "count")
|
|
57
67
|
tag(:td, (summary[:loop][:count] || 0), :class => "count")
|
|
58
68
|
tag(:td, (summary[:branch][:count] || 0), :class => "count")
|
|
69
|
+
# Line coverage percentage
|
|
70
|
+
if line_summary
|
|
71
|
+
tag(:td, :class => "pct") { percent(line_summary[:percent]) }
|
|
72
|
+
else
|
|
73
|
+
tag(:td, :class => "pct") { tag :span, -1 }
|
|
74
|
+
end
|
|
59
75
|
tag(:td, :class => "pct") { percent(summary[:block][:percent]) }
|
|
60
76
|
tag(:td, :class => "pct") { percent(summary[:loop][:percent]) }
|
|
61
77
|
tag(:td, :class => "pct") { percent(summary[:branch][:percent]) }
|
|
@@ -5,11 +5,16 @@ module Piggly
|
|
|
5
5
|
|
|
6
6
|
def initialize(config, profile)
|
|
7
7
|
@config, @profile = config, profile
|
|
8
|
+
@line_coverage = Compiler::LineCoverage.new(config)
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def report(procedures, index)
|
|
11
12
|
io = File.open("#{report_path}/index.html", "w")
|
|
12
13
|
|
|
14
|
+
# Calculate line coverage for all procedures
|
|
15
|
+
all_line_coverage = calculate_all_line_coverage(procedures)
|
|
16
|
+
overall_line_summary = @line_coverage.summary(all_line_coverage)
|
|
17
|
+
|
|
13
18
|
html(io) do
|
|
14
19
|
tag :html do
|
|
15
20
|
tag :head do
|
|
@@ -20,7 +25,7 @@ module Piggly
|
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
tag :body do
|
|
23
|
-
aggregate("PL/pgSQL Coverage Summary", @profile.summary)
|
|
28
|
+
aggregate("PL/pgSQL Coverage Summary", @profile.summary, overall_line_summary)
|
|
24
29
|
table(procedures.sort_by{|p| index.label(p) }, index)
|
|
25
30
|
timestamp
|
|
26
31
|
end
|
|
@@ -32,14 +37,38 @@ module Piggly
|
|
|
32
37
|
|
|
33
38
|
private
|
|
34
39
|
|
|
40
|
+
# Calculate combined line coverage summary for all procedures
|
|
41
|
+
# Returns aggregated coverage data that can be passed to @line_coverage.summary()
|
|
42
|
+
def calculate_all_line_coverage(procedures)
|
|
43
|
+
all_coverage = {}
|
|
44
|
+
next_key = 1 # Use sequential keys to avoid collisions
|
|
45
|
+
|
|
46
|
+
procedures.each do |procedure|
|
|
47
|
+
begin
|
|
48
|
+
coverage = @line_coverage.calculate(procedure, @profile)
|
|
49
|
+
# Add each line's coverage data with a unique key
|
|
50
|
+
# Keys don't need to represent actual line numbers for summary calculation
|
|
51
|
+
coverage.each do |_line, data|
|
|
52
|
+
all_coverage[next_key] = data
|
|
53
|
+
next_key += 1
|
|
54
|
+
end
|
|
55
|
+
rescue => e
|
|
56
|
+
$stderr.puts "Index: ERROR calculating coverage for #{procedure.name}: #{e.class}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
all_coverage
|
|
60
|
+
end
|
|
61
|
+
|
|
35
62
|
def table(procedures, index)
|
|
36
63
|
tag :div, :class => "table-wrapper" do
|
|
37
64
|
tag :table, :class => "summary sortable" do
|
|
38
65
|
tag :tr do
|
|
39
66
|
tag :th, "Procedure"
|
|
67
|
+
tag :th, "Lines"
|
|
40
68
|
tag :th, "Blocks"
|
|
41
69
|
tag :th, "Loops"
|
|
42
70
|
tag :th, "Branches"
|
|
71
|
+
tag :th, "Line Coverage"
|
|
43
72
|
tag :th, "Block Coverage"
|
|
44
73
|
tag :th, "Loop Coverage"
|
|
45
74
|
tag :th, "Branch Coverage"
|
|
@@ -50,6 +79,15 @@ module Piggly
|
|
|
50
79
|
row = k.modulo(2) == 0 ? "even" : "odd"
|
|
51
80
|
label = index.label(procedure)
|
|
52
81
|
|
|
82
|
+
# Calculate line coverage for this procedure
|
|
83
|
+
line_summary = nil
|
|
84
|
+
begin
|
|
85
|
+
coverage = @line_coverage.calculate(procedure, @profile)
|
|
86
|
+
line_summary = @line_coverage.summary(coverage)
|
|
87
|
+
rescue => e
|
|
88
|
+
# Skip if can't calculate
|
|
89
|
+
end
|
|
90
|
+
|
|
53
91
|
tag :tr, :class => row do
|
|
54
92
|
unless summary.include?(:block) or summary.include?(:loop) or summary.include?(:branch)
|
|
55
93
|
# Parser couldn't parse this file
|
|
@@ -57,14 +95,18 @@ module Piggly
|
|
|
57
95
|
tag(:td, :class => "count") { tag :span, -1 }
|
|
58
96
|
tag(:td, :class => "count") { tag :span, -1 }
|
|
59
97
|
tag(:td, :class => "count") { tag :span, -1 }
|
|
98
|
+
tag(:td, :class => "count") { tag :span, -1 }
|
|
99
|
+
tag(:td, :class => "pct") { tag :span, -1 }
|
|
60
100
|
tag(:td, :class => "pct") { tag :span, -1 }
|
|
61
101
|
tag(:td, :class => "pct") { tag :span, -1 }
|
|
62
102
|
tag(:td, :class => "pct") { tag :span, -1 }
|
|
63
103
|
else
|
|
64
104
|
tag(:td, :class => "file") { tag :a, label, :href => procedure.identifier + ".html" }
|
|
105
|
+
tag :td, (line_summary ? line_summary[:count] : 0), :class => "count"
|
|
65
106
|
tag :td, (summary[:block][:count] || 0), :class => "count"
|
|
66
107
|
tag :td, (summary[:loop][:count] || 0), :class => "count"
|
|
67
108
|
tag :td, (summary[:branch][:count] || 0), :class => "count"
|
|
109
|
+
tag(:td, :class => "pct") { percent(line_summary ? line_summary[:percent] : nil) }
|
|
68
110
|
tag(:td, :class => "pct") { percent(summary[:block][:percent]) }
|
|
69
111
|
tag(:td, :class => "pct") { percent(summary[:loop][:percent]) }
|
|
70
112
|
tag(:td, :class => "pct") { percent(summary[:branch][:percent]) }
|
|
@@ -5,6 +5,7 @@ module Piggly
|
|
|
5
5
|
|
|
6
6
|
def initialize(config, profile)
|
|
7
7
|
@config, @profile = config, profile
|
|
8
|
+
@line_coverage = Compiler::LineCoverage.new(config)
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def report(procedure)
|
|
@@ -14,6 +15,15 @@ module Piggly
|
|
|
14
15
|
compiler = Compiler::CoverageReport.new(@config)
|
|
15
16
|
data = compiler.compile(procedure, @profile)
|
|
16
17
|
|
|
18
|
+
# Calculate line coverage for this procedure
|
|
19
|
+
line_summary = nil
|
|
20
|
+
begin
|
|
21
|
+
coverage = @line_coverage.calculate(procedure, @profile)
|
|
22
|
+
line_summary = @line_coverage.summary(coverage)
|
|
23
|
+
rescue => e
|
|
24
|
+
# Skip if can't calculate
|
|
25
|
+
end
|
|
26
|
+
|
|
17
27
|
html(io) do
|
|
18
28
|
tag :html, :xmlns => "http://www.w3.org/1999/xhtml" do
|
|
19
29
|
tag :head do
|
|
@@ -25,7 +35,7 @@ module Piggly
|
|
|
25
35
|
|
|
26
36
|
tag :body do
|
|
27
37
|
tag :div, :class => "header" do
|
|
28
|
-
aggregate(procedure.name, @profile.summary(procedure))
|
|
38
|
+
aggregate(procedure.name, @profile.summary(procedure), line_summary)
|
|
29
39
|
end
|
|
30
40
|
|
|
31
41
|
tag :div, :class => "container" do
|
|
@@ -421,15 +421,15 @@ table.full td.covered {
|
|
|
421
421
|
width: 100% !important;
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
/* SYNTAX HIGHLIGHTING -
|
|
425
|
-
.tI { color:
|
|
426
|
-
.tD { color:
|
|
427
|
-
.tK { color:
|
|
428
|
-
.tC { color:
|
|
429
|
-
.tQ { color:
|
|
430
|
-
.tS { color:
|
|
431
|
-
.tL { color:
|
|
432
|
-
.tM { color:
|
|
424
|
+
/* SYNTAX HIGHLIGHTING - Inherit coverage colors, except comments */
|
|
425
|
+
.tI { color: inherit; } /* identifier */
|
|
426
|
+
.tD { color: inherit; font-style: italic; } /* data type */
|
|
427
|
+
.tK { color: inherit; font-weight: 500; } /* keyword */
|
|
428
|
+
.tC { color: var(--color-text); font-style: italic; } /* comment */
|
|
429
|
+
.tQ { color: inherit; font-style: italic; } /* sql statement */
|
|
430
|
+
.tS { color: inherit; } /* string literal */
|
|
431
|
+
.tL { color: inherit; font-style: italic; } /* label */
|
|
432
|
+
.tM { color: inherit; } /* dollar quote marker */
|
|
433
433
|
|
|
434
434
|
/* CODE BLOCKS */
|
|
435
435
|
.b {
|
|
@@ -460,18 +460,23 @@ table.full td.covered {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
.c1 {
|
|
463
|
-
color: var(--color-
|
|
463
|
+
color: var(--color-success);
|
|
464
464
|
}
|
|
465
465
|
|
|
466
466
|
/* LOOP COVERAGE */
|
|
467
|
-
.l0000
|
|
467
|
+
.l0000 {
|
|
468
|
+
font-weight: 600;
|
|
469
|
+
color: var(--color-danger); /* red - never evaluated */
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.l0001, .l0010, .l0100, .l0011, .l0101, .l0110, .l0111,
|
|
468
473
|
.l1000, .l1001, .l1010, .l1100, .l1011, .l1101, .l1110 {
|
|
469
474
|
font-weight: 600;
|
|
470
|
-
color: var(--color-warning);
|
|
475
|
+
color: var(--color-warning); /* orange - partial coverage */
|
|
471
476
|
}
|
|
472
477
|
|
|
473
478
|
.l1111 {
|
|
474
|
-
color: var(--color-
|
|
479
|
+
color: var(--color-success); /* green - full coverage */
|
|
475
480
|
}
|
|
476
481
|
|
|
477
482
|
/* BRANCH DECISIONS */
|
|
@@ -480,18 +485,14 @@ table.full td.covered {
|
|
|
480
485
|
color: var(--color-danger);
|
|
481
486
|
}
|
|
482
487
|
|
|
483
|
-
.b01
|
|
484
|
-
font-weight: 600;
|
|
485
|
-
color: #10b981;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
+
.b01,
|
|
488
489
|
.b10 {
|
|
489
490
|
font-weight: 600;
|
|
490
|
-
color:
|
|
491
|
+
color: var(--color-warning);
|
|
491
492
|
}
|
|
492
493
|
|
|
493
494
|
.b11 {
|
|
494
|
-
color: var(--color-
|
|
495
|
+
color: var(--color-success);
|
|
495
496
|
}
|
|
496
497
|
|
|
497
498
|
/* SCROLLBAR STYLING (for webkit browsers) */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Piggly
|
|
2
|
+
module Reporter
|
|
3
|
+
|
|
4
|
+
#
|
|
5
|
+
# Generates SonarQube generic test coverage XML format.
|
|
6
|
+
#
|
|
7
|
+
# Format specification:
|
|
8
|
+
# https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/test-coverage/generic-test-data/
|
|
9
|
+
#
|
|
10
|
+
class Sonar < Base
|
|
11
|
+
|
|
12
|
+
def initialize(config, profile, output_path = nil)
|
|
13
|
+
@config = config
|
|
14
|
+
@profile = profile
|
|
15
|
+
@output_path = output_path || File.join(@config.report_root, "sonar-coverage.xml")
|
|
16
|
+
@line_coverage = Compiler::LineCoverage.new(config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Generate Sonar coverage report for all procedures
|
|
20
|
+
# @param procedures [Array<Dumper::ReifiedProcedure>] list of procedures
|
|
21
|
+
def report(procedures)
|
|
22
|
+
FileUtils.makedirs(File.dirname(@output_path))
|
|
23
|
+
|
|
24
|
+
File.open(@output_path, "w:UTF-8") do |io|
|
|
25
|
+
io.puts '<?xml version="1.0" encoding="UTF-8"?>'
|
|
26
|
+
io.puts '<coverage version="1">'
|
|
27
|
+
|
|
28
|
+
procedures.each do |procedure|
|
|
29
|
+
begin
|
|
30
|
+
write_procedure_coverage(io, procedure)
|
|
31
|
+
rescue => e
|
|
32
|
+
# Skip procedures that can't be processed
|
|
33
|
+
$stderr.puts "Warning: Could not generate Sonar coverage for #{procedure.name}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
io.puts '</coverage>'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@output_path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Write coverage data for a single procedure
|
|
46
|
+
def write_procedure_coverage(io, procedure)
|
|
47
|
+
coverage = @line_coverage.calculate(procedure, @profile)
|
|
48
|
+
return if coverage.empty?
|
|
49
|
+
|
|
50
|
+
# Get the source file path and convert to relative path
|
|
51
|
+
absolute_path = procedure.source_path(@config)
|
|
52
|
+
source_path = make_relative_path(absolute_path)
|
|
53
|
+
|
|
54
|
+
io.puts " <file path=\"#{escape_xml(source_path)}\">"
|
|
55
|
+
|
|
56
|
+
# Sort lines and output coverage data
|
|
57
|
+
coverage.keys.sort.each do |line|
|
|
58
|
+
line_data = coverage[line]
|
|
59
|
+
write_line_coverage(io, line, line_data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
io.puts " </file>"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Write coverage data for a single line
|
|
66
|
+
def write_line_coverage(io, line_number, line_data)
|
|
67
|
+
attrs = []
|
|
68
|
+
attrs << "lineNumber=\"#{line_number}\""
|
|
69
|
+
attrs << "covered=\"#{line_data[:covered]}\""
|
|
70
|
+
|
|
71
|
+
io.puts " <lineToCover #{attrs.join(' ')}/>"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Escape special XML characters
|
|
75
|
+
def escape_xml(text)
|
|
76
|
+
text.to_s
|
|
77
|
+
.gsub("&", "&")
|
|
78
|
+
.gsub("<", "<")
|
|
79
|
+
.gsub(">", ">")
|
|
80
|
+
.gsub("\"", """)
|
|
81
|
+
.gsub("'", "'")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Convert absolute path to relative path from project root for cross-platform compatibility
|
|
85
|
+
def make_relative_path(absolute_path)
|
|
86
|
+
project_root = File.dirname(File.dirname(@config.cache_root))
|
|
87
|
+
|
|
88
|
+
relative = absolute_path.sub(/^#{Regexp.escape(project_root)}[\/\\]?/, '')
|
|
89
|
+
relative.gsub('\\', '/')
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/piggly/reporter.rb
CHANGED
data/lib/piggly/tags.rb
CHANGED
data/lib/piggly/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: piggly-nsd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.3.
|
|
4
|
+
version: 2.3.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kvle Putnam
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- lib/piggly/compiler.rb
|
|
57
57
|
- lib/piggly/compiler/cache_dir.rb
|
|
58
58
|
- lib/piggly/compiler/coverage_report.rb
|
|
59
|
+
- lib/piggly/compiler/line_coverage.rb
|
|
59
60
|
- lib/piggly/compiler/trace_compiler.rb
|
|
60
61
|
- lib/piggly/config.rb
|
|
61
62
|
- lib/piggly/dumper.rb
|
|
@@ -79,6 +80,7 @@ files:
|
|
|
79
80
|
- lib/piggly/reporter/resources/highlight.js
|
|
80
81
|
- lib/piggly/reporter/resources/piggly.css
|
|
81
82
|
- lib/piggly/reporter/resources/sortable.js
|
|
83
|
+
- lib/piggly/reporter/sonar.rb
|
|
82
84
|
- lib/piggly/tags.rb
|
|
83
85
|
- lib/piggly/task.rb
|
|
84
86
|
- lib/piggly/util.rb
|