| File: | blib/lib/App/Project/Doctor/Report.pm |
| Coverage: | 100.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Project::Doctor::Report; | |||||
| 2 | ||||||
| 3 | # A Report aggregates all Finding objects produced by check plugins and | |||||
| 4 | # renders them as human-readable text, machine-readable JSON, or TAP output | |||||
| 5 | # for CI pipelines. It also tracks the overall pass/fail exit code. | |||||
| 6 | ||||||
| 7 | 5 5 5 | 4656 4 63 | use strict; | |||
| 8 | 5 5 5 | 8 4 147 | use warnings; | |||
| 9 | 5 5 5 | 9 5 16 | use autodie qw(:all); | |||
| 10 | ||||||
| 11 | # croak dies at the caller's location; carp warns there. | |||||
| 12 | 5 5 5 | 10543 5 164 | use Carp qw(croak carp); | |||
| 13 | # Readonly prevents accidental mutation of constants after they are defined. | |||||
| 14 | 5 5 5 | 8 5 99 | use Readonly; | |||
| 15 | # blessed() lets us confirm that add_findings() received real Finding objects. | |||||
| 16 | 5 5 5 | 10 5 91 | use Scalar::Util qw(blessed); | |||
| 17 | # validate_strict enforces parameter schemas; not used by new() (takes no args). | |||||
| 18 | 5 5 5 | 10 3 3073 | use Params::Validate::Strict qw(validate_strict); | |||
| 19 | ||||||
| 20 | our $VERSION = '0.02'; | |||||
| 21 | ||||||
| 22 | # --------------------------------------------------------------------------- | |||||
| 23 | # Constants | |||||
| 24 | # --------------------------------------------------------------------------- | |||||
| 25 | ||||||
| 26 | # The icons shown at the start of each text-report line, keyed by severity. | |||||
| 27 | Readonly::Hash my %ICON => ( | |||||
| 28 | pass => '[v]', # Healthy -- no action needed | |||||
| 29 | error => '[X]', # Broken -- must be fixed | |||||
| 30 | warning => '[!]', # Suspicious -- should be reviewed | |||||
| 31 | info => '[i]', # Informational -- no action needed | |||||
| 32 | ); | |||||
| 33 | ||||||
| 34 | # Numeric rank used to pick the "worst" severity in a group of findings. | |||||
| 35 | # Higher number = more severe; error is always the worst. | |||||
| 36 | Readonly::Hash my %SEV_RANK => (error => 3, warning => 2, info => 1, pass => 0); | |||||
| 37 | ||||||
| 38 | # The column width reserved for the check name in the text report. | |||||
| 39 | # Chosen to accommodate the longest default check name ('CpanReadiness' = 13 chars). | |||||
| 40 | Readonly::Scalar my $LABEL_WIDTH => 18; | |||||
| 41 | ||||||
| 42 | # --------------------------------------------------------------------------- | |||||
| 43 | # Constructor | |||||
| 44 | # --------------------------------------------------------------------------- | |||||
| 45 | ||||||
| 46 | sub new { | |||||
| 47 | # Report takes no constructor arguments; start with an empty findings list. | |||||
| 48 | 126 | 73905 | my ($class, %args) = @_; | |||
| 49 | 126 | 203 | return bless { _findings => [] }, $class; | |||
| 50 | } | |||||
| 51 | ||||||
| 52 | # --------------------------------------------------------------------------- | |||||
| 53 | # Mutator | |||||
| 54 | # --------------------------------------------------------------------------- | |||||
| 55 | ||||||
| 56 - 61 | =head2 add_findings( @findings ) Appends one or more L<App::Project::Doctor::Finding> objects. Croaks on non-Finding arguments. =cut | |||||
| 62 | ||||||
| 63 | sub add_findings { | |||||
| 64 | 140 | 1794 | my ($self, @findings) = @_; | |||
| 65 | 140 | 130 | for my $f (@findings) { | |||
| 66 | # Validate each element to catch bugs where a check returns a string | |||||
| 67 | # or undef instead of a real Finding object. | |||||
| 68 | 197 | 513 | croak 'Expected an App::Project::Doctor::Finding' | |||
| 69 | unless blessed($f) && $f->isa('App::Project::Doctor::Finding'); | |||||
| 70 | 186 186 | 131 190 | push @{ $self->{_findings} }, $f; | |||
| 71 | } | |||||
| 72 | # Return $self so callers can chain: $report->add_findings(...)->render_text. | |||||
| 73 | 129 | 131 | return $self; | |||
| 74 | } | |||||
| 75 | ||||||
| 76 | # --------------------------------------------------------------------------- | |||||
| 77 | # Accessors / filters | |||||
| 78 | # --------------------------------------------------------------------------- | |||||
| 79 | ||||||
| 80 | # Return every finding in insertion order (used by render_* methods). | |||||
| 81 | 50 50 | 1657 78 | sub all_findings { @{ $_[0]->{_findings} } } | |||
| 82 | # Return only the findings with severity 'error'. | |||||
| 83 | 64 114 64 | 50 99 81 | sub errors { grep { $_->severity eq 'error' } @{ $_[0]->{_findings} } } | |||
| 84 | # Return only the findings with severity 'warning'. | |||||
| 85 | 43 77 43 | 33 66 46 | sub warnings { grep { $_->severity eq 'warning' } @{ $_[0]->{_findings} } } | |||
| 86 | # Return only the findings with severity 'pass'. | |||||
| 87 | 3 17 3 | 4 14 6 | sub passes { grep { $_->severity eq 'pass' } @{ $_[0]->{_findings} } } | |||
| 88 | # Return only the findings that carry an automated fix coderef. | |||||
| 89 | 77 125 77 | 50 133 94 | sub fixable { grep { $_->is_fixable } @{ $_[0]->{_findings} } } | |||
| 90 | ||||||
| 91 | # has_errors returns 1/0 (not just truthy) for type-safe callers. | |||||
| 92 | 29 | 36 | sub has_errors { (scalar($_[0]->errors) > 0) ? 1 : 0 } | |||
| 93 | 10 | 14 | sub has_warnings { (scalar($_[0]->warnings) > 0) ? 1 : 0 } | |||
| 94 | ||||||
| 95 - 99 | =head2 exit_code Returns 0 (clean) or 1 (errors present). =cut | |||||
| 100 | ||||||
| 101 | # The process should exit 1 if any finding is an error, 0 otherwise. | |||||
| 102 | 16 | 30 | sub exit_code { $_[0]->has_errors ? 1 : 0 } | |||
| 103 | ||||||
| 104 | # --------------------------------------------------------------------------- | |||||
| 105 | # Rendering | |||||
| 106 | # --------------------------------------------------------------------------- | |||||
| 107 | ||||||
| 108 - 112 | =head2 render_text( %opts ) Returns the full text report. Accepted options: C<verbose> (bool). =cut | |||||
| 113 | ||||||
| 114 | sub render_text { | |||||
| 115 | 28 | 46 | my ($self, %opts) = @_; | |||
| 116 | # verbose mode adds per-finding detail lines under each check summary. | |||||
| 117 | 28 | 50 | my $verbose = $opts{verbose} // 0; | |||
| 118 | ||||||
| 119 | # Group findings by check name while preserving the insertion order of | |||||
| 120 | # the first finding seen for each check. This keeps the output stable. | |||||
| 121 | 28 | 26 | my (%by_check, @order); | |||
| 122 | 28 | 36 | for my $f ($self->all_findings) { | |||
| 123 | 34 | 44 | my $name = $f->check_name; | |||
| 124 | 34 | 42 | unless (exists $by_check{$name}) { | |||
| 125 | # First time we see this check name -- record its position. | |||||
| 126 | 31 | 26 | push @order, $name; | |||
| 127 | 31 | 39 | $by_check{$name} = []; | |||
| 128 | } | |||||
| 129 | 34 34 | 21 35 | push @{ $by_check{$name} }, $f; | |||
| 130 | } | |||||
| 131 | ||||||
| 132 | # Build the output one line at a time and join at the end. | |||||
| 133 | 28 | 23 | my @lines; | |||
| 134 | 28 | 27 | for my $name (@order) { | |||
| 135 | 31 31 | 22 31 | my @group = @{ $by_check{$name} }; | |||
| 136 | # Choose the worst severity in this group to pick the icon. | |||||
| 137 | 31 | 40 | my $sev = _worst_severity(\@group); | |||
| 138 | 31 | 70 | my $icon = $ICON{$sev}; # Severity is always valid; no fallback needed. | |||
| 139 | ||||||
| 140 | # Use the first non-pass finding as the summary line for mixed groups. | |||||
| 141 | 31 34 | 84 32 | my ($lead) = grep { $_->severity ne 'pass' } @group; | |||
| 142 | 31 | 63 | my $summary = $lead ? $lead->message : $group[0]->message; | |||
| 143 | ||||||
| 144 | # Format: icon check-name (padded) summary message | |||||
| 145 | 31 | 63 | push @lines, sprintf(' %-4s %-*s %s', $icon, $LABEL_WIDTH, $name, $summary); | |||
| 146 | ||||||
| 147 | 31 | 46 | if ($verbose) { | |||
| 148 | # In verbose mode, print each non-pass finding with its detail. | |||||
| 149 | 6 | 7 | for my $f (@group) { | |||
| 150 | # Skip pass findings in verbose mode -- they have no useful detail. | |||||
| 151 | 7 | 9 | next if $f->severity eq 'pass'; | |||
| 152 | 6 | 9 | push @lines, sprintf(' -> %s', $f->message); | |||
| 153 | # Only print the detail line when there is something to show. | |||||
| 154 | 6 | 12 | push @lines, sprintf(' %s', $f->detail) if $f->detail; | |||
| 155 | } | |||||
| 156 | } | |||||
| 157 | } | |||||
| 158 | ||||||
| 159 | # Print a one-line summary of error/warning counts below the check table. | |||||
| 160 | 28 | 33 | my $ec = scalar($self->errors); | |||
| 161 | 28 | 36 | my $wc = scalar($self->warnings); | |||
| 162 | 28 | 21 | push @lines, ''; # Blank line before the summary. | |||
| 163 | 28 | 74 | push @lines, $ec || $wc | |||
| 164 | ? join(' - ', ($ec ? "$ec error(s)" : ()), ($wc ? "$wc warning(s)" : ())) | |||||
| 165 | : 'No errors or warnings.'; | |||||
| 166 | ||||||
| 167 | # If there are fixable findings, show them and hint that the user can apply them. | |||||
| 168 | 28 | 36 | my @fixable = $self->fixable; | |||
| 169 | 28 | 32 | if (@fixable) { | |||
| 170 | 4 | 5 | push @lines, ''; | |||
| 171 | 4 | 5 | push @lines, 'Suggested fixes:'; | |||
| 172 | 4 | 3 | my $i = 0; | |||
| 173 | # Number each fix starting at 1 for the interactive prompt that follows. | |||||
| 174 | 4 | 8 | push @lines, sprintf(' [%d] %s', ++$i, $_->message) for @fixable; | |||
| 175 | 4 | 2 | push @lines, ''; | |||
| 176 | 4 | 4 | push @lines, 'Would you like me to apply them? [Y/n]'; | |||
| 177 | } | |||||
| 178 | ||||||
| 179 | # Join with newlines and add a trailing newline for clean shell output. | |||||
| 180 | 28 | 83 | return join("\n", @lines) . "\n"; | |||
| 181 | } | |||||
| 182 | ||||||
| 183 - 187 | =head2 render_json Returns findings as a pretty-printed JSON string (requires L<JSON::MaybeXS>). =cut | |||||
| 188 | ||||||
| 189 | sub render_json { | |||||
| 190 | 4 | 142 | my $self = shift; | |||
| 191 | # JSON::MaybeXS is loaded lazily so it is only required when --format=json. | |||||
| 192 | 4 | 12 | require JSON::MaybeXS; | |||
| 193 | # canonical => 1 sorts keys so the output is diff-friendly. | |||||
| 194 | return JSON::MaybeXS->new(utf8 => 1, pretty => 1, canonical => 1) | |||||
| 195 | 4 3 | 75 8 | ->encode([ map { $_->to_hash } $self->all_findings ]); | |||
| 196 | } | |||||
| 197 | ||||||
| 198 - 202 | =head2 render_tap Returns a TAP-format string for CI pipeline consumption. =cut | |||||
| 203 | ||||||
| 204 | sub render_tap { | |||||
| 205 | 4 | 7 | my $self = shift; | |||
| 206 | 4 | 6 | my @findings = $self->all_findings; | |||
| 207 | # TAP header declares how many tests will follow. | |||||
| 208 | 4 | 8 | my @lines = ('1..' . scalar @findings); | |||
| 209 | 4 | 6 | my $n = 0; | |||
| 210 | 4 | 5 | for my $f (@findings) { | |||
| 211 | 10 | 8 | $n++; | |||
| 212 | # pass and info severities are "ok"; error and warning are "not ok". | |||||
| 213 | 10 | 10 | my $ok = $f->severity =~ /^(?:pass|info)$/ ? 'ok' : 'not ok'; | |||
| 214 | 10 | 12 | push @lines, sprintf('%s %d - [%s] %s', $ok, $n, $f->check_name, $f->message); | |||
| 215 | } | |||||
| 216 | 4 | 37 | return join("\n", @lines) . "\n"; | |||
| 217 | } | |||||
| 218 | ||||||
| 219 | # --------------------------------------------------------------------------- | |||||
| 220 | # Private helpers | |||||
| 221 | # --------------------------------------------------------------------------- | |||||
| 222 | ||||||
| 223 | # Purpose: Find the most severe severity string in a group of findings. | |||||
| 224 | # Entry: $group is a non-empty arrayref of Finding objects. | |||||
| 225 | # Exit: String -- one of 'error', 'warning', 'info', 'pass'. | |||||
| 226 | # Side effects: None. | |||||
| 227 | sub _worst_severity { | |||||
| 228 | 35 | 335 | my $group = shift; | |||
| 229 | # Sort by numeric rank (descending) and take the first (= highest) value. | |||||
| 230 | 35 7 41 35 | 30 27 41 27 | return (sort { $SEV_RANK{$b} <=> $SEV_RANK{$a} } map { $_->severity } @{$group})[0]; | |||
| 231 | } | |||||
| 232 | ||||||
| 233 | 1; | |||||
| 234 | ||||||