lib/App/GHGen/Fixer.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 85.48%
TER3 (LCSAJ): 100.0% (9/9)
Approximate LCSAJ segments: 63

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::GHGen::Fixer;
    2: 
    3: use v5.36;
    4: use strict;
    5: use warnings;
    6: use YAML::XS qw(LoadFile DumpFile);
    7: use Path::Tiny;
    8: 
    9: use Exporter 'import';
   10: our @EXPORT_OK = qw(
   11: 	apply_fixes
   12: 	can_auto_fix
   13: 	fix_workflow
   14: 	%ACTION_UPDATES
   15: );
   16: 
   17: our $VERSION = '0.10';
   18: 
   19: =head1 PACKAGE DATA
   20: 
   21: =head2 %ACTION_UPDATES
   22: 
   23: Canonical mapping of outdated action strings to their current replacements.
   24: 
   25:     'old/action@vN' => 'old/action@vM'
   26: 
   27: This hash is the B<single source of truth> for action version knowledge.
   28: Both C<find_outdated_actions> (detection) and C<update_actions> (mutation)
   29: consume it so that detecting an outdated action always implies the ability
   30: to fix it — and vice versa.
   31: 
   32: Premise 1: the Analyzer reports every key in this hash as "outdated".
   33: Premise 2: the Fixer replaces every key with its value.
   34: Conclusion: no detected issue is unfixable, and no fix targets an undetected issue.
   35: 
   36: =cut
   37: 
   38: our %ACTION_UPDATES = (
   39: 	'actions/cache@v4'        => 'actions/cache@v5',
   40: 	'actions/cache@v3'        => 'actions/cache@v5',
   41: 	'actions/checkout@v5'     => 'actions/checkout@v6',
   42: 	'actions/checkout@v4'     => 'actions/checkout@v6',
   43: 	'actions/checkout@v2'     => 'actions/checkout@v6',
   44: 	'actions/checkout@v3'     => 'actions/checkout@v6',
   45: 	'actions/setup-node@v3'   => 'actions/setup-node@v4',
   46: 	'actions/setup-python@v4' => 'actions/setup-python@v5',
   47: 	'actions/setup-go@v4'     => 'actions/setup-go@v5',
   48: );
   49: 
   50: =head1 NAME
   51: 
   52: App::GHGen::Fixer - Auto-fix workflow issues
   53: 
   54: =head1 SYNOPSIS
   55: 
   56:     use App::GHGen::Fixer qw(apply_fixes);
   57: 
   58:     my $fixed = apply_fixes($workflow, \@issues);
   59: 
   60: =head1 FUNCTIONS
   61: 
   62: =head2 can_auto_fix($issue)
   63: 
   64: Determine whether a given issue can be automatically resolved.
   65: 
   66: =head3 Purpose
   67: 
   68: Act as a capability gate before calling C<apply_fixes>.  Returns true only
   69: for the four issue types that the Fixer knows how to handle.
   70: 
   71: =head3 Arguments
   72: 
   73: =over 4
   74: 
   75: =item C<$issue> (HashRef, required)
   76: 
   77: An issue hash with at least a C<type> key.  Recognised types are
   78: C<performance>, C<security>, C<cost>, and C<maintenance>.
   79: 
   80: =back
   81: 
   82: =head3 Returns
   83: 
   84: C<1> (true) when the issue type is auto-fixable; C<0> (false) otherwise.
   85: 
   86: =head3 Side Effects
   87: 
   88: None.  Pure predicate.
   89: 
   90: =head3 Usage Example
   91: 
   92:     if (can_auto_fix($issue)) {
   93:         apply_fixes($workflow, [$issue]);
   94:     }
   95: 
   96: =head3 API SPECIFICATION
   97: 
   98: =head4 Input
   99: 
  100:     { issue => { type => 'hashref', required => 1 } }
  101: 
  102: =head4 Output
  103: 
  104:     { type => 'scalar' }   # boolean: 1 or 0
  105: 
  106: =head3 FORMAL SPECIFICATION
  107: 
  108:     can_auto_fix : Issue → 𝔹
  109: 
  110:     FixableTypes ≔ { performance, security, cost, maintenance }
  111: 
  112:     can_auto_fix(i) ≡ i.type ∈ FixableTypes
  113: 
  114: =cut
  115: 
  116: sub can_auto_fix($issue) {
  117: 	my %fixable = (
  118: 		'performance' => 1,  # Can add caching
  119: 		'security'    => 1,  # Can update action versions and add permissions
  120: 		'cost'        => 1,  # Can add concurrency, filters
  121: 		'maintenance' => 1,  # Can update runners
  122: 	);
  123: 

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

124: return $fixable{$issue->{type}} // 0; 125: } 126: 127: =head2 apply_fixes($workflow, $issues) 128: 129: Apply all auto-fixable changes from C<$issues> directly to C<$workflow>. 130: 131: =head3 Purpose 132: 133: Iterate over C<$issues>, skip issues that are not auto-fixable, and call 134: the appropriate internal fix routine for each fixable type/message 135: combination. Modifies C<$workflow> in place. 136: 137: =head3 Arguments 138: 139: =over 4 140: 141: =item C<$workflow> (HashRef, required) 142: 143: The parsed workflow hash to be mutated. 144: 145: =item C<$issues> (ArrayRef[HashRef], required) 146: 147: The issues to process. Each must have C<type> and C<message> keys. 148: 149: =back 150: 151: =head3 Returns 152: 153: The number of individual fix operations applied (an integer ≥ 0). 154: 155: =head3 Side Effects 156: 157: Modifies C<$workflow> in place. 158: 159: =head3 Usage Example 160: 161: my $n = apply_fixes($workflow, \@issues); 162: say "$n fix(es) applied."; 163: 164: =head3 API SPECIFICATION 165: 166: =head4 Input 167: 168: { 169: workflow => { type => 'hashref', required => 1 }, 170: issues => { type => 'arrayref', required => 1 }, 171: } 172: 173: =head4 Output 174: 175: { type => 'scalar' } # non-negative integer 176: 177: =head3 FORMAL SPECIFICATION 178: 179: apply_fixes : Workflow × seq Issue → â„• 180: 181: applied ≔ ∑ { fix(w, i) ∣ i ∈ issues, can_auto_fix(i) } 182: result ≔ applied 183: 184: Mutates w by applying each fix in sequence. 185: 186: =cut 187: 188: # File-scoped dispatch table for apply_fixes. 189: # Premise: can_auto_fix guarantees type ∈ {performance,security,cost,maintenance}. 190: # Conclusion: we only need to match on (type, message pattern) — no further type 191: # validation is required inside the loop. 192: # Each rule: [ type_string, compiled_pattern, handler_sub ]. 193: # Using anonymous delegates (not direct coderefs) so symbol-table mocks work. 194: my @_APPLY_RULES = ( 195: [ 'performance', qr/caching/, sub($wf) { add_caching($wf) } ], 196: [ 'security', qr/unpinned/, sub($wf) { fix_unpinned_actions($wf) } ], 197: [ 'security', qr/permissions/, sub($wf) { add_permissions($wf) } ], 198: [ 'maintenance', qr/outdated action/, sub($wf) { update_actions($wf) } ], 199: [ 'cost', qr/concurrency/, sub($wf) { add_concurrency($wf) } ], 200: [ 'cost', qr/triggers/, sub($wf) { add_trigger_filters($wf) } ], 201: [ 'maintenance', qr/runner/, sub($wf) { update_runners($wf) } ], 202: [ 'performance', qr/missing timeout-minutes/, sub($wf) { add_missing_timeout($wf) } ], 203: ); 204: 205: sub apply_fixes($workflow, $issues) { 206 → 208 → 222 206: my $modified = 0; 207: 208: for my $issue (@$issues) { 209: # Guard: can_auto_fix is a complete predicate — if it returns false the 210: # type is guaranteed not in the dispatch table, so skip immediately. 211: next unless can_auto_fix($issue); 212: 213: for my $rule (@_APPLY_RULES) { 214: my ($type, $pattern, $handler) = @$rule;

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

215: if ($issue->{type} eq $type && $issue->{message} =~ $pattern) { 216: $modified += $handler->($workflow); 217: last; # first-match-wins; one issue maps to exactly one fix 218: } 219: } 220: } 221:

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

222: return $modified; 223: } 224: 225: =head2 fix_workflow($file, $issues) 226: 227: Load a workflow YAML file, apply fixes, and write it back to disk. 228: 229: =head3 Purpose 230: 231: Persist the results of C<apply_fixes> by reading the workflow from C<$file> 232: with C<YAML::XS::LoadFile>, calling C<apply_fixes>, and rewriting the file 233: with C<YAML::XS::DumpFile> when at least one fix was applied. 234: 235: =head3 Arguments 236: 237: =over 4 238: 239: =item C<$file> (Str, required) 240: 241: Path to a YAML workflow file. Passed directly to C<YAML::XS::LoadFile>. 242: 243: =item C<$issues> (ArrayRef[HashRef], required) 244: 245: Issues to fix, each with C<type> and C<message> keys. 246: 247: =back 248: 249: =head3 Returns 250: 251: The number of fixes applied (an integer ≥ 0). The file is only rewritten 252: when the count is greater than zero. 253: 254: =head3 Side Effects 255: 256: Reads C<$file> from disk; rewrites C<$file> in place when fixes are applied. 257: 258: =head3 Usage Example 259: 260: my $n = fix_workflow('.github/workflows/ci.yml', \@issues); 261: say "$n fix(es) written to ci.yml."; 262: 263: =head3 API SPECIFICATION 264: 265: =head4 Input 266: 267: { 268: file => { type => 'scalar', required => 1 }, 269: issues => { type => 'arrayref', required => 1 }, 270: } 271: 272: =head4 Output 273: 274: { type => 'scalar' } # non-negative integer 275: 276: =head3 FORMAL SPECIFICATION 277: 278: fix_workflow : Path × seq Issue → â„• 279: 280: w ≔ LoadFile(file) 281: fixes ≔ apply_fixes(w, issues) 282: fixes > 0 → DumpFile(file, w) 283: 284: result ≔ fixes 285: 286: =cut 287: 288: sub fix_workflow($file, $issues) { 289 → 292 → 296 289: my $workflow = LoadFile($file); 290: my $fixes = apply_fixes($workflow, $issues); 291:

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

292: if ($fixes > 0) { 293: DumpFile($file, $workflow); 294: } 295:

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

296: return $fixes; 297: } 298: 299: # Fix implementations 300: 301: sub add_caching($workflow) { 302 → 305 → 329 302: my $jobs = $workflow->{jobs} or return 0; 303: my $modified = 0; 304: 305: for my $job (values %$jobs) { 306: my $steps = $job->{steps} or next; 307: 308: # Check if already has caching 309: my $has_cache = grep { $_->{uses} && $_->{uses} =~ /actions\/cache/ } @$steps; 310: next if $has_cache; 311: 312: # Detect project type and add appropriate cache 313: my $cache_step = detect_and_create_cache_step($steps); 314: next unless $cache_step; 315: 316: # Insert cache step after checkout 317: my $insert_at = 0; 318: for my $i (0 .. $#$steps) {

Mutants (Total: 1, Killed: 0, Survived: 1)
319: if ($steps->[$i]->{uses} && $steps->[$i]->{uses} =~ /actions\/checkout/) { 320: $insert_at = $i + 1; 321: last; 322: } 323: } 324: 325: splice @$steps, $insert_at, 0, $cache_step; 326: $modified++; 327: } 328:

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

329: return $modified; 330: } 331: 332: sub detect_and_create_cache_step($steps) { 333: # Detect project type from steps 334 → 334 → 389 334: for my $step (@$steps) { 335: my $run = $step->{run} // ''; 336: 337: # Node.js

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

338: if ($run =~ /npm (?:install|ci)/ || ($step->{uses} && $step->{uses} =~ /setup-node/)) { 339: return { 340: name => 'Cache dependencies', 341: uses => 'actions/cache@v5', 342: with => { 343: path => '~/.npm', 344: key => '${{ runner.os }}-node-${{ hashFiles(\'**/package-lock.json\') }}', 345: 'restore-keys' => '${{ runner.os }}-node-', 346: }, 347: }; 348: } 349: 350: # Python

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

351: if ($run =~ /pip install/ || ($step->{uses} && $step->{uses} =~ /setup-python/)) { 352: return { 353: name => 'Cache pip packages', 354: uses => 'actions/cache@v5', 355: with => { 356: path => '~/.cache/pip', 357: key => '${{ runner.os }}-pip-${{ hashFiles(\'**/requirements.txt\') }}', 358: 'restore-keys' => '${{ runner.os }}-pip-', 359: }, 360: }; 361: } 362: 363: # Rust

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

364: if ($run =~ /cargo (?:build|test)/) { 365: return { 366: name => 'Cache cargo', 367: uses => 'actions/cache@v5', 368: with => { 369: path => "~/.cargo/bin/\n~/.cargo/registry/index/\n~/.cargo/registry/cache/\n~/.cargo/git/db/\ntarget/", 370: key => '${{ runner.os }}-cargo-${{ hashFiles(\'**/Cargo.lock\') }}', 371: }, 372: }; 373: } 374: 375: # Go

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

376: if ($run =~ /go (?:build|test)/ || ($step->{uses} && $step->{uses} =~ /setup-go/)) { 377: return { 378: name => 'Cache Go modules', 379: uses => 'actions/cache@v5', 380: with => { 381: path => '~/go/pkg/mod', 382: key => '${{ runner.os }}-go-${{ hashFiles(\'**/go.sum\') }}', 383: 'restore-keys' => '${{ runner.os }}-go-', 384: }, 385: }; 386: } 387: } 388:

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

389: return undef; 390: } 391: 392: sub fix_unpinned_actions($workflow) { 393 → 396 → 411 393: my $jobs = $workflow->{jobs} or return 0; 394: my $modified = 0; 395: 396: for my $job (values %$jobs) { 397: my $steps = $job->{steps} or next; 398: for my $step (@$steps) { 399: next unless $step->{uses}; 400:

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

401: if ($step->{uses} =~ /^(.+?)\@(?:master|main)$/) { 402: my $action = $1; 403: # Map to appropriate version 404: my $version = get_latest_version($action); 405: $step->{uses} = "$action\@$version"; 406: $modified++; 407: } 408: } 409: } 410:

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

411: return $modified; 412: } 413: 414: sub add_permissions($workflow) {

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

415: return 0 if $workflow->{permissions}; 416: 417: $workflow->{permissions} = { contents => 'read' };

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

418: return 1; 419: } 420: 421: sub update_actions($workflow) { 422 → 427 → 440 422: my $jobs = $workflow->{jobs} or return 0; 423: my $modified = 0; 424: 425: # Premise: %ACTION_UPDATES is the canonical version table (defined above). 426: # Conclusion: this function and find_outdated_actions (Analyzer) are always in sync. 427: for my $job (values %$jobs) { 428: my $steps = $job->{steps} or next; 429: for my $step (@$steps) { 430: next unless $step->{uses}; 431: for my $old (keys %ACTION_UPDATES) {

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

432: if ($step->{uses} =~ /^\Q$old\E/) { 433: $step->{uses} = $ACTION_UPDATES{$old}; 434: $modified++; 435: } 436: } 437: } 438: } 439:

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

440: return $modified; 441: } 442: 443: sub add_concurrency($workflow) {

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

444: return 0 if $workflow->{concurrency}; 445: 446: $workflow->{concurrency} = { 447: group => '${{ github.workflow }}-${{ github.ref }}', 448: 'cancel-in-progress' => 'true', 449: };

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

450: return 1; 451: } 452: 453: sub add_trigger_filters($workflow) { 454 → 458 → 477 454: my $on = $workflow->{on} or return 0; 455: my $modified = 0; 456: 457: # If 'on' is just 'push', expand it

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

458: if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) { 459: $workflow->{on} = { 460: push => { 461: branches => ['main', 'master'], 462: }, 463: pull_request => { 464: branches => ['main', 'master'], 465: }, 466: }; 467: $modified++; 468: } 469: elsif (ref $on eq 'HASH' && $on->{push} && ref $on->{push} eq '') { 470: # 'push' with no filters 471: $on->{push} = { 472: branches => ['main', 'master'], 473: }; 474: $modified++; 475: } 476:

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

477: return $modified; 478: } 479: 480: sub add_missing_timeout($workflow) { 481 → 484 → 495 481: my $jobs = $workflow->{jobs} or return 0; 482: my $modified = 0; 483: 484: for my $job_name (keys %$jobs) { 485: my $job = $jobs->{$job_name}; 486: 487: # Skip if timeout already exists 488: next if exists $job->{'timeout-minutes'}; 489: 490: # Insert default timeout 491: $job->{'timeout-minutes'} = 30; 492: $modified++; 493: } 494:

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

495: return $modified; 496: } 497: 498: sub update_runners($workflow) { 499 → 509 → 518 499: my $jobs = $workflow->{jobs} or return 0; 500: my $modified = 0; 501: 502: my %runner_updates = ( 503: 'ubuntu-18.04' => 'ubuntu-latest', 504: 'ubuntu-16.04' => 'ubuntu-latest', 505: 'macos-10.15' => 'macos-latest', 506: 'windows-2016' => 'windows-latest', 507: ); 508: 509: for my $job (values %$jobs) { 510: my $runs_on = $job->{'runs-on'} or next; 511:

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

512: if (exists $runner_updates{$runs_on}) { 513: $job->{'runs-on'} = $runner_updates{$runs_on}; 514: $modified++; 515: } 516: } 517:

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

518: return $modified; 519: } 520: 521: sub get_latest_version($action) { 522: my %versions = ( 523: 'actions/checkout' => 'v6', 524: 'actions/cache' => 'v5', 525: 'actions/setup-node' => 'v4', 526: 'actions/setup-python' => 'v5', 527: 'actions/setup-go' => 'v5', 528: 'actions/upload-artifact' => 'v4', 529: 'actions/download-artifact' => 'v4', 530: ); 531:

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

532: return $versions{$action} // 'v4'; # Default fallback 533: } 534: 535: =head1 AUTHOR 536: 537: Nigel Horne E<lt>njh@nigelhorne.comE<gt> 538: 539: L<https://github.com/nigelhorne> 540: 541: =head1 COPYRIGHT AND LICENSE 542: 543: Copyright 2025-2026 Nigel Horne. 544: 545: Usage is subject to license terms. 546: 547: The license terms of this software are as follows: 548: 549: =cut 550: 551: 1;