TER1 (Statement): 100.00%
TER2 (Branch): 85.48%
TER3 (LCSAJ): 100.0% (9/9)
Approximate LCSAJ segments: 63
● 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::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) {
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: 1, Killed: 0, Survived: 1)
- COND_INV_318_13: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (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;