Skip to content

Commit 4cd0c2b

Browse files
mkarleskyclaude
andcommitted
Gcov plugin: version-gating/arg-building refactor, bug fixes, unit tests
Code review of plugins/gcov/lib/ against its own docs and against gcovr's and ReportGenerator's real CLI/version history turned up several confirmed bugs and a DRY opportunity spanning the two report-generation backends. Bug fixes (each verified against real tools, not just documentation): - :gcov ↳ :gcovr ↳ :decisions version floor corrected 6.0 -> 5.1 -- gcovr's own changelog and a real gcovr 5.1/5.0 pip install confirm --decisions was introduced in 5.1, not 6.0 as the recent #1080 fix stated. - :fail_under_* now rejects 0 (documented range is 1-100; 0 was silently accepted). - :object_directory/:source_encoding values are now quoted, matching every other string-valued gcovr option -- an unquoted value containing a space previously broke the constructed command line. - Config-driven values (test/mock prefixes, build root) are now Regexp.escape'd before being spliced into exclusion-pattern regexes, in both gcovr_reportinator.rb and reportgenerator_reportinator.rb. - :mcdc's GCC-version check moved out of Gcov#setup(), which runs for every build the plugin is merely enabled for, into a lazily-invoked, memoized check that only fires on a real gcov:-context compile/link -- consistent with this same file's existing lazy-validation rationale for the gcovr/ ReportGenerator Reportinators themselves. - gcovr_exec_exception? now reports every simultaneously violated :fail_under_* threshold instead of only the first. - Added the missing .dup guard in ReportGeneratorReportinator#collect_reportgenerator_opts, mirroring the existing guard on the gcovr side. - gcovr 7.0+ deprecated flag migration: :branches/:sort_uncovered/ :sort_percentage now use --txt-metric branch/--sort uncovered-number/ --sort uncovered-percent at gcovr 7.0+, silencing gcovr's own deprecation warnings; the pre-7.0 names are still used automatically below that. One audit finding was investigated, disproven, and reverted rather than shipped: ReportGenerator's `settings:` arguments were flagged as missing a leading '-' to match every sibling option. A real reportgenerator run proved the opposite -- a leading '-' makes it log "Unknown command line parameter 'settings'" and the setting never applies; the original dash-less form is correct and unchanged. Refactor: two generic, shared utilities on GcovReportinator, replacing repeated hand-written version-gate/argument-string boilerplate with one declarative table per reportinator -- - ToolVersionGating (gcov_types.rb): generalizes the gcovr-only GcovToolVersion struct and min_version? into a reusable ToolVersion + detect_tool_version/enforce_version_gates!, used by GcovrReportinator's own gcovr-version gates and by Gcov's GCC-version gate alike. detect_tool_version is also the one seam a unit test now stubs directly, instead of faking a full tool_executor.build_command_line/.exec/regex-parse round-trip. - build_args_from_table (gcov_reportinator.rb): a declarative option-symbol -> CLI-flag table drives both gcovr's args_builder_common and ReportGenerator's build_optional_args, replacing ad hoc string concatenation with plain, readable data (including handling for a version-dependent flag name, via a Proc, for the deprecated-flag migration above). - build_custom_args lifted to the shared base class (was byte-identical in both reportinators). Unit tests: net-new coverage for a plugin that had none before -- only real tools -- gcov/gcovr/reportgenerator -- are ever exercised via system tests; `@tool_executor.exec` is always stubbed here. - spec/units/plugins/gcov_reportinator_spec.rb: the shared base-class utilities. - spec/units/plugins/gcovr_reportinator_spec.rb: version gating, args_builder_common, config-file exclusion warning, exclusion-regex escaping, multi-violation exec-exception reporting. - spec/units/plugins/reportgenerator_reportinator_spec.rb: build_optional_args (including a regression test locking in the settings: dash-less form above), the .dup mutation guard, exclusion-regex escaping. - spec/units/plugins/gcov_spec.rb: the lazy, memoized :mcdc/GCC-version check. Verified: full unit suite (2991 examples, 0 failures), mkdocs build --strict, and the full Gcov system-test suite (26 examples, 0 failures, 2 pending -- gdb unavailable, unrelated) run against real gcc/gcovr/reportgenerator via throwtheswitch/madsciencelab-plugins Docker. Changelog entries intentionally withheld from this commit -- reserved for a follow-on documentation-only pass alongside separately-deferred doc corrections, and for splitting between a master-branch 1.1.8 port and next_version's own 1.2.0 entry once CI has run on this branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 04723f8 commit 4cd0c2b

12 files changed

Lines changed: 1075 additions & 245 deletions

docs/mkdocs/plugins/gcov/gcovr.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Report the decision coverage. For HTML, JSON, and the summary report.
131131

132132
Implied automatically whenever `:fail_under_decision` is set.
133133

134-
**Requires:** `gcovr` 6.0 or higher
134+
**Requires:** `gcovr` 5.1 or higher
135135

136136
---
137137

@@ -166,21 +166,25 @@ Select the source file character encoding. (`gcovr --source-encoding`)
166166
### `:branches`
167167

168168
Report the branch coverage instead of the line coverage. Applies to the text
169-
report only. (`gcovr --branches`)
169+
report only. Uses `gcovr --txt-metric branch` on `gcovr` 7.0+ (`--branches` is
170+
deprecated as of 7.0, though still functional); `gcovr --branches` below it.
170171

171172
---
172173

173174
### `:sort_uncovered`
174175

175176
Sort entries by increasing number of uncovered lines. Applies to text and HTML
176-
reports. (`gcovr --sort-uncovered`)
177+
reports. Uses `gcovr --sort uncovered-number` on `gcovr` 7.0+ (`--sort-uncovered`
178+
is deprecated as of 7.0, though still functional); `gcovr --sort-uncovered` below it.
177179

178180
---
179181

180182
### `:sort_percentage`
181183

182184
Sort entries by increasing percentage of uncovered lines. Applies to text and
183-
HTML reports. (`gcovr --sort-percentage`)
185+
HTML reports. Uses `gcovr --sort uncovered-percent` on `gcovr` 7.0+
186+
(`--sort-percentage` is deprecated as of 7.0, though still functional);
187+
`gcovr --sort-percentage` below it.
184188

185189
---
186190

docs/mkdocs/reference/gcov-plugin.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ Report the decision coverage. For HTML, JSON, and the summary report.
230230

231231
Implied automatically whenever `:fail_under_decision` is set.
232232

233-
**Requires:** `gcovr` 6.0 or higher
233+
**Requires:** `gcovr` 5.1 or higher
234234

235235
---
236236

@@ -263,21 +263,26 @@ Source file character encoding. (`gcovr --source-encoding`)
263263
### `:branches`
264264

265265
Report branch coverage instead of line coverage. Applies to text reports only.
266-
(`gcovr --branches`)
266+
Uses `gcovr --txt-metric branch` on `gcovr` 7.0+ (`--branches` is deprecated as
267+
of 7.0, though still functional); `gcovr --branches` below it.
267268

268269
---
269270

270271
### `:sort_uncovered`
271272

272273
Sort report entries by increasing number of uncovered lines. Applies to text
273-
and HTML reports. (`gcovr --sort-uncovered`)
274+
and HTML reports. Uses `gcovr --sort uncovered-number` on `gcovr` 7.0+
275+
(`--sort-uncovered` is deprecated as of 7.0, though still functional);
276+
`gcovr --sort-uncovered` below it.
274277

275278
---
276279

277280
### `:sort_percentage`
278281

279282
Sort report entries by increasing percentage of uncovered lines. Applies to
280-
text and HTML reports. (`gcovr --sort-percentage`)
283+
text and HTML reports. Uses `gcovr --sort uncovered-percent` on `gcovr` 7.0+
284+
(`--sort-percentage` is deprecated as of 7.0, though still functional);
285+
`gcovr --sort-percentage` below it.
281286

282287
---
283288

plugins/gcov/lib/gcov.rb

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,21 @@
1616
require 'reportgenerator_reportinator'
1717

1818
class Gcov < Plugin
19-
19+
20+
include ToolVersionGating
21+
22+
# Checked lazily (see validate_mcdc_gcc_version!, called from the gcov-context build
23+
# hooks below) rather than here in setup(), which runs for every build this plugin is
24+
# merely *enabled* for -- not only real `gcov:` builds. Shelling out to `gcc --version`
25+
# here would make GCC-version validation (and a build-breaking exception on too-old a
26+
# GCC) fire even for builds that never touch :mcdc at all, e.g. plain `test:all`.
27+
MCDC_GCC_VERSION_GATE = {
28+
:mcdc => {
29+
min: [14, 0],
30+
message: ":gcov ↳ :mcdc ➡️ Modified condition/decision coverage requires GCC %{req} or higher (found %{found})"
31+
}
32+
}.freeze
33+
2034
# `Plugin` setup()
2135
def setup
2236
@result_list = []
@@ -80,16 +94,9 @@ def setup
8094

