view release on metacpan or search on metacpan
than a version- or platform-specific compatibility issue. Likely
causes listed in evidence: missing file in tarball, broken
Makefile.PL, or undeclared dependency. Integrated into
detect_root_causes() where it is evaluated first and sorted by
confidence alongside the existing OS, locale, and Perl version
cliff detectors.
- Fixed blib/ paths appearing in coverage table instead of lib/
paths. Devel::Cover instruments blib/ during testing; paths are
now normalised to lib/ for display, with deduplication against
any native lib/ entry.
- Fixed structural coverage percentages in Executive Summary and
Structural Coverage sections showing ~24% instead of ~93%.
_coverage_totals now aggregates from individual own-project files
rather than Devel::Cover's pre-aggregated Total key which
includes all instrumented CPAN dependencies.
- Fixed cyclomatic complexity badge colour and tooltip inverted.
High complexity now correctly shows red/Needs improvement;
low complexity shows green/Good. Second condition also fixed
to use $complexity rather than $score.
0.31 Fri Apr 10 08:07:40 EDT 2026
- Added TER3 (LCSAJ path coverage) column to mutation files table in
the test dashboard index, showing percentage and raw fraction
(e.g. "71.8% (352/491)")
- Added TER1, TER2 and TER3 metrics to per-file mutation report pages,
replacing the plain "Statement" and "Branch" labels with their
formal Test Effectiveness Ratio names
- Renamed the LCSAJ column header in the mutation files table to TER3
- With min set to zero on an integer, sometimes negative or floats could be created - added abs() and int() calls as needed
- Added --generate_mutant_tests=DIR option to generate_index.pl to
produce a timestamped test stub file (t/mutant_YYYYMMDD_HHMMSS.t)
for surviving mutants. High/Medium difficulty survivors get TODO
test stubs; Low difficulty survivors get comment-only hints.
bin/test-generator-index view on Meta::CPAN
matching survivors are skipped (with a note if --verbose is active).
New boundary values are merged into whichever edge key already
exists in the schema (edge_case_array or edge_cases), with
deduplication against existing values.
This flag is independent of --generate_test and can be used alone.
=head1 DASHBOARD SECTIONS
Coverage Table - Per-file statement/branch/condition/subroutine
percentages with delta vs previous snapshot,
sortable columns, and sparkline trend per file
Coverage Trend - Chart of total coverage over recent commits with
linear regression line, zoom and pan support
RT Issues - Count of open RT tickets for the distribution
CPAN Testers - Failure table for the current release, with
Perl version cliff detection, locale analysis,
dependency version cliff detection, and root
cause confidence scoring
Mutation Report - Per-file mutation score (killed/survived/total),
cyclomatic complexity, and TER3 (LCSAJ path
bin/test-generator-index view on Meta::CPAN
# CPAN Testers reports. Set to 0 to disable if the
# API is unreachable or the analysis is too slow.
# --------------------------------------------------
Readonly my $ENABLE_DEP_ANALYSIS => 1;
# Read and decode data
my $cover_db = eval { decode_json(read_file($config{cover_db})) };
my $mutation_db = eval { decode_json(read_file($config{mutation_db})) };
# --------------------------------------------------
# Compute coverage percentage from only our own files,
# excluding absolute paths (installed CPAN modules)
# which inflate Devel::Cover's pre-aggregated Total.
# --------------------------------------------------
my ($coverage_pct, $badge_color) = (0, 'red');
if($cover_db->{summary}) {
my ($sum, $count) = (0, 0);
for my $f (keys %{ $cover_db->{summary} }) {
next if $f eq 'Total';
next if $f =~ /^\//; # skip absolute paths
$sum += $cover_db->{summary}{$f}{total}{percentage} // 0;
$count++;
}
if($count) {
my $pct = _own_file_coverage_pct($cover_db->{summary});
$coverage_pct = defined $pct ? int($pct) : 0;
$badge_color = $coverage_pct > $config{med_threshold} ? 'brightgreen'
: $coverage_pct > $config{low_threshold} ? 'yellow'
: 'red';
}
bin/test-generator-index view on Meta::CPAN
next if $file eq 'Total';
next if $file =~ /^\//; # skip absolute paths
# Normalise blib/ paths and deduplicate against lib/ entries
my $delta_key = $file;
if($file =~ /^blib\/lib\/(.+)$/) {
next if exists $cover_db->{summary}{"lib/$1"};
$delta_key = "lib/$1";
}
my $curr = $cover_db->{summary}{$file}{total}{percentage} // 0;
my $prev = $prev_data->{summary}{$file}{total}{percentage}
// $prev_data->{summary}{$delta_key}{total}{percentage}
// 0;
my $delta = sprintf('%.1f', $curr - $prev);
$deltas{$delta_key} = $delta;
}
}
# Check if we're in a git repository first
unless(run_git('rev-parse', '--git-dir')) {
die 'Error: Not in a git repository or git is not available';
}
bin/test-generator-index view on Meta::CPAN
$display_file = $lib_path;
}
my $info = $cover_db->{summary}{$file};
my $html_file = $display_file;
$html_file =~ s|/|-|g;
$html_file =~ s|\.pm$|-pm|;
$html_file =~ s|\.pl$|-pl|;
$html_file .= '.html';
my $total = $info->{total}{percentage} // 0;
$total_files++;
$total_coverage += $total;
$low_coverage_count++ if $total < $config{low_threshold};
my $badge_class = $total >= $config{med_threshold} ? 'badge-good'
: $total >= $config{low_threshold} ? 'badge-warn'
: 'badge-bad';
my $tooltip = $total >= $config{med_threshold} ? 'Excellent coverage'
: $total >= $config{low_threshold} ? 'Moderate coverage'
bin/test-generator-index view on Meta::CPAN
my $badge_html = sprintf(
'<span class="coverage-badge %s" title="%s">%.1f%%</span>',
$badge_class, $tooltip, $total
);
my $delta_html;
if(exists $deltas{$file}) {
my $delta = $deltas{$file};
my $delta_class = $delta > 0 ? 'positive' : $delta < 0 ? 'negative' : 'neutral';
my $delta_icon = $delta > 0 ? '▲' : $delta < 0 ? '▼' : '●';
my $prev_pct = $prev_data->{summary}{$file}{total}{percentage} // 0;
$delta_html = sprintf(
'<td class="%s" title="Previous: %.1f%%">%s %.1f%%</td>',
$delta_class, $prev_pct, $delta_icon, abs($delta)
);
} else {
$delta_html = '<td class="neutral" title="No previous data">●</td>';
}
my $source_url = $github_base . $display_file;
my $has_coverage = (
defined $info->{statement}{percentage} ||
defined $info->{branch}{percentage} ||
defined $info->{condition}{percentage} ||
defined $info->{subroutine}{percentage}
);
my $source_link = $has_coverage
? sprintf('<a href="%s" class="icon-link" title="View source on GitHub">🔍</a>', $source_url)
: '<span class="disabled-icon" title="No coverage data">🔍</span>';
# Create the sparkline - limit to last N points like the main trend chart
my @file_history;
# Get the last max_points history files (same as trend chart)
bin/test-generator-index view on Meta::CPAN
next unless $json; # Skip if not cached (shouldn't happen, but be safe)
# Try both with and without blib/ prefix since older history
# files store paths under blib/lib/... while the dashboard
# displays them as lib/...
my $hist_key = $json->{summary}{"blib/$file"} ? "blib/$file"
: $json->{summary}{$file} ? $file
: undef;
if($hist_key) {
my $pct = $json->{summary}{$hist_key}{total}{percentage} // 0;
push @file_history, sprintf('%.1f', $pct);
}
}
my $points_attr = join(',', @file_history);
push @html, sprintf(
qq{<tr class="%s"><td><a href="%s" title="View coverage line by line" target="_blank">%s</a> %s<canvas class="sparkline" width="80" height="20" data-points="$points_attr"></canvas></td><td>%.1f</td><td>%.1f</td><td>%.1f</td><td>%.1f</td><td>%s</td>...
$row_class, $html_file, $display_file, $source_link,
$info->{statement}{percentage} // 0,
$info->{branch}{percentage} // 0,
$info->{condition}{percentage} // 0,
$info->{subroutine}{percentage} // 0,
$badge_html,
$delta_html
);
}
# Summary row
my $avg_coverage = $total_files ? int($total_coverage / $total_files) : 0;
push @html, sprintf(
qq{<tr class="summary-row nosort"><td colspan="2"><strong>Summary</strong></td><td colspan="2">%d files</td><td colspan="3">Avg: %d%%, Low: %d</td></tr>},
bin/test-generator-index view on Meta::CPAN
next if $file eq 'Total';
next if $file =~ /^\//; # skip absolute paths (installed modules)
# Skip blib/ entries that have a corresponding lib/ entry
# to avoid counting the same file twice in the totals
if($file =~ /^blib\/lib\/(.+)$/) {
next if exists $cover_db->{summary}{"lib/$1"};
}
my $info = $cover_db->{summary}{$file};
$sum_stmt += $info->{statement}{percentage} // 0;
$sum_branch += $info->{branch}{percentage} // 0;
$sum_cond += $info->{condition}{percentage} // 0;
$sum_sub += $info->{subroutine}{percentage} // 0;
$sum_total += $info->{total}{percentage} // 0;
$counted++;
}
if($counted) {
my $avg_total = $sum_total / $counted;
my $class = $avg_total > 80 ? 'high' : $avg_total > 50 ? 'med' : 'low';
push @html, sprintf(
qq{<tr class="%s nosort"><td><strong>Total</strong></td><td>%.1f</td><td>%.1f</td><td>%.1f</td><td>%.1f</td><td colspan="2"><strong>%.1f</strong></td></tr>},
$class,
bin/test-generator-index view on Meta::CPAN
my $full_sha = $sha_lookup{$sha};
next unless defined $full_sha;
next unless $commit_messages{$full_sha}; # skip merge commits
# Compute average across our own files only
my ($sum, $count) = (0, 0);
for my $f (keys %{ $json->{summary} }) {
next if $f eq 'Total';
next if $f =~ /^\//;
next unless $f =~ /^(?:lib|blib|bin)\//; # only own project files
$sum += $json->{summary}{$f}{total}{percentage} // 0;
$count++;
}
next unless $count;
# Use full SHA for lookups and URL
my $timestamp = $commit_times{$full_sha} // strftime('%Y-%m-%dT%H:%M:%S', localtime((stat($file))->mtime));
# Git log returns format like: "2024-01-15 14:30:45 -0500" or "2024-01-15 14:30:45 +0000"
# We need ISO 8601 format: "2024-01-15T14:30:45-05:00"
bin/test-generator-index view on Meta::CPAN
sub confidence_score {
my (%args) = @_;
my $fail = $args{fail} // 0;
my $pass = $args{pass} // 0;
return (0, 'none') if($fail + $pass) == 0;
my $score = $fail / ($fail + $pass);
# Convert config thresholds from percent â fraction
my $med = ($config{med_threshold} // 90) / 100;
my $low = ($config{low_threshold} // 70) / 100;
my $label =
$score >= $med ? 'strong' :
$score >= $low ? 'moderate' :
'weak';
return ($score, $label);
}
bin/test-generator-index view on Meta::CPAN
my $killed = scalar @{ $file_data->{killed} || [] };
my $survived = scalar @{ $file_data->{survived} || [] };
my $total = $killed + $survived;
return $total ? ($killed / $total) * 100 : 0;
}
# --------------------------------------------------
# _ter_badge
#
# Purpose: Format a single TER percentage value as
# a colour-coded HTML badge, consistent
# with the coverage badge style used
# elsewhere in the dashboard.
#
# Entry: $pct - percentage value (0-100), or
# undef if data is unavailable
# $label - fallback text to display when
# $pct is undef (e.g. 'n/a')
#
# Exit: Returns an HTML span string. Never
# returns undef.
#
# Side effects: None.
#
# Notes: Thresholds are taken from %config:
bin/test-generator-index view on Meta::CPAN
$file, $lcsaj_dir, $lcsaj_hits, []
);
my $ter3_str;
if(!defined $lcsaj_cov) {
# .lcsaj.json not found â TER3 unavailable for this file
$ter3_str = 'n/a';
} elsif(!$lcsaj_total) {
# File found but no paths defined
$ter3_str = '-';
} else {
# Format as percentage with raw fraction for clarity
$ter3_str = sprintf('%.1f%% (%d/%d)',
($lcsaj_cov / $lcsaj_total) * 100,
$lcsaj_cov,
$lcsaj_total
);
}
# Display TER1/TER2/TER3 coverage metrics in the summary block.
# TER1 = statement, TER2 = branch, TER3 = LCSAJ path coverage.
print $out "<div class='summary'>\n";
bin/test-generator-index view on Meta::CPAN
# $branch_total, $branch_hit)
# Returns (0, 0, 0, 0) if $cov is undef,
# not a hashref, or contains no summary.
#
# Side effects: None.
#
# Notes: Devel::Cover's pre-aggregated 'Total'
# key includes all instrumented files â
# CPAN dependencies, blib/ copies, and
# absolute paths â which inflates the
# reported percentage. This function
# recomputes from per-file entries,
# applying the same own-file filter
# (lib/, blib/, bin/ prefixes only, no
# absolute paths) used in the coverage
# table and badge calculation.
# blib/ entries that have a corresponding
# lib/ entry are skipped to avoid
# double-counting.
# --------------------------------------------------
sub _coverage_totals
bin/test-generator-index view on Meta::CPAN
}
}
}
return ($covered, $total);
}
# --------------------------------------------------
# _own_file_coverage_pct
#
# Compute average coverage percentage across only the
# project's own files in a Devel::Cover summary hashref,
# excluding Devel::Cover's pre-aggregated Total key and
# any absolute paths (installed CPAN modules) which
# would otherwise inflate the reported figure.
#
# Arguments:
# $summary - hashref of Devel::Cover summary data
#
# Returns:
# Average total coverage percentage, or undef if no
# qualifying files found
# --------------------------------------------------
sub _own_file_coverage_pct {
my ($summary) = @_;
return undef unless $summary;
my ($sum, $count) = (0, 0);
for my $f (keys %$summary) {
next if $f eq 'Total';
next if $f =~ /^\//; # skip absolute paths
next unless $f =~ /^(?:lib|blib|bin)\//; # only own project files
$sum += $summary->{$f}{total}{percentage} // 0;
$count++;
}
return $count ? $sum / $count : undef;
}
=head1 AUTHOR
Nigel Horne <njh@nigelhorne.com>
bin/test-generator-mutate view on Meta::CPAN
Mutate a single file instead of scanning the entire C<--lib> directory.
=head2 --tests <dir>
Directory containing test files.
Defaults to C<t>.
=head2 --min-score <int>
Minimum acceptable mutation score (percentage).
If the final score is below this value, the program exits with a
non-zero status.
=head2 --json <file>
Write mutation results to the specified JSON file.
The output structure: