lib/App/Project/Doctor/Report.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (5/5)
Approximate LCSAJ segments: 29

LCSAJ Legend

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.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    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