lib/App/Project/Doctor/Check/GitHubActions.pm

Structural Coverage (Approximate)

TER1 (Statement): 89.06%
TER2 (Branch): 87.50%
TER3 (LCSAJ): 100.0% (4/4)
Approximate LCSAJ segments: 17

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::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: my @raw = $linter->lint($abs_path); 114: 115: # Normalise: linter may return hashrefs OR plain strings. 116: return map {

Mutants (Total: 2, Killed: 2, Survived: 0)

117: ref $_ eq 'HASH' 118: ? { 119: # Use the message key when present; fall back to a generic string. 120: message => $_->{message} // '(unknown lint error)', 121: # Only include 'line' when the linter actually provided one. 122: (defined $_->{line} ? (line => $_->{line}) : ()), 123: } 124: : { message => "$_" } # Plain string errors have no line number. 125: } @raw; 126: } 127: 128: # Purpose: Return a coderef that generates a GitHub Actions workflow file. 129: # Entry: $ctx is the current App::Project::Doctor::Context. 130: # Exit: Coderef ($ctx) -> void; creates .github/workflows/perl-ci.yml. 131: # Side effects: Creates directories and files under $ctx->root when called. 132: sub _fix_generate { 133: my $ctx = shift; 134: # Return the fix as a closure; it captures $ctx but does not run yet. 135: return sub {

Mutants (Total: 2, Killed: 2, Survived: 0)

136: my $root = $ctx->root; 137: require App::GHGen::Generator; 138: # generate_workflow returns a YAML string or undef on failure. 139: my $yaml = App::GHGen::Generator::generate_workflow('perl'); 140: return unless $yaml; # Nothing to write if generation failed. 141: # Build the target directory path in a cross-platform way. 142: my $wf_dir = File::Spec->catdir($root, '.github', 'workflows'); 143: require File::Path; 144: # make_path creates .github/ and .github/workflows/ if they don't exist. 145: File::Path::make_path($wf_dir); 146: # Write the generated YAML to the standard workflow file name. 147: open my $fh, '>', File::Spec->catfile($wf_dir, 'perl-ci.yml'); 148: print {$fh} $yaml; 149: close $fh; 150: }; 151: } 152: 153: 1; 154: 155: __END__ 156: 157: =head1 NAME 158: 159: App::Project::Doctor::Check::GitHubActions - Validate GitHub Actions workflows 160: 161: =head1 DESCRIPTION 162: 163: Uses L<App::Workflow::Lint> to validate every C<.yml>/C<.yaml> file under 164: C<.github/workflows/>. A fix via L<App::GHGen::Generator> is offered when no files exist. 165: 166: =head1 METHODS 167: 168: =head2 check( $context ) 169: 170: Validates all GitHub Actions workflow YAML files. 171: 172: =head3 API SPECIFICATION 173: 174: =head4 Input 175: 176: $context : App::Project::Doctor::Context 177: 178: =head4 Output 179: 180: List of App::Project::Doctor::Finding -- 181: info when .github/workflows/ is absent (CI check owns the error), 182: warning (fixable) when directory exists but contains no YAML, 183: one error per lint violation found, 184: pass when all workflow files validate cleanly. 185: 186: =head3 MESSAGES 187: 188: Code | Trigger | Resolution 189: -----|-------------------------------|------------------------------------- 190: G001 | workflows/ has no YAML files | Fix generates a workflow via App::GHGen::Generator 191: G002 | Lint error in a workflow file | Edit the file to correct syntax 192: 193: =head3 FORMAL SPECIFICATION 194: 195: check : Context -> [Finding] 196: check ctx == 197: if not exists WORKFLOW_DIR then [info] 198: else if |workflow_files| = 0 then [warning+fix] 199: else concat { lint_errors f | f <- workflow_files } 200: ++ (if all clean then [pass] else []) 201: 202: =head1 AUTHOR 203: 204: Nigel Horne C<< <njh@nigelhorne.com> >> 205: 206: =head1 LICENSE 207: 208: Copyright (C) 2026 Nigel Horne. 209: This library is free software; you can redistribute it and/or modify 210: it under the same terms as Perl itself. 211: 212: =cut