8195
@mutex = Mutex.new()
8296

83-
# Validate MC/DC configuration against GCC version (only incurs gcc --version when :mcdc: TRUE)
84-
if @project_config[:gcov_mcdc]
85-
gcc_version = get_gcc_version()
86-
if gcc_version.major < 14
87-
raise CeedlingException.new(
88-
":gcov ↳ :mcdc ➡️ Modified condition/decision coverage requires GCC 14 or higher " \
89-
"(found #{gcc_version.major}.#{gcc_version.minor})"
90-
)
91-
end
92-
end
97+
# See validate_mcdc_gcc_version! and MCDC_GCC_VERSION_GATE above for why this isn't
98+
# checked eagerly here.
99+
@mcdc_gcc_checked = false
93100
end
94101

95102
# Called within class and also externally by plugin Rakefile
@@ -138,6 +145,8 @@ def process_untested_sources(sources:, guidance: true)
138145
@loginator.log_list( untested_sources.sort, header, Verbosity::COMPLAIN, LogLabels::WARNING )
139146

140147
when GCOV_UNTESTED_SOURCES_COMPILE
148+
validate_mcdc_gcc_version!
149+
141150
msg = 'Processing Untested Sources'
142151
msg = @reportinator.generate_heading( @loginator.decorate( msg, LogLabels::RUN ) )
143152
@loginator.log( msg )
@@ -241,6 +250,8 @@ def pre_test_compile_register(arg_hash)
241250
return unless arg_hash[:context] == GCOV_SYM
242251
return if EXTENSION_ASSEMBLY.match?(arg_hash[:source])
243252

253+
validate_mcdc_gcc_version!
254+
244255
arg_hash[:tool] = TOOLS_GCOV_COMPILER
245256
arg_hash[:flags] += ['-fcondition-coverage'] if @project_config[:gcov_mcdc]
246257

@@ -270,6 +281,8 @@ def pre_compile_execute(arg_hash)
270281
def pre_test_link_register(arg_hash)
271282
return unless arg_hash[:context] == GCOV_SYM
272283

284+
validate_mcdc_gcc_version!
285+
273286
@cli_gcov_task = true
274287
arg_hash[:tool] = TOOLS_GCOV_LINKER
275288
arg_hash[:flags] += ['-fcondition-coverage'] if @project_config[:gcov_mcdc]
@@ -453,23 +466,35 @@ def build_reportinators(config)
453466
return reportinators
454467
end
455468

456-
def get_gcc_version()
457-
command = @tool_executor.build_command_line( TOOLS_GCOV_GCC_VERSION, [])
469+
# Lazily validates :mcdc against the installed GCC version -- called from every
470+
# gcov-context build hook that could otherwise emit -fcondition-coverage (compile,
471+
# untested-source compile, link), memoized so `gcc --version` runs at most once
472+
# regardless of how many source files or hooks fire before it. See MCDC_GCC_VERSION_GATE
473+
# above for why this isn't checked eagerly in setup().
474+
def validate_mcdc_gcc_version!
475+
return if @mcdc_gcc_checked
476+
@mcdc_gcc_checked = true
477+
return unless @project_config[:gcov_mcdc]
478+
479+
enforce_version_gates!(
480+
{ mcdc: @project_config[:gcov_mcdc] },
481+
get_gcc_version(),
482+
MCDC_GCC_VERSION_GATE
483+
)
484+
end
458485

486+
# Get the GCC version number as a ToolVersion struct.
487+
def get_gcc_version()
459488
@loginator.lazy( Verbosity::OBNOXIOUS ) do
460489
@reportinator.generate_progress("Collecting GCC version for conditional feature handling")
461490
end
462491

463-
shell_result = @tool_executor.exec( command )
464-
465492
# First line of gcc --version: "gcc[.exe] (...platform info...) major.minor.patch"
466-
version_match = shell_result[:output].match(/^gcc(?:#{Regexp.escape(EXTENSION_WIN_EXE)})?\s+.*\s+(\d+)\.(\d+)\.\d+/)
467-
468-
if version_match.nil? || version_match[1].nil? || version_match[2].nil?
469-
raise CeedlingException.new("Could not collect `gcc` version from its command line")
470-
end
471-
472-
return GcovToolVersion.new( version_match[1].to_i, version_match[2].to_i )
493+
detect_tool_version(
494+
TOOLS_GCOV_GCC_VERSION,
495+
/^gcc(?:#{Regexp.escape(EXTENSION_WIN_EXE)})?\s+.*\s+(\d+)\.(\d+)\.\d+/,
496+
tool_label: 'gcc'
497+
)
473498
end
474499

475500
end

plugins/gcov/lib/gcov_constants.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
GCOV_GCOVR_ARTIFACTS_FILE_COBERTURA = File.join(GCOV_GCOVR_ARTIFACTS_PATH, "GcovCoverageCobertura.xml")
2929
GCOV_GCOVR_ARTIFACTS_FILE_SONARQUBE = File.join(GCOV_GCOVR_ARTIFACTS_PATH, "GcovCoverageSonarQube.xml")
3030
GCOV_GCOVR_ARTIFACTS_FILE_JSON = File.join(GCOV_GCOVR_ARTIFACTS_PATH, "GcovCoverage.json")
31+
GCOV_GCOVR_DEFAULT_TEXT_ARTIFACT_FILENAME = "coverage.txt".freeze
3132

3233
# Report Creation Utilities
3334
GCOV_UTILITY_NAME_GCOVR = "gcovr"

plugins/gcov/lib/gcov_reportinator.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
# SPDX-License-Identifier: MIT
66
# =========================================================================
77

8+
require 'ceedling/exceptions'
9+
require 'gcov_types'
10+
811
# Abstract base class that formalises the gcov reportinator interface.
912
#
1013
# Subclasses must:
@@ -13,13 +16,16 @@
1316
# and expose it via attr_reader.
1417
# - Set @configurator in initialize to access build_exclusion_data().
1518
# - Set @loginator in initialize to access print_shell_exec_time().
19+
# - Set @tool_executor in initialize to use ToolVersionGating's detect_tool_version().
1620
# - Initialize @summary = '' and set it during generate_reports() when the
1721
# tool produces a coverage summary (e.g. gcovr --print-summary output).
1822
# Gcov#generate_coverage_reports reads summary() and is the sole logging site.
1923
# - Implement generate_reports(opts) as a void orchestrator.
2024
#
2125
class GcovReportinator
2226

27+
include ToolVersionGating
28+
2329
def initialize(config)
2430
@config = config
2531
end
@@ -64,4 +70,63 @@ def build_exclusion_data
6470
}
6571
end
6672

73+
74+
# Shared "escape hatch" handling for arbitrary user-supplied CLI arguments, applied
75+
# even when a config/report file is otherwise in full control -- both GcovrReportinator's
76+
# :custom_args and ReportGeneratorReportinator's :custom_args use this identical pattern.
77+
def build_custom_args(custom_args)
78+
return "" if custom_args.nil?
79+
80+
custom_args.each_with_object(String.new) do |custom_arg, args|
81+
args << "\"#{custom_arg}\" " unless custom_arg.nil? || custom_arg.empty?
82+
end
83+
end
84+
85+
86+
# Generic CLI-argument builder driven by a declarative table, so each reportinator's
87+
# own option-to-flag mapping reads as plain data rather than repeated ad hoc string
88+
# concatenation. table: { option_symbol => spec }, where spec is:
89+
# :flag - the CLI flag text, or a Proc(version) -> String for a flag whose
90+
# name itself depends on the tool version (e.g. a deprecated/renamed flag).
91+
# :type - :boolean (flag alone), :value (flag + one quoted value by default),
92+
# :inline_value (flag and value quoted together as a single CLI token,
93+
# ReportGenerator's `-key:value` style, vs. gcovr's `--flag "value"`),
94+
# :list (flag repeated once per array entry), :integer (flag + value,
95+
# only if the configured value is an Integer), or :integer_percent
96+
# (as :integer, plus a 1-100 range check raising CeedlingException).
97+
# :quote - set false to emit a :value/:list entry's value unquoted.
98+
# :min_version - [major, minor]; entry is silently omitted below this version
99+
# (distinct from VERSION_GATES-style checks, which raise instead).
100+
def build_args_from_table(opts, table, version: nil, component_prefix: '')
101+
table.each_with_object(String.new) do |(option, spec), args|
102+
value = opts[option]
103+
next if value.nil? || value == false
104+
next if spec[:min_version] && !min_version?(version, *spec[:min_version])
105+
106+
flag = spec[:flag].respond_to?(:call) ? spec[:flag].call(version) : spec[:flag]
107+
quote = spec[:quote] != false
108+
109+
case spec[:type]
110+
when :boolean
111+
args << "#{flag} "
112+
when :value
113+
args << "#{flag} #{quote ? "\"#{value}\"" : value} "
114+
when :inline_value
115+
args << "\"#{flag}#{value}\" "
116+
when :list
117+
Array(value).each { |v| args << "#{flag} #{quote ? "\"#{v}\"" : v} " }
118+
when :integer
119+
args << "#{flag} #{value} " if value.is_a?(Integer)
120+
when :integer_percent
121+
unless value.is_a?(Integer)
122+
raise CeedlingException.new("#{component_prefix} ↳ :#{option} ➡️ '#{value}' must be an integer")
123+
end
124+
unless (1..100).cover?(value)
125+
raise CeedlingException.new("#{component_prefix} ↳ :#{option} ➡️ '#{value}' must be an integer percentage 1 – 100")
126+
end
127+
args << "#{flag} #{value} "
128+
end
129+
end
130+
end
131+
67132
end

plugins/gcov/lib/gcov_types.rb

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,65 @@
55
# SPDX-License-Identifier: MIT
66
# =========================================================================
77

8-
GcovToolVersion = Struct.new(:major, :minor)
8+
require 'ceedling/exceptions'
9+
10+
# major.minor version of any tool this plugin shells out to (gcovr, gcc) --
11+
# generic so gcc-version and gcovr-version checks share one comparable type
12+
# instead of each plugin file inventing its own.
13+
ToolVersion = Struct.new(:major, :minor)
14+
15+
# Generic version detection + gating, mixed into any class that shells out to
16+
# a tool whose available features depend on its version (GcovReportinator's
17+
# gcovr/ReportGenerator subclasses, Gcov's own gcc check). One mechanic here;
18+
# each includer supplies its own version-tool config, output pattern, and
19+
# declarative gate table rather than hand-rolling comparison/raise boilerplate
20+
# per gated option. Requires the includer to have set @tool_executor.
21+
module ToolVersionGating
22+
23+
# True when version is at least major.minor. Defined at both module- and instance-level
24+
# (the instance method just delegates) so a declarative table built at class-definition
25+
# time -- outside any includer instance's context, e.g. a Proc picking a version-dependent
26+
# flag name -- can still call it as ToolVersionGating.min_version?(...).
27+
def self.min_version?(version, major, minor)
28+
return true if version.major > major
29+
return true if version.major == major && version.minor >= minor
30+
return false
31+
end
32+
33+
def min_version?(version, major, minor)
34+
ToolVersionGating.min_version?(version, major, minor)
35+
end
36+
37+
# Run a `--version`-style tool invocation and parse a ToolVersion from its output.
38+
# tool_config: a ToolExecutor tool-config hash needing no positional substitutions.
39+
# pattern: a Regexp whose first two capture groups are (major, minor).
40+
def detect_tool_version(tool_config, pattern, tool_label:)
41+
command = @tool_executor.build_command_line( tool_config, [] )
42+
shell_result = @tool_executor.exec( command )
43+
version_match = shell_result[:output].match( pattern )
44+
45+
if version_match.nil? || version_match[1].nil? || version_match[2].nil?
46+
raise CeedlingException.new( "Could not collect `#{tool_label}` version from its command line" )
47+
end
48+
49+
ToolVersion.new( version_match[1].to_i, version_match[2].to_i )
50+
end
51+
52+
# Raise one CeedlingException listing every configured option in gate_table whose
53+
# value is set but whose required minimum version exceeds the detected version.
54+
# gate_table: { option_symbol => { min: [major, minor], message: "... %{req} ... %{found} ..." } }.
55+
# opts: any hash the option symbols can be looked up in -- callers may merge values
56+
# from more than one config namespace (e.g. gcov.rb's :mcdc lives directly under
57+
# :gcov, not under :gcov ↳ :gcovr like GcovrReportinator's own gated options).
58+
def enforce_version_gates!(opts, version, gate_table)
59+
violations = gate_table.filter_map do |option, gate|
60+
next if opts[option].nil? || opts[option] == false
61+
next if min_version?( version, *gate[:min] )
62+
63+
gate[:message] % { req: gate[:min].join('.'), found: "#{version.major}.#{version.minor}" }
64+
end
65+
66+
raise CeedlingException.new( violations.join("\n") ) unless violations.empty?
67+
end
68+
69+
end

0 commit comments

Comments
 (0)