piggly-nsd 2.3.3 → 2.3.5
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 +30 -4
- data/lib/piggly/command/report.rb +58 -6
- data/lib/piggly/compiler/coverage_report.rb +1 -1
- data/lib/piggly/compiler/line_coverage.rb +211 -0
- data/lib/piggly/compiler.rb +1 -0
- data/lib/piggly/parser/grammar.tt +760 -748
- 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/schema_csv.rb +98 -0
- data/lib/piggly/reporter/sonar.rb +99 -0
- data/lib/piggly/reporter.rb +2 -0
- data/lib/piggly/tags.rb +1 -1
- data/lib/piggly/task.rb +4 -1
- data/lib/piggly/util/line_numbers.rb +25 -0
- data/lib/piggly/util.rb +1 -0
- data/lib/piggly/version.rb +2 -2
- data/spec/examples/grammar/statements/loop_spec.rb +9 -0
- data/spec/examples/grammar/statements/sql_spec.rb +14 -0
- data/spec/examples/reporter/schema_csv_spec.rb +72 -0
- data/spec/examples/util/line_numbers_spec.rb +59 -0
- data/spec/issues/037_spec.rb +30 -0
- data/spec/issues/case_in_condition_spec.rb +50 -0
- data/spec/issues/commit_spec.rb +34 -0
- data/spec/spec_helper.rb +1 -1
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 736495bc6e9ad8785b755afb2b3a834cd0dff2e62983b0397e4d6fdf6f0fe3af
|
|
4
|
+
data.tar.gz: 958489e0a1a542230ad57dc8748ef4a231e9c03cea1452c34246887e9789746f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba81b385db041ea25c62a0777838d84ed89a165e489d6d4ef3f60ea908902acad3ba2a186e3eb7325a7520f407eead2ae3e6121fd8f4429664a3a9f00a848b01
|
|
7
|
+
data.tar.gz: e076e78ce56f6e8e7ed238f7fa354dfe9799b2707520394e5635162f8029359c581bf5d055b53efd0c0739b58410c59ee5cb9e94c08b7dd600ecf64f478fb7d4
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
[](https://github.com/NSDDeveloper/piggly/actions/workflows/ci.yml)
|
|
2
2
|
# Piggly-NSD
|
|
3
3
|
|
|
4
4
|
Code coverage reports for PostgreSQL PL/pgSQL stored procedures
|
|
@@ -51,12 +51,16 @@ 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
|
|
|
57
61
|
To install the latest from github:
|
|
58
62
|
|
|
59
|
-
$ git clone https://github.com/
|
|
63
|
+
$ git clone https://github.com/NSDDeveloper/piggly.git
|
|
60
64
|
$ cd piggly
|
|
61
65
|
$ bundle install
|
|
62
66
|
$ bundle exec rake spec
|
|
@@ -122,11 +126,33 @@ 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
|
+
|
|
142
|
+
### Schema CSV Report
|
|
143
|
+
|
|
144
|
+
To generate a schema-level CSV report, use the `--schema-csv-path` option:
|
|
145
|
+
|
|
146
|
+
$ piggly report -f messages.txt --schema-csv-path piggly/reports/coverage_by_schema.csv
|
|
147
|
+
|
|
148
|
+
The CSV report groups procedures by schema and contains the following columns:
|
|
149
|
+
|
|
150
|
+
- `No`
|
|
151
|
+
- `Schema Name`
|
|
152
|
+
- `Objects Count`
|
|
153
|
+
- `Covered Objects`
|
|
154
|
+
- `Line Coverage Percent`
|
|
155
|
+
|
|
130
156
|
## Running the Examples
|
|
131
157
|
|
|
132
158
|
$ cd piggly
|
|
@@ -167,4 +193,4 @@ Once the report is built you can open it in `piggly/reports/index.html`.
|
|
|
167
193
|
|
|
168
194
|
## Bugs & Issues
|
|
169
195
|
|
|
170
|
-
Please report any issues or feature requests on the [github tracker](https://github.com/
|
|
196
|
+
Please report any issues or feature requests on the [github tracker](https://github.com/NSDDeveloper/piggly/issues).
|
|
@@ -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, schema_csv_path, html_report_requested = configure(argv)
|
|
17
17
|
|
|
18
18
|
profile = Profile.new
|
|
19
19
|
index = Dumper::Index.new(config)
|
|
@@ -33,12 +33,28 @@ 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
|
+
# Generate schema-level CSV report if requested
|
|
51
|
+
if schema_csv_path
|
|
52
|
+
create_schema_csv_report(config, procedures, profile, schema_csv_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
unless html_report_requested || sonar_path || schema_csv_path
|
|
56
|
+
puts "Warning: No report output specified. Use -o for HTML reports, -x for Sonar report, or --schema-csv-path for CSV report."
|
|
57
|
+
end
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
# Adds the given procedures to Profile
|
|
@@ -118,14 +134,45 @@ module Piggly
|
|
|
118
134
|
queue.execute
|
|
119
135
|
end
|
|
120
136
|
|
|
137
|
+
# Create Sonar coverage XML report
|
|
138
|
+
#
|
|
139
|
+
def create_sonar_report(config, procedures, profile, output_path)
|
|
140
|
+
puts "creating Sonar coverage report"
|
|
141
|
+
reporter = Reporter::Sonar.new(config, profile, output_path)
|
|
142
|
+
path = reporter.report(procedures)
|
|
143
|
+
puts "Sonar coverage report written to: #{path}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Create schema-level CSV coverage report
|
|
147
|
+
#
|
|
148
|
+
def create_schema_csv_report(config, procedures, profile, output_path)
|
|
149
|
+
puts "creating schema CSV coverage report"
|
|
150
|
+
reporter = Reporter::SchemaCsv.new(config, profile, output_path)
|
|
151
|
+
path = reporter.report(procedures)
|
|
152
|
+
puts "Schema CSV coverage report written to: #{path}"
|
|
153
|
+
end
|
|
154
|
+
|
|
121
155
|
def configure(argv, config = Config.new)
|
|
122
156
|
io = $stdin
|
|
157
|
+
sonar_path = nil
|
|
158
|
+
schema_csv_path = nil
|
|
159
|
+
html_report_requested = false
|
|
160
|
+
|
|
123
161
|
p = OptionParser.new do |o|
|
|
124
162
|
o.on("-t", "--dry-run", "only print the names of matching procedures", &o_dry_run(config))
|
|
125
163
|
o.on("-s", "--select PATTERN", "select procedures matching PATTERN", &o_select(config))
|
|
126
164
|
o.on("-r", "--reject PATTERN", "ignore procedures matching PATTERN", &o_reject(config))
|
|
127
165
|
o.on("-c", "--cache-root PATH", "local cache directory", &o_cache_root(config))
|
|
128
|
-
o.on("-o", "--report-root PATH", "report output directory"
|
|
166
|
+
o.on("-o", "--report-root PATH", "report output directory") do |path|
|
|
167
|
+
config.report_root = path
|
|
168
|
+
html_report_requested = true
|
|
169
|
+
end
|
|
170
|
+
o.on("-x", "--sonar-report-path PATH", "generate Sonar coverage XML report at PATH") do |path|
|
|
171
|
+
sonar_path = path
|
|
172
|
+
end
|
|
173
|
+
o.on("--schema-csv-path PATH", "generate schema-level CSV coverage report at PATH") do |path|
|
|
174
|
+
schema_csv_path = path
|
|
175
|
+
end
|
|
129
176
|
o.on("-a", "--accumulate", "accumulate data from the previous run", &o_accumulate(config))
|
|
130
177
|
o.on("-V", "--version", "show version", &o_version(config))
|
|
131
178
|
o.on("-h", "--help", "show this message") { abort o.to_s }
|
|
@@ -140,13 +187,18 @@ module Piggly
|
|
|
140
187
|
|
|
141
188
|
begin
|
|
142
189
|
p.parse! argv
|
|
190
|
+
|
|
191
|
+
unless html_report_requested || sonar_path || schema_csv_path
|
|
192
|
+
raise OptionParser::MissingArgument,
|
|
193
|
+
"at least one report type required: use -o for HTML reports, -x for Sonar report, or --schema-csv-path for CSV report"
|
|
194
|
+
end
|
|
143
195
|
|
|
144
196
|
if io.eql?($stdin) and $stdin.tty?
|
|
145
197
|
raise OptionParser::MissingArgument,
|
|
146
198
|
"stdin must be a pipe, or use --input PATH"
|
|
147
199
|
end
|
|
148
200
|
|
|
149
|
-
return io, config
|
|
201
|
+
return io, config, sonar_path, schema_csv_path, html_report_requested
|
|
150
202
|
rescue OptionParser::InvalidOption,
|
|
151
203
|
OptionParser::InvalidArgument,
|
|
152
204
|
OptionParser::MissingArgument
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
max_line = Util::LineNumbers.count(source)
|
|
96
|
+
return true if line_number < 1 || line_number > max_line
|
|
97
|
+
|
|
98
|
+
line_content = source.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
|
+
start_line = Util::LineNumbers.at_offset(source, start_pos)
|
|
141
|
+
end_line = Util::LineNumbers.at_offset(source, end_pos + 1)
|
|
142
|
+
|
|
143
|
+
[start_line, end_line]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Record coverage data for a specific line
|
|
147
|
+
# @param coverage [Hash] accumulator
|
|
148
|
+
# @param line [Integer] line number
|
|
149
|
+
# @param tag [Tags::AbstractTag] the tag for this node
|
|
150
|
+
def record_line_coverage(coverage, line, tag)
|
|
151
|
+
coverage[line] ||= {
|
|
152
|
+
covered: nil, # nil = no block/loop tags yet, will be determined by branches if any
|
|
153
|
+
has_block_or_loop: false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case tag.type
|
|
157
|
+
when :block
|
|
158
|
+
# Block tags affect line coverage - line is covered if block was executed
|
|
159
|
+
coverage[line][:has_block_or_loop] = true
|
|
160
|
+
if coverage[line][:covered].nil?
|
|
161
|
+
coverage[line][:covered] = tag.complete?
|
|
162
|
+
else
|
|
163
|
+
coverage[line][:covered] = coverage[line][:covered] && tag.complete?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
when :loop
|
|
167
|
+
# A loop is covered if it was executed at least once (any iteration pattern)
|
|
168
|
+
coverage[line][:has_block_or_loop] = true
|
|
169
|
+
loop_executed = loop_was_executed?(tag)
|
|
170
|
+
if coverage[line][:covered].nil?
|
|
171
|
+
coverage[line][:covered] = loop_executed
|
|
172
|
+
else
|
|
173
|
+
coverage[line][:covered] = coverage[line][:covered] && loop_executed
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
when :branch
|
|
177
|
+
# For lines with only branches, mark as covered if at least one branch was taken
|
|
178
|
+
unless coverage[line][:has_block_or_loop]
|
|
179
|
+
if tag.is_a?(Tags::ConditionalBranchTag)
|
|
180
|
+
branch_taken = tag.true || tag.false
|
|
181
|
+
if coverage[line][:covered].nil?
|
|
182
|
+
coverage[line][:covered] = branch_taken
|
|
183
|
+
else
|
|
184
|
+
coverage[line][:covered] = coverage[line][:covered] || branch_taken
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
# Unconditional branches (return, exit, etc.)
|
|
188
|
+
if coverage[line][:covered].nil?
|
|
189
|
+
coverage[line][:covered] = tag.complete?
|
|
190
|
+
else
|
|
191
|
+
coverage[line][:covered] = coverage[line][:covered] || tag.complete?
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Ensure covered has a boolean value (default to false if still nil)
|
|
198
|
+
coverage[line][:covered] = false if coverage[line][:covered].nil?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Check if a loop tag indicates the loop was executed at least once
|
|
202
|
+
# For Sonar, partial loop coverage counts as covered
|
|
203
|
+
# @param tag [Tags::AbstractLoopTag] the loop tag
|
|
204
|
+
# @return [Boolean] true if loop was executed (any iteration pattern)
|
|
205
|
+
def loop_was_executed?(tag)
|
|
206
|
+
tag.pass || tag.once || tag.twice || tag.ends
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
end
|
|
211
|
+
end
|
data/lib/piggly/compiler.rb
CHANGED