File Coverage

File:blib/lib/App/GHGen/Fixer.pm
Coverage:95.7%

linestmtbrancondsubtimecode
1package App::GHGen::Fixer;
2
3
9
9
48296
11
use v5.36;
4
9
9
9
12
8
84
use strict;
5
9
9
9
12
7
159
use warnings;
6
9
8
8
10
5
105
use YAML::XS qw(LoadFile DumpFile);
7
8
8
8
9
7
90
use Path::Tiny;
8
9
8
8
8
8
5
5461
use Exporter 'import';
10our @EXPORT_OK = qw(
11        apply_fixes
12        can_auto_fix
13        fix_workflow
14        %ACTION_UPDATES
15);
16
17our $VERSION = '0.10';
18
19 - 36
=head1 PACKAGE DATA

=head2 %ACTION_UPDATES

Canonical mapping of outdated action strings to their current replacements.

    'old/action@vN' => 'old/action@vM'

This hash is the B<single source of truth> for action version knowledge.
Both C<find_outdated_actions> (detection) and C<update_actions> (mutation)
consume it so that detecting an outdated action always implies the ability
to fix it — and vice versa.

Premise 1: the Analyzer reports every key in this hash as "outdated".
Premise 2: the Fixer replaces every key with its value.
Conclusion: no detected issue is unfixable, and no fix targets an undetected issue.

=cut
37
38our %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 - 114
=head1 NAME

App::GHGen::Fixer - Auto-fix workflow issues

=head1 SYNOPSIS

    use App::GHGen::Fixer qw(apply_fixes);

    my $fixed = apply_fixes($workflow, \@issues);

=head1 FUNCTIONS

=head2 can_auto_fix($issue)

Determine whether a given issue can be automatically resolved.

=head3 Purpose

Act as a capability gate before calling C<apply_fixes>.  Returns true only
for the four issue types that the Fixer knows how to handle.

=head3 Arguments

=over 4

=item C<$issue> (HashRef, required)

An issue hash with at least a C<type> key.  Recognised types are
C<performance>, C<security>, C<cost>, and C<maintenance>.

=back

=head3 Returns

C<1> (true) when the issue type is auto-fixable; C<0> (false) otherwise.

=head3 Side Effects

None.  Pure predicate.

=head3 Usage Example

    if (can_auto_fix($issue)) {
        apply_fixes($workflow, [$issue]);
    }

=head3 API SPECIFICATION

=head4 Input

    { issue => { type => 'hashref', required => 1 } }

=head4 Output

    { type => 'scalar' }   # boolean: 1 or 0

=head3 FORMAL SPECIFICATION

    can_auto_fix : Issue → 𝔹

    FixableTypes ≔ { performance, security, cost, maintenance }

    can_auto_fix(i) ≡ i.type ∈ FixableTypes

=cut
115
116
43
43
43
3247
24
16
sub can_auto_fix($issue) {
117
43
54
        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
124
43
80
        return $fixable{$issue->{type}} // 0;
125}
126
127 - 186
=head2 apply_fixes($workflow, $issues)

Apply all auto-fixable changes from C<$issues> directly to C<$workflow>.

=head3 Purpose

Iterate over C<$issues>, skip issues that are not auto-fixable, and call
the appropriate internal fix routine for each fixable type/message
combination.  Modifies C<$workflow> in place.

=head3 Arguments

=over 4

=item C<$workflow> (HashRef, required)

The parsed workflow hash to be mutated.

=item C<$issues> (ArrayRef[HashRef], required)

The issues to process.  Each must have C<type> and C<message> keys.

=back

=head3 Returns

The number of individual fix operations applied (an integer ≥ 0).

=head3 Side Effects

Modifies C<$workflow> in place.

=head3 Usage Example

    my $n = apply_fixes($workflow, \@issues);
    say "$n fix(es) applied.";

=head3 API SPECIFICATION

=head4 Input

    {
        workflow => { type => 'hashref',  required => 1 },
        issues   => { type => 'arrayref', required => 1 },
    }

=head4 Output

    { type => 'scalar' }   # non-negative integer

=head3 FORMAL SPECIFICATION

    apply_fixes : Workflow × seq Issue → â„•

    applied ≔ ∑ { fix(w, i) ∣ i ∈ issues, can_auto_fix(i) }
    result  â‰” applied

    Mutates w by applying each fix in sequence.

=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.
194my @_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
25
25
25
25
11512
19
12
15
sub apply_fixes($workflow, $issues) {
206
25
15
        my $modified = 0;
207
208
25
21
        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
33
30
                next unless can_auto_fix($issue);
212
213
30
20
                for my $rule (@_APPLY_RULES) {
214
161
106
                        my ($type, $pattern, $handler) = @$rule;
215
161
205
                        if ($issue->{type} eq $type && $issue->{message} =~ $pattern) {
216
27
31
                                $modified += $handler->($workflow);
217
27
27
                                last;   # first-match-wins; one issue maps to exactly one fix
218                        }
219                }
220        }
221
222
25
24
        return $modified;
223}
224
225 - 286
=head2 fix_workflow($file, $issues)

Load a workflow YAML file, apply fixes, and write it back to disk.

=head3 Purpose

Persist the results of C<apply_fixes> by reading the workflow from C<$file>
with C<YAML::XS::LoadFile>, calling C<apply_fixes>, and rewriting the file
with C<YAML::XS::DumpFile> when at least one fix was applied.

=head3 Arguments

=over 4

=item C<$file> (Str, required)

Path to a YAML workflow file.  Passed directly to C<YAML::XS::LoadFile>.

=item C<$issues> (ArrayRef[HashRef], required)

Issues to fix, each with C<type> and C<message> keys.

=back

=head3 Returns

The number of fixes applied (an integer ≥ 0).  The file is only rewritten
when the count is greater than zero.

=head3 Side Effects

Reads C<$file> from disk; rewrites C<$file> in place when fixes are applied.

=head3 Usage Example

    my $n = fix_workflow('.github/workflows/ci.yml', \@issues);
    say "$n fix(es) written to ci.yml.";

=head3 API SPECIFICATION

=head4 Input

    {
        file   => { type => 'scalar',  required => 1 },
        issues => { type => 'arrayref', required => 1 },
    }

=head4 Output

    { type => 'scalar' }   # non-negative integer

=head3 FORMAL SPECIFICATION

    fix_workflow : Path × seq Issue → â„•

    w       â‰” LoadFile(file)
    fixes   â‰” apply_fixes(w, issues)
    fixes > 0 → DumpFile(file, w)

    result ≔ fixes

=cut
287
288
5
5
5
5
9080
5
4
34
sub fix_workflow($file, $issues) {
289
5
9
        my $workflow = LoadFile($file);
290
5
180
        my $fixes = apply_fixes($workflow, $issues);
291
292
5
9
        if ($fixes > 0) {
293
3
5
                DumpFile($file, $workflow);
294        }
295
296
5
5476
    return $fixes;
297}
298
299# Fix implementations
300
301
11
11
11
8505
7
7
sub add_caching($workflow) {
302
11
21
        my $jobs = $workflow->{jobs} or return 0;
303
11
5
        my $modified = 0;
304
305
11
15
    for my $job (values %$jobs) {
306
11
15
        my $steps = $job->{steps} or next;
307
308        # Check if already has caching
309
11
29
43
51
        my $has_cache = grep { $_->{uses} && $_->{uses} =~ /actions\/cache/ } @$steps;
310
11
14
        next if $has_cache;
311
312        # Detect project type and add appropriate cache
313
8
17
        my $cache_step = detect_and_create_cache_step($steps);
314
8
12
        next unless $cache_step;
315
316        # Insert cache step after checkout
317
6
3
        my $insert_at = 0;
318
6
10
        for my $i (0 .. $#$steps) {
319
8
17
            if ($steps->[$i]->{uses} && $steps->[$i]->{uses} =~ /actions\/checkout/) {
320
5
4
                $insert_at = $i + 1;
321
5
5
                last;
322            }
323        }
324
325
6
9
        splice @$steps, $insert_at, 0, $cache_step;
326
6
6
        $modified++;
327    }
328
329
11
12
    return $modified;
330}
331
332
17
17
17
8751
9
11
sub detect_and_create_cache_step($steps) {
333    # Detect project type from steps
334
17
22
    for my $step (@$steps) {
335
25
38
        my $run = $step->{run} // '';
336
337        # Node.js
338
25
68
        if ($run =~ /npm (?:install|ci)/ || ($step->{uses} && $step->{uses} =~ /setup-node/)) {
339            return {
340
8
22
                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
351
17
40
        if ($run =~ /pip install/ || ($step->{uses} && $step->{uses} =~ /setup-python/)) {
352            return {
353
2
5
                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
364
15
15
        if ($run =~ /cargo (?:build|test)/) {
365            return {
366
1
2
                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
376
14
36
        if ($run =~ /go (?:build|test)/ || ($step->{uses} && $step->{uses} =~ /setup-go/)) {
377            return {
378
2
7
                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
389
4
5
    return undef;
390}
391
392
2
2
2
2693
2
2
sub fix_unpinned_actions($workflow) {
393
2
4
        my $jobs = $workflow->{jobs} or return 0;
394
2
1
        my $modified = 0;
395
396
2
3
    for my $job (values %$jobs) {
397
2
4
        my $steps = $job->{steps} or next;
398
2
2
        for my $step (@$steps) {
399
6
6
            next unless $step->{uses};
400
401
6
14
            if ($step->{uses} =~ /^(.+?)\@(?:master|main)$/) {
402
4
3
                my $action = $1;
403                # Map to appropriate version
404
4
4
                my $version = get_latest_version($action);
405
4
3
                $step->{uses} = "$action\@$version";
406
4
4
                $modified++;
407            }
408        }
409    }
410
411
2
2
    return $modified;
412}
413
414
4
4
4
1632
4
4
sub add_permissions($workflow) {
415
4
17
        return 0 if $workflow->{permissions};
416
417
3
5
        $workflow->{permissions} = { contents => 'read' };
418
3
3
        return 1;
419}
420
421
4
4
4
2702
4
2
sub update_actions($workflow) {
422
4
6
        my $jobs = $workflow->{jobs} or return 0;
423
4
5
        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
4
5
        for my $job (values %$jobs) {
428
4
7
                my $steps = $job->{steps} or next;
429
4
4
                for my $step (@$steps) {
430
17
14
                        next unless $step->{uses};
431
16
16
                        for my $old (keys %ACTION_UPDATES) {
432
144
385
                                if ($step->{uses} =~ /^\Q$old\E/) {
433
13
10
                                        $step->{uses} = $ACTION_UPDATES{$old};
434
13
18
                                        $modified++;
435                                }
436                        }
437                }
438        }
439
440
4
4
        return $modified;
441}
442
443
10
10
10
1132
9
2
sub add_concurrency($workflow) {
444
10
15
        return 0 if $workflow->{concurrency};
445
446    $workflow->{concurrency} = {
447
9
16
        group => '${{ github.workflow }}-${{ github.ref }}',
448        'cancel-in-progress' => 'true',
449    };
450
9
8
    return 1;
451}
452
453
9
9
9
6435
8
4
sub add_trigger_filters($workflow) {
454
9
15
        my $on = $workflow->{on} or return 0;
455
8
8
        my $modified = 0;
456
457    # If 'on' is just 'push', expand it
458
8
6
44
9
    if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) {
459        $workflow->{on} = {
460
3
10
            push => {
461                branches => ['main', 'master'],
462            },
463            pull_request => {
464                branches => ['main', 'master'],
465            },
466        };
467
3
2
        $modified++;
468    }
469    elsif (ref $on eq 'HASH' && $on->{push} && ref $on->{push} eq '') {
470        # 'push' with no filters
471        $on->{push} = {
472
1
2
            branches => ['main', 'master'],
473        };
474
1
1
        $modified++;
475    }
476
477
8
9
    return $modified;
478}
479
480
6
6
6
1529
6
4
sub add_missing_timeout($workflow) {
481
6
9
    my $jobs = $workflow->{jobs} or return 0;
482
6
6
    my $modified = 0;
483
484
6
10
    for my $job_name (keys %$jobs) {
485
7
4
        my $job = $jobs->{$job_name};
486
487        # Skip if timeout already exists
488
7
13
        next if exists $job->{'timeout-minutes'};
489
490        # Insert default timeout
491
6
5
        $job->{'timeout-minutes'} = 30;
492
6
5
        $modified++;
493    }
494
495
6
6
        return $modified;
496}
497
498
8
8
8
6285
5
6
sub update_runners($workflow) {
499
8
11
        my $jobs = $workflow->{jobs} or return 0;
500
8
7
        my $modified = 0;
501
502
8
17
    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
8
13
    for my $job (values %$jobs) {
510
8
9
        my $runs_on = $job->{'runs-on'} or next;
511
512
7
14
        if (exists $runner_updates{$runs_on}) {
513
5
4
            $job->{'runs-on'} = $runner_updates{$runs_on};
514
5
5
            $modified++;
515        }
516    }
517
518
8
13
    return $modified;
519}
520
521
8
8
8
2276
9
3
sub get_latest_version($action) {
522
8
22
    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
532
8
22
    return $versions{$action} // 'v4';  # Default fallback
533}
534
535 - 549
=head1 AUTHOR

Nigel Horne E<lt>njh@nigelhorne.comE<gt>

L<https://github.com/nigelhorne>

=head1 COPYRIGHT AND LICENSE

Copyright 2025-2026 Nigel Horne.

Usage is subject to license terms.

The license terms of this software are as follows:

=cut
550
5511;