TER1 (Statement): 89.06%
TER2 (Branch): 87.50%
TER3 (LCSAJ): 100.0% (4/4)
Approximate LCSAJ segments: 17
● 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::Check::GitHubActions; 2: 3: # This check validates that GitHub Actions workflow files are present and 4: # syntactically correct. It uses App::Workflow::Lint for the actual YAML 5: # validation. Note: it does NOT own the "missing CI entirely" error -- 6: # that belongs to Check::CI. When the workflow directory is absent this 7: # check emits only an informational finding. 8: 9: use strict; 10: use warnings; 11: use autodie qw(:all); 12: 13: # Inherit the standard check interface from Check::Base. 14: use parent -norequire, 'App::Project::Doctor::Check::Base'; 15: 16: # croak dies at the caller's location; carp warns there. 17: use Carp qw(croak carp); 18: # File::Spec builds OS-portable paths for the fix closure. 19: use File::Spec; 20: # Readonly makes constants truly immutable at runtime. 21: use Readonly; 22: 23: our $VERSION = '0.02'; 24: 25: # The directory under the repo root where workflow files live. 26: Readonly::Scalar my $WORKFLOW_DIR => '.github/workflows'; 27: 28: # Short name used in Finding.check_name and the text-report column. 29: sub name { 'GitHub Actions' } 30: # One-line description for --help and verbose output. 31: sub description { 'Workflow files are present and lint cleanly.' } 32: # This check can offer a fix (generate a workflow) when no files exist. 33: sub can_fix { 1 } 34: # Run after CI (20) but before Meta (30). 35: sub order { 25 } 36: 37: sub check { ●38 → 46 → 54 38: my ($self, $ctx) = @_; 39: # Guard: require a proper Context object with filesystem helpers. 40: croak 'check requires an App::Project::Doctor::Context' unless ref $ctx; 41: 42: my @findings; 43: 44: # If the workflow directory doesn't exist at all, emit info and stop. 45: # Check::CI already emits the error for a missing CI setup. 46: unless ($ctx->has_file($WORKFLOW_DIR)) {Mutants (Total: 1, Killed: 1, Survived: 0)
47: return _f(
Mutants (Total: 2, Killed: 2, Survived: 0)
48: severity => 'info', 49: message => 'No .github/workflows/ -- skipping GitHub Actions validation.', 50: ); 51: } 52: 53: # The directory exists; find all YAML files inside it. ●54 → 57 → 66 54: my $workflow_files = $ctx->find_files($WORKFLOW_DIR, qr/\.ya?ml$/i); 55: 56: # Directory present but empty of YAML: warn and offer to generate a default workflow. 57: unless (@{$workflow_files}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
58: return _f(
Mutants (Total: 2, Killed: 2, Survived: 0)
59: severity => 'warning', 60: message => '.github/workflows/ exists but contains no YAML files.', 61: fix => _fix_generate($ctx), 62: ); 63: } 64: 65: # Lint each workflow file and collect errors. ●66 → 66 → 80 66: for my $wf (@{$workflow_files}) { 67: my @errors = _lint_workflow($ctx->abs_path($wf)); 68: for my $err (@errors) { 69: # Each lint error becomes a separate Finding with file and optional line. 70: push @findings, _f( 71: severity => 'error', 72: message => "Workflow '$wf': $err->{message}", 73: file => $wf, 74: defined $err->{line} ? (line => $err->{line}) : (), 75: ); 76: } 77: } 78: 79: # Only add a pass finding when no errors were collected. ●80 → 80 → 87 80: unless (@findings) {
Mutants (Total: 1, Killed: 1, Survived: 0)
81: push @findings, _f( 82: severity => 'pass', 83: message => sprintf('%d workflow file(s) validated OK.', scalar @{$workflow_files}), 84: ); 85: } 86: 87: return @findings;
Mutants (Total: 2, Killed: 2, Survived: 0)
88: } 89: 90: # --------------------------------------------------------------------------- 91: # Private helpers 92: # --------------------------------------------------------------------------- 93: 94: # Purpose: Create a Finding with check_name pre-filled to 'GitHub Actions'. 95: # Entry: %args is a valid Finding constructor argument list. 96: # Exit: App::Project::Doctor::Finding object. 97: # Side effects: None. 98: sub _f { 99: require App::Project::Doctor::Finding; 100: return App::Project::Doctor::Finding->new(check_name => 'GitHub Actions', @_);
Mutants (Total: 2, Killed: 2, Survived: 0)
101: } 102: 103: # Purpose: Run App::Workflow::Lint against a single workflow file and 104: # normalise its output into a consistent list of error hashrefs. 105: # Entry: $abs_path is the absolute path to the YAML file being linted. 106: # Exit: List of hashrefs, each with 'message' (string) and optional 'line' (int). 107: # Side effects: Loads App::Workflow::Lint if not already in memory. 108: sub _lint_workflow { 109: my $abs_path = shift; 110: require App::Workflow::Lint; 111: # App::Workflow::Lint is instantiated fresh per call to avoid any state leakage. 112: my $linter = App::Workflow::Lint->new; 113: # The public API is check_file(), not lint() -- see App::Workflow::Lint docs. 114: my @raw = $linter->check_file($abs_path); 115: 116: # Normalise: linter may return hashrefs OR plain strings. 117: return map {
Mutants (Total: 2, Killed: 2, Survived: 0)
118: ref $_ eq 'HASH' 119: ? { 120: # Use the message key when present; fall back to a generic string. 121: message => $_->{message} // '(unknown lint error)', 122: # Only include 'line' when the linter actually provided one. 123: (defined $_->{line} ? (line => $_->{line}) : ()), 124: } 125: : { message => "$_" } # Plain string errors have no line number. 126: } @raw; 127: } 128: 129: # Purpose: Return a coderef that generates a GitHub Actions workflow file. 130: # Entry: $ctx is the current App::Project::Doctor::Context. 131: # Exit: Coderef ($ctx) -> void; creates .github/workflows/perl-ci.yml. 132: # Side effects: Creates directories and files under $ctx->root when called. 133: sub _fix_generate { 134: my $ctx = shift; 135: # Return the fix as a closure; it captures $ctx but does not run yet. 136: return sub {
Mutants (Total: 2, Killed: 2, Survived: 0)
137: my $root = $ctx->root; 138: require App::GHGen::Generator; 139: # generate_workflow returns a YAML string or undef on failure. 140: my $yaml = App::GHGen::Generator::generate_workflow('perl'); 141: return unless $yaml; # Nothing to write if generation failed. 142: # Build the target directory path in a cross-platform way. 143: my $wf_dir = File::Spec->catdir($root, '.github', 'workflows'); 144: require File::Path; 145: # make_path creates .github/ and .github/workflows/ if they don't exist. 146: File::Path::make_path($wf_dir); 147: # Write the generated YAML to the standard workflow file name. 148: open my $fh, '>', File::Spec->catfile($wf_dir, 'perl-ci.yml'); 149: print {$fh} $yaml; 150: close $fh; 151: }; 152: } 153: 154: 1; 155: 156: __END__ 157: 158: =head1 NAME 159: 160: App::Project::Doctor::Check::GitHubActions - Validate GitHub Actions workflows 161: 162: =head1 DESCRIPTION 163: 164: Uses L<App::Workflow::Lint> to validate every C<.yml>/C<.yaml> file under 165: C<.github/workflows/>. A fix via L<App::GHGen::Generator> is offered when no files exist. 166: 167: =head1 METHODS 168: 169: =head2 check( $context ) 170: 171: Validates all GitHub Actions workflow YAML files. 172: 173: =head3 API SPECIFICATION 174: 175: =head4 Input 176: 177: $context : App::Project::Doctor::Context 178: 179: =head4 Output 180: 181: List of App::Project::Doctor::Finding -- 182: info when .github/workflows/ is absent (CI check owns the error), 183: warning (fixable) when directory exists but contains no YAML, 184: one error per lint violation found, 185: pass when all workflow files validate cleanly. 186: 187: =head3 MESSAGES 188: 189: Code | Trigger | Resolution 190: -----|-------------------------------|------------------------------------- 191: G001 | workflows/ has no YAML files | Fix generates a workflow via App::GHGen::Generator 192: G002 | Lint error in a workflow file | Edit the file to correct syntax 193: 194: =head3 FORMAL SPECIFICATION 195: 196: check : Context -> [Finding] 197: check ctx == 198: if not exists WORKFLOW_DIR then [info] 199: else if |workflow_files| = 0 then [warning+fix] 200: else concat { lint_errors f | f <- workflow_files } 201: ++ (if all clean then [pass] else []) 202: 203: =head1 AUTHOR 204: 205: Nigel Horne C<< <njh@nigelhorne.com> >> 206: 207: =head1 LICENSE 208: 209: Copyright (C) 2026 Nigel Horne. 210: This library is free software; you can redistribute it and/or modify 211: it under the same terms as Perl itself. 212: 213: =cut