///|
/// Coverage before and after a change, plus the percentage-point delta.
pub(all) struct MetricChange {
before : CoverageCount
after : CoverageCount
percentage_delta : Double
} derive(Debug)
///|
/// How a source path changed between two reports.
pub(all) enum FileChangeKind {
Added
Removed
Modified
Unchanged
} derive(Debug, Eq)
///|
/// A per-file summary comparison.
pub(all) struct FileComparison {
path : String
kind : FileChangeKind
before : CoverageSummary
after : CoverageSummary
} derive(Debug, Eq)
///|
/// Report-wide metric changes and path-level comparisons.
pub(all) struct CoverageComparison {
baseline : CoverageSummary
current : CoverageSummary
lines : MetricChange
branches : MetricChange
functions : MetricChange
files : Array[FileComparison]
} derive(Debug)
///|
/// Allowed percentage-point drops for regression checks.
pub(all) struct RegressionLimits {
lines : Double?
branches : Double?
functions : Double?
} derive(Debug)
///|
/// One metric whose drop exceeded its allowance.
pub(all) struct RegressionViolation {
metric : CoverageMetric
percentage_delta : Double
allowed_drop : Double
} derive(Debug)
///|
/// Result of comparing current coverage to a baseline policy.
pub(all) struct RegressionResult {
passed : Bool
comparison : CoverageComparison
violations : Array[RegressionViolation]
} derive(Debug)
///|
/// Construct optional maximum percentage-point drops.
pub fn RegressionLimits::new(
lines? : Double,
branches? : Double,
functions? : Double,
) -> RegressionLimits {
{ lines, branches, functions }
}
///|
fn metric_change(before : CoverageCount, after : CoverageCount) -> MetricChange {
{ before, after, percentage_delta: after.percentage() - before.percentage() }
}
///|
fn file_summary_map(
report : CoverageReport,
) -> Map[String, CoverageSummary] raise CoverageError {
let summaries : Map[String, CoverageSummary] = Map([])
for file in summarize_files(report) {
summaries[file.path] = file.summary
}
summaries
}
///|
fn file_change_kind(
before_present : Bool,
after_present : Bool,
before : CoverageSummary,
after : CoverageSummary,
) -> FileChangeKind {
match (before_present, after_present) {
(false, true) => Added
(true, false) => Removed
(true, true) => if before == after { Unchanged } else { Modified }
(false, false) => Unchanged
}
}
///|
/// Compare a current report to a baseline after canonicalization.
///
/// Deltas are percentage points, so moving from 75% to 80% is `+5`, not a
/// relative 6.67% increase. File comparisons include added and removed paths.
pub fn compare_reports(
baseline_report : CoverageReport,
current_report : CoverageReport,
) -> CoverageComparison raise CoverageError {
let baseline = summarize(baseline_report)
let current = summarize(current_report)
let before_files = file_summary_map(baseline_report)
let after_files = file_summary_map(current_report)
let path_set : Map[String, Unit] = Map([])
for path, _ in before_files {
path_set[path] = ()
}
for path, _ in after_files {
path_set[path] = ()
}
let paths = [ for path, _ in path_set => path ]
paths.sort()
let files : Array[FileComparison] = []
for path in paths {
let before_present = before_files.contains(path)
let after_present = after_files.contains(path)
let before = before_files.get_or_default(path, empty_summary())
let after = after_files.get_or_default(path, empty_summary())
files.push({
path,
kind: file_change_kind(before_present, after_present, before, after),
before,
after,
})
}
{
baseline,
current,
lines: metric_change(baseline.lines, current.lines),
branches: metric_change(baseline.branches, current.branches),
functions: metric_change(baseline.functions, current.functions),
files,
}
}
///|
fn add_regression_violation(
violations : Array[RegressionViolation],
metric : CoverageMetric,
change : MetricChange,
allowance : Double?,
) -> Unit {
match allowance {
Some(allowed_drop) =>
if change.percentage_delta < -allowed_drop {
violations.push({
metric,
percentage_delta: change.percentage_delta,
allowed_drop,
})
}
None => ()
}
}
///|
/// Fail when a coverage percentage drops by more than an allowed number of
/// percentage points.
///
/// Equality passes: a 5-point drop satisfies an allowance of 5 points.
pub fn check_regressions(
baseline : CoverageReport,
current : CoverageReport,
limits : RegressionLimits,
) -> RegressionResult raise CoverageError {
validate_threshold("line regression allowance", limits.lines)
validate_threshold("branch regression allowance", limits.branches)
validate_threshold("function regression allowance", limits.functions)
let comparison = compare_reports(baseline, current)
let violations : Array[RegressionViolation] = []
add_regression_violation(violations, Lines, comparison.lines, limits.lines)
add_regression_violation(
violations,
Branches,
comparison.branches,
limits.branches,
)
add_regression_violation(
violations,
Functions,
comparison.functions,
limits.functions,
)
{ passed: violations.is_empty(), comparison, violations }
}
///|
fn delta_text(delta : Double) -> String {
let hundredths = (delta * 100.0).round().to_int()
let sign = if hundredths > 0 { "+" } else { "" }
let absolute = if hundredths < 0 { -hundredths } else { hundredths }
let whole = absolute / 100
let fraction = absolute % 100
let fraction_text = if fraction < 10 {
"0\{fraction}"
} else {
fraction.to_string()
}
if hundredths < 0 {
"-\{whole}.\{fraction_text} pp"
} else {
"\{sign}\{whole}.\{fraction_text} pp"
}
}
///|
fn comparison_kind_text(kind : FileChangeKind) -> String {
match kind {
Added => "added"
Removed => "removed"
Modified => "modified"
Unchanged => "unchanged"
}
}
///|
fn write_comparison_row(
output : StringBuilder,
scope : String,
metric : String,
change : MetricChange,
) -> Unit {
output.write_string(
"| \{markdown_cell(scope)} | \{metric} | " +
"\{count_text(change.before)} | \{count_text(change.after)} | " +
"\{delta_text(change.percentage_delta)} |\n",
)
}
///|
/// Render an overall and optional per-file baseline comparison as Markdown.
pub fn comparison_to_markdown(
comparison : CoverageComparison,
title? : String = "Coverage comparison",
include_files? : Bool = true,
) -> String {
let output = StringBuilder()
output.write_string("## \{markdown_cell(title)}\n\n")
output.write_string("| Scope | Metric | Before | After | Delta |\n")
output.write_string("|:--|:--|--:|--:|--:|\n")
write_comparison_row(output, "**Overall**", "lines", comparison.lines)
write_comparison_row(output, "**Overall**", "branches", comparison.branches)
write_comparison_row(output, "**Overall**", "functions", comparison.functions)
if include_files {
for file in comparison.files {
let scope = "`\{file.path}` (\{comparison_kind_text(file.kind)})"
write_comparison_row(
output,
scope,
"lines",
metric_change(file.before.lines, file.after.lines),
)
write_comparison_row(
output,
scope,
"branches",
metric_change(file.before.branches, file.after.branches),
)
write_comparison_row(
output,
scope,
"functions",
metric_change(file.before.functions, file.after.functions),
)
}
}
output.to_string()
}
///|
/// Produce a concise regression result for CI logs.
pub fn regression_message(result : RegressionResult) -> String {
if result.passed {
return "coverage regression check passed"
}
let messages : Array[String] = []
for violation in result.violations {
messages.push(
"\{metric_name(violation.metric)} " +
"changed \{delta_text(violation.percentage_delta)}; " +
"allowed drop \{violation.allowed_drop} pp",
)
}
"coverage regression check failed: \{messages.join("; ")}"
}