TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): -
Approximate LCSAJ segments: 11
● 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::Finding; 2: 3: # A Finding is a single diagnostic result produced by a check plugin. 4: # It carries a severity level (error/warning/pass/info), a human-readable 5: # message, an optional automated fix coderef, and optional file/line location. 6: 7: use strict; 8: use warnings; 9: use autodie qw(:all); 10: 11: # croak reports errors at the caller's location rather than inside this module. 12: use Carp qw(croak carp); 13: # Params::Get normalises @_ into a hashref, handling both hash and hashref args. 14: use Params::Get; 15: # validate_strict enforces the parameter schema and throws on any violation. 16: use Params::Validate::Strict qw(validate_strict); 17: # Readonly creates truly immutable constants -- assigning to them throws at runtime. 18: use Readonly; 19: 20: our $VERSION = '0.02'; 21: 22: # --------------------------------------------------------------------------- 23: # Constants 24: # --------------------------------------------------------------------------- 25: 26: # Maps each severity to the short bracketed icon shown in text-report lines. 27: Readonly::Hash my %SEVERITY_ICON => ( 28: error => '[X]', # Something is broken and must be fixed 29: warning => '[!]', # Something is suspicious and should be reviewed 30: pass => '[v]', # This item is healthy 31: info => '[i]', # Informational; no action required 32: ); 33: 34: # Used by new() to reject unknown severity strings before storing them. 35: # The keys here must exactly match the keys in %SEVERITY_ICON above. 36: Readonly::Hash my %VALID_SEVERITY => map { $_ => 1 } qw(error warning pass info); 37: 38: # --------------------------------------------------------------------------- 39: # Constructor 40: # --------------------------------------------------------------------------- 41: 42: sub new { 43: my $class = shift; # The package name, e.g. 'App::Project::Doctor::Finding' 44: 45: # Check 'message' before running validate_strict so the caller gets our 46: # descriptive error rather than a generic Params::Validate type error. 47: my %raw = @_; 48: croak 'message must be a non-empty string' 49: unless defined $raw{message} && length $raw{message}; 50: 51: # validate_strict applies defaults, enforces types, and throws immediately 52: # if anything is wrong. It never returns undef -- it either returns the 53: # validated hashref or dies. 54: my $args = validate_strict( 55: schema => { 56: severity => { type => 'scalar', optional => 1, default => 'info' }, 57: message => { type => 'scalar' }, 58: detail => { type => 'scalar', optional => 1, default => '' }, 59: fix => { type => 'coderef', optional => 1 }, 60: check_name => { type => 'scalar', optional => 1, default => 'Unknown' }, 61: file => { type => 'scalar', optional => 1, default => '' }, 62: line => { type => 'integer', optional => 1, min => 1 }, 63: }, 64: args => Params::Get::get_params(undef, \@_) || {}, 65: ); 66: 67: # validate_strict only checks that severity is a scalar; we also need to 68: # confirm it is one of our four known values. 69: croak "Invalid severity '$args->{severity}'" 70: unless $VALID_SEVERITY{ $args->{severity} }; 71: 72: # Bless the validated args hashref and return the new object. 73: return bless $args, $class;Mutants (Total: 2, Killed: 2, Survived: 0)
74: } 75: 76: # --------------------------------------------------------------------------- 77: # Accessors (all read-only -- attributes are set once in new() and never changed) 78: # --------------------------------------------------------------------------- 79: 80: # The importance level of this finding: error, warning, pass, or info. 81: sub severity { $_[0]->{severity} } 82: # The short human-readable description of the problem or result. 83: sub message { $_[0]->{message} } 84: # An optional longer explanation; empty string when not set. 85: sub detail { $_[0]->{detail} } 86: # The optional coderef called by Fixer to resolve the issue. 87: sub fix { $_[0]->{fix} } 88: # The name of the check that produced this finding (e.g. 'Tests'). 89: sub check_name { $_[0]->{check_name} } 90: # Relative path to the affected file; empty string when not applicable. 91: sub file { $_[0]->{file} } 92: # Line number in the affected file; undef when not applicable. 93: sub line { $_[0]->{line} } 94: 95: # --------------------------------------------------------------------------- 96: # Public methods 97: # --------------------------------------------------------------------------- 98: 99: =head2 is_fixable 100: 101: Returns 1 when this finding carries an automated fix coderef, 0 otherwise. 102: 103: =cut 104: 105: # Return exactly 1 or 0 (not just truthy/falsy) so type-checked callers are happy. 106: sub is_fixable { defined $_[0]->{fix} ? 1 : 0 } 107: 108: # has_fix is a synonym for is_fixable kept for backward compatibility. 109: # Both methods are part of the public API and must always agree. 110: sub has_fix { defined $_[0]->{fix} ? 1 : 0 } 111: 112: =head2 icon 113: 114: Returns the bracketed ASCII status icon for this finding's severity. 115: 116: =cut 117: 118: # Severity is always valid here because new() checked it against %VALID_SEVERITY, 119: # and all valid severities have a matching entry in %SEVERITY_ICON. 120: sub icon { $SEVERITY_ICON{ $_[0]->{severity} } } 121: 122: =head2 to_hash 123: 124: Serialises the finding to a plain hashref for JSON encoding. 125: The C<fix> coderef is omitted. 126: 127: =cut 128: 129: sub to_hash { 130: my $self = shift; 131: # Build the base hashref with all fields that are always present. 132: my %h = ( 133: severity => $self->severity, 134: message => $self->message, 135: detail => $self->detail, 136: check_name => $self->check_name, 137: file => $self->file, 138: ); 139: # Only include 'line' when it was actually set; absent means "unknown location". 140: $h{line} = $self->line if defined $self->line; 141: return \%h;
Mutants (Total: 2, Killed: 2, Survived: 0)
142: } 143: 144: 1; 145: 146: __END__ 147: 148: =head1 NAME 149: 150: App::Project::Doctor::Finding - A single diagnostic finding produced by a check 151: 152: =head1 VERSION 153: 154: 0.02 155: 156: =head1 SYNOPSIS 157: 158: use App::Project::Doctor::Finding; 159: 160: my $f = App::Project::Doctor::Finding->new( 161: severity => 'error', 162: message => 'No test files found under t/', 163: check_name => 'Tests', 164: fix => sub { 165: my $ctx = shift; 166: # scaffold a basic test file 167: }, 168: ); 169: 170: printf "%s %s\n", $f->icon, $f->message; 171: $f->fix->($ctx) if $f->is_fixable; 172: 173: =head1 DESCRIPTION 174: 175: A value object representing one diagnostic item emitted by an 176: C<App::Project::Doctor::Check::*> plugin. Each finding carries a severity 177: level, a human-readable message, an optional file/line location, and an 178: optional automated fix coderef. 179: 180: =head1 CONSTRUCTOR 181: 182: =head2 new( %args ) 183: 184: my $finding = App::Project::Doctor::Finding->new( 185: severity => 'error', # required: error|warning|pass|info 186: message => 'text', # required non-empty string 187: detail => '...', # optional extended explanation 188: fix => sub {...}, # optional coderef ($ctx) -> 1 189: check_name => 'Tests', # optional, default 'Unknown' 190: file => 'lib/F.pm',# optional 191: line => 42, # optional positive integer 192: ); 193: 194: Croaks on invalid severity or empty message. 195: 196: =head3 API SPECIFICATION 197: 198: =head4 Input 199: 200: severity : 'error' | 'warning' | 'pass' | 'info' default 'info' 201: message : non-empty String 202: detail : String default '' 203: fix : CodeRef ($ctx) -> 1 optional 204: check_name : String default 'Unknown' 205: file : String default '' 206: line : positive Integer optional 207: 208: =head4 Output 209: 210: Blessed hashref of type C<App::Project::Doctor::Finding>. 211: 212: =head1 ACCESSORS 213: 214: C<severity>, C<message>, C<detail>, C<fix>, C<check_name>, C<file>, C<line> 215: -- all read-only. 216: 217: =head1 METHODS 218: 219: =head2 is_fixable 220: 221: Returns 1 when C<fix> is defined, 0 otherwise. 222: 223: =head3 API SPECIFICATION 224: 225: =head4 Input 226: 227: None. 228: 229: =head4 Output 230: 231: Integer 1 or 0. 232: 233: =head2 has_fix 234: 235: Synonym for C<is_fixable>. Both are part of the public API. 236: 237: =head2 icon 238: 239: Returns the severity icon string: C<[v]> pass, C<[X]> error, C<[!]> warning, 240: C<[i]> info. 241: 242: =head3 API SPECIFICATION 243: 244: =head4 Input 245: 246: None. 247: 248: =head4 Output 249: 250: String -- one of C<[v]>, C<[X]>, C<[!]>, C<[i]>. 251: 252: =head2 to_hash 253: 254: Returns a plain hashref suitable for JSON encoding. C<fix> is excluded. 255: 256: =head3 API SPECIFICATION 257: 258: =head4 Input 259: 260: None. 261: 262: =head4 Output 263: 264: HashRef with keys: severity, message, detail, check_name, file, line (if set). 265: 266: =head3 MESSAGES 267: 268: Code | Trigger | Resolution 269: -----|-------------------------------|---------------------------- 270: F001 | message is undef or empty | Provide a non-empty message 271: F002 | severity is not a valid value | Use error|warning|pass|info 272: 273: =head3 FORMAL SPECIFICATION 274: 275: Finding == [ 276: severity : SEVERITY, 277: message : String, 278: detail : String, 279: fix : (Context -> Bool) | undefined, 280: check_name : String, 281: file : String, 282: line : N | undefined 283: ] 284: 285: SEVERITY ::= error | warning | pass | info 286: 287: is_fixable : Finding -> Bool 288: is_fixable f == (fix f /= undefined) 289: 290: =head1 LIMITATIONS 291: 292: The C<fix> coderef is not serialisable and is omitted from C<to_hash>. 293: 294: Encapsulation of private helpers is enforced by convention only; a future 295: migration to C<Sub::Private> in enforce mode is tracked as a TODO. 296: 297: =head1 AUTHOR 298: 299: Nigel Horne C<< <njh@nigelhorne.com> >> 300: 301: =head1 LICENSE 302: 303: Copyright (C) 2026 Nigel Horne. 304: This library is free software; you can redistribute it and/or modify 305: it under the same terms as Perl itself. 306: 307: =cut