TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (5/5)
Approximate LCSAJ segments: 29
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
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: use strict; 8: use warnings; 9: use autodie qw(:all); 10: 11: # croak dies at the caller's location; carp warns there. 12: use Carp qw(croak carp); 13: # Readonly prevents accidental mutation of constants after they are defined. 14: use Readonly; 15: # blessed() lets us confirm that add_findings() received real Finding objects. 16: use Scalar::Util qw(blessed); 17: # validate_strict enforces parameter schemas; not used by new() (takes no args). 18: 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: my ($class, %args) = @_; 49: return bless { _findings => [] }, $class;Mutants (Total: 2, Killed: 2, Survived: 0)
50: } 51: 52: # --------------------------------------------------------------------------- 53: # Mutator 54: # --------------------------------------------------------------------------- 55: 56: =head2 add_findings( @findings ) 57: 58: Appends one or more L<App::Project::Doctor::Finding> objects. 59: Croaks on non-Finding arguments. 60: 61: =cut 62: 63: sub add_findings { ●64 → 65 → 73 64: my ($self, @findings) = @_; 65: 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: croak 'Expected an App::Project::Doctor::Finding' 69: unless blessed($f) && $f->isa('App::Project::Doctor::Finding'); 70: push @{ $self->{_findings} }, $f; 71: } 72: # Return $self so callers can chain: $report->add_findings(...)->render_text. 73: return $self;
Mutants (Total: 2, Killed: 2, Survived: 0)
74: } 75: 76: # --------------------------------------------------------------------------- 77: # Accessors / filters 78: # --------------------------------------------------------------------------- 79: 80: # Return every finding in insertion order (used by render_* methods). 81: sub all_findings { @{ $_[0]->{_findings} } } 82: # Return only the findings with severity 'error'. 83: sub errors { grep { $_->severity eq 'error' } @{ $_[0]->{_findings} } } 84: # Return only the findings with severity 'warning'. 85: sub warnings { grep { $_->severity eq 'warning' } @{ $_[0]->{_findings} } } 86: # Return only the findings with severity 'pass'. 87: sub passes { grep { $_->severity eq 'pass' } @{ $_[0]->{_findings} } } 88: # Return only the findings that carry an automated fix coderef. 89: sub fixable { grep { $_->is_fixable } @{ $_[0]->{_findings} } } 90: 91: # has_errors returns 1/0 (not just truthy) for type-safe callers. 92: sub has_errors { (scalar($_[0]->errors) > 0) ? 1 : 0 }
Mutants (Total: 3, Killed: 3, Survived: 0)
93: sub has_warnings { (scalar($_[0]->warnings) > 0) ? 1 : 0 }
Mutants (Total: 3, Killed: 3, Survived: 0)
94: 95: =head2 exit_code 96: 97: Returns 0 (clean) or 1 (errors present). 98: 99: =cut 100: 101: # The process should exit 1 if any finding is an error, 0 otherwise. 102: sub exit_code { $_[0]->has_errors ? 1 : 0 } 103: 104: # --------------------------------------------------------------------------- 105: # Rendering 106: # --------------------------------------------------------------------------- 107: 108: =head2 render_text( %opts ) 109: 110: Returns the full text report. Accepted options: C<verbose> (bool). 111: 112: =cut 113: 114: sub render_text { ●115 → 122 → 133 115: my ($self, %opts) = @_; 116: # verbose mode adds per-finding detail lines under each check summary. 117: 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: my (%by_check, @order); 122: for my $f ($self->all_findings) { 123: my $name = $f->check_name; 124: unless (exists $by_check{$name}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
125: # First time we see this check name -- record its position. 126: push @order, $name; 127: $by_check{$name} = []; 128: } 129: push @{ $by_check{$name} }, $f; 130: } 131: 132: # Build the output one line at a time and join at the end. ●133 → 134 → 160 133: my @lines; 134: for my $name (@order) { 135: my @group = @{ $by_check{$name} }; 136: # Choose the worst severity in this group to pick the icon. 137: my $sev = _worst_severity(\@group); 138: 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: my ($lead) = grep { $_->severity ne 'pass' } @group; 142: my $summary = $lead ? $lead->message : $group[0]->message; 143: 144: # Format: icon check-name (padded) summary message 145: push @lines, sprintf(' %-4s %-*s %s', $icon, $LABEL_WIDTH, $name, $summary); 146: 147: if ($verbose) {
Mutants (Total: 1, Killed: 1, Survived: 0)
148: # In verbose mode, print each non-pass finding with its detail. 149: for my $f (@group) { 150: # Skip pass findings in verbose mode -- they have no useful detail. 151: next if $f->severity eq 'pass'; 152: push @lines, sprintf(' -> %s', $f->message); 153: # Only print the detail line when there is something to show. 154: 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 → 169 → 180 160: my $ec = scalar($self->errors); 161: my $wc = scalar($self->warnings); 162: push @lines, ''; # Blank line before the summary. 163: 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: my @fixable = $self->fixable; 169: if (@fixable) {
Mutants (Total: 1, Killed: 1, Survived: 0)
170: push @lines, ''; 171: push @lines, 'Suggested fixes:'; 172: my $i = 0; 173: # Number each fix starting at 1 for the interactive prompt that follows. 174: push @lines, sprintf(' [%d] %s', ++$i, $_->message) for @fixable; 175: push @lines, ''; 176: 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: return join("\n", @lines) . "\n";
Mutants (Total: 2, Killed: 2, Survived: 0)
181: } 182: 183: =head2 render_json 184: 185: Returns findings as a pretty-printed JSON string (requires L<JSON::MaybeXS>). 186: 187: =cut 188: 189: sub render_json { 190: my $self = shift; 191: # JSON::MaybeXS is loaded lazily so it is only required when --format=json. 192: 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)
Mutants (Total: 2, Killed: 2, Survived: 0)
195: ->encode([ map { $_->to_hash } $self->all_findings ]); 196: } 197: 198: =head2 render_tap 199: 200: Returns a TAP-format string for CI pipeline consumption. 201: 202: =cut 203: 204: sub render_tap { ●205 → 210 → 216 205: my $self = shift; 206: my @findings = $self->all_findings; 207: # TAP header declares how many tests will follow. 208: my @lines = ('1..' . scalar @findings); 209: my $n = 0; 210: for my $f (@findings) { 211: $n++; 212: # pass and info severities are "ok"; error and warning are "not ok". 213: my $ok = $f->severity =~ /^(?:pass|info)$/ ? 'ok' : 'not ok'; 214: push @lines, sprintf('%s %d - [%s] %s', $ok, $n, $f->check_name, $f->message); 215: } 216: return join("\n", @lines) . "\n";
Mutants (Total: 2, Killed: 2, Survived: 0)
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: my $group = shift; 229: # Sort by numeric rank (descending) and take the first (= highest) value. 230: return (sort { $SEV_RANK{$b} <=> $SEV_RANK{$a} } map { $_->severity } @{$group})[0];
Mutants (Total: 2, Killed: 2, Survived: 0)
231: } 232: 233: 1; 234: 235: __END__ 236: 237: =head1 NAME 238: 239: App::Project::Doctor::Report - Aggregate and render diagnostic findings 240: 241: =head1 VERSION 242: 243: 0.02 244: 245: =head1 SYNOPSIS 246: 247: use App::Project::Doctor::Report; 248: 249: my $report = App::Project::Doctor::Report->new; 250: $report->add_findings(@findings); 251: print $report->render_text(verbose => 1); 252: exit $report->exit_code; 253: 254: =head1 DESCRIPTION 255: 256: Collects L<App::Project::Doctor::Finding> objects from all checks and renders 257: them as text, JSON, or TAP. 258: 259: =head1 CONSTRUCTOR 260: 261: =head2 new 262: 263: Creates an empty report ready to receive findings. 264: 265: =head3 API SPECIFICATION 266: 267: =head4 Input 268: 269: None. 270: 271: =head4 Output 272: 273: Blessed hashref of type C<App::Project::Doctor::Report>. 274: 275: =head3 FORMAL SPECIFICATION 276: 277: new : () -> Report 278: new () == { findings : [] } 279: 280: =head1 METHODS 281: 282: =head2 add_findings( @findings ) 283: 284: =head3 API SPECIFICATION 285: 286: =head4 Input 287: 288: @findings : List of App::Project::Doctor::Finding 289: 290: =head4 Output 291: 292: Returns C<$self> for chaining. Croaks if any element is not an 293: C<App::Project::Doctor::Finding>. 294: 295: =head2 all_findings 296: 297: Returns every accumulated Finding in insertion order. 298: 299: =head3 API SPECIFICATION 300: 301: =head4 Input 302: 303: None. 304: 305: =head4 Output 306: 307: List of C<App::Project::Doctor::Finding>. 308: 309: =head2 errors / warnings / passes 310: 311: Return the subset of accumulated findings with the matching severity. 312: 313: =head3 API SPECIFICATION 314: 315: =head4 Input 316: 317: None. 318: 319: =head4 Output 320: 321: List of C<App::Project::Doctor::Finding>. 322: 323: =head2 fixable 324: 325: Returns findings that carry an automated fix coderef. 326: 327: =head3 API SPECIFICATION 328: 329: =head4 Input 330: 331: None. 332: 333: =head4 Output 334: 335: List of C<App::Project::Doctor::Finding>. 336: 337: =head2 has_errors 338: 339: Returns 1 when the report contains at least one error-severity finding, 0 otherwise. 340: 341: =head3 API SPECIFICATION 342: 343: =head4 Input 344: 345: None. 346: 347: =head4 Output 348: 349: Integer 1 or 0. 350: 351: =head2 has_warnings 352: 353: Returns 1 when the report contains at least one warning-severity finding, 0 otherwise. 354: 355: =head3 API SPECIFICATION 356: 357: =head4 Input 358: 359: None. 360: 361: =head4 Output 362: 363: Integer 1 or 0. 364: 365: =head2 exit_code 366: 367: =head3 API SPECIFICATION 368: 369: =head4 Input 370: 371: None. 372: 373: =head4 Output 374: 375: Integer 0 or 1. 376: 377: =head2 render_text( %opts ) 378: 379: =head3 API SPECIFICATION 380: 381: =head4 Input 382: 383: verbose : Bool default 0 384: 385: =head4 Output 386: 387: String. 388: 389: =head2 render_json 390: 391: =head3 API SPECIFICATION 392: 393: =head4 Input 394: 395: None. 396: 397: =head4 Output 398: 399: UTF-8 JSON string. 400: 401: =head2 render_tap 402: 403: =head3 API SPECIFICATION 404: 405: =head4 Input 406: 407: None. 408: 409: =head4 Output 410: 411: TAP string. 412: 413: =head3 MESSAGES 414: 415: Code | Trigger | Resolution 416: -----|---------|---------- 417: (none currently defined) 418: 419: =head3 FORMAL SPECIFICATION 420: 421: Report == { findings : [Finding] } 422: 423: all_findings : Report -> [Finding] 424: all_findings r == findings r 425: 426: errors : Report -> [Finding] 427: errors r == { f in findings r | severity f = error } 428: 429: warnings : Report -> [Finding] 430: warnings r == { f in findings r | severity f = warning } 431: 432: passes : Report -> [Finding] 433: passes r == { f in findings r | severity f = pass } 434: 435: fixable : Report -> [Finding] 436: fixable r == { f in findings r | is_fixable f } 437: 438: has_errors : Report -> Bool 439: has_errors r == |errors r| > 0 440: 441: has_warnings : Report -> Bool 442: has_warnings r == |warnings r| > 0 443: 444: exit_code : Report -> {0,1} 445: exit_code r == if has_errors r then 1 else 0 446: 447: =head1 AUTHOR 448: 449: Nigel Horne C<< <njh@nigelhorne.com> >> 450: 451: =head1 LICENSE 452: 453: Copyright (C) 2026 Nigel Horne. 454: This library is free software; you can redistribute it and/or modify 455: it under the same terms as Perl itself. 456: 457: =cut