File Coverage

File:blib/lib/App/GHGen/Analyzer.pm
Coverage:95.9%

linestmtbrancondsubtimecode
1package App::GHGen::Analyzer;
2
3
8
8
587403
14
use v5.36;
4
8
8
8
13
5
159
use warnings;
5
8
8
8
14
2
66
use strict;
6
7
8
8
8
582
6833
143
use YAML::XS qw(LoadFile);
8
8
7
7
20
5
106
use Path::Tiny;
9
7
7
7
794
10
374
use App::GHGen::Fixer qw(%ACTION_UPDATES);
10
11
7
7
7
15
8
4304
use Exporter 'import';
12our @EXPORT_OK = qw(
13        analyze_workflow
14        find_workflows
15        get_cache_suggestion
16);
17
18our $VERSION = '0.10';
19
20 - 81
=head1 NAME

App::GHGen::Analyzer - Analyze GitHub Actions workflows

=head1 SYNOPSIS

    use App::GHGen::Analyzer qw(analyze_workflow);

    my @issues = analyze_workflow($workflow_hashref, 'ci.yml');

=head1 FUNCTIONS

=head2 find_workflows()

Find all workflow files in .github/workflows directory.
Returns a sorted list of L<Path::Tiny> objects.

=head3 Purpose

Discover every GitHub Actions workflow file under C<.github/workflows> in
the current working directory.

=head3 Arguments

None.

=head3 Returns

A sorted list of L<Path::Tiny> objects, one per C<.yml> or C<.yaml> file
found.  Returns an empty list when the directory does not exist or is empty.

=head3 Side Effects

None.  Performs read-only filesystem access.

=head3 Usage Example

    use App::GHGen::Analyzer qw(find_workflows);
    chdir '/path/to/repo';
    my @wfs = find_workflows();
    say $_->basename for @wfs;

=head3 API SPECIFICATION

=head4 Input

    # No parameters.

=head4 Output

    { type => 'array', element => { isa => 'Path::Tiny' } }

=head3 FORMAL SPECIFICATION

    FindWorkflows : → seq Path

    dir ≔ .github/workflows
    FindWorkflows ≡
        Â¬dir.exists ∨ ¬dir.is_dir  â†’  âŸ¨âŸ©
        | otherwise  â†’  sort(lex) { f ∈ dir.children ∣ f.name =~ /\.ya?ml$/i }

=cut
82
83
5
5
78722
4
sub find_workflows() {
84
5
11
        my $workflows_dir = path('.github/workflows');
85
86
5
92
        return () unless $workflows_dir->exists && $workflows_dir->is_dir;
87
3
54
        return sort $workflows_dir->children(qr/\.ya?ml$/i);
88}
89
90 - 185
=head2 analyze_workflow($workflow, $filename)

Analyze a workflow hash for issues. Returns array of issue hashes.

Each issue has: C<type>, C<severity>, C<message>, and an optional C<fix>.

=head3 Purpose

Inspect a parsed GitHub Actions workflow and return a list of detected
issues covering performance, security, cost, and maintenance concerns.

=head3 Arguments

=over 4

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

A hash reference representing the parsed workflow (e.g. from C<YAML::XS::LoadFile>).
Must contain at least a C<jobs> key whose value is a hash of job definitions.

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

The filename of the workflow, used only as context in issue messages.

=back

=head3 Returns

A list (not an array reference) of issue hash references.  Each issue has:

    {
        type     => Str,  # 'performance' | 'security' | 'cost' | 'maintenance'
        severity => Str,  # 'high' | 'medium' | 'low'
        message  => Str,
        fix      => Str,  # optional: suggested YAML snippet
    }

Returns an empty list when no issues are detected.

=head3 Side Effects

None.  Pure function; does not modify its arguments.

=head3 Usage Example

    use App::GHGen::Analyzer qw(analyze_workflow);
    use YAML::XS qw(LoadFile);

    my $wf     = LoadFile('.github/workflows/ci.yml');
    my @issues = analyze_workflow($wf, 'ci.yml');
    for my $issue (@issues) {
        say "$issue->{severity}: $issue->{message}";
    }

=head3 API SPECIFICATION

=head4 Input

    {
        workflow => { type => 'hashref', required => 1 },
        filename => { type => 'scalar',  required => 1 },
    }

=head4 Output

    {
        type    => 'array',
        element => {
            type => 'hashref',
            keys => {
                type     => { type => 'scalar' },
                severity => { type => 'scalar' },
                message  => { type => 'scalar' },
                fix      => { type => 'scalar', optional => 1 },
            },
        },
    }

=head3 FORMAL SPECIFICATION

    Issue ≔ { type: IssueType, severity: Severity, message: ℤ*, fix?: ℤ* }
    IssueType  â‰” performance | security | cost | maintenance
    Severity   â‰” high | medium | low

    analyze_workflow : Workflow × ℤ* → seq Issue

    âˆ€ w: Workflow, f: ℤ*:
      Â¬has_caching(w)           â‡’ performance∈medium ∈ result(w,f)
      find_unpinned_actions(w)≠∅ ⇒ security∈high     âˆˆ result(w,f)
      find_outdated_actions(w)≠∅ ⇒ maintenance∈medium ∈ result(w,f)
      has_broad_triggers(w)     â‡’ cost∈medium        âˆˆ result(w,f)
      Â¬w.concurrency            â‡’ cost∈low           âˆˆ result(w,f)
      has_outdated_runners(w)   â‡’ maintenance∈low    âˆˆ result(w,f)
      âˆƒ j∈w.jobs: ¬j.timeout-minutes ⇒ performance∈low ∈ result(w,f)

=cut
186
187
37
37
37
37
226926
30
31
37
sub analyze_workflow($workflow, $filename) {
188
37
20
        my @issues;
189
190    # Check 1: Missing dependency caching
191
37
37
    unless (has_caching($workflow)) {
192
22
25
        my $cache_suggestion = get_cache_suggestion($workflow);
193
22
41
        push @issues, {
194            type => 'performance',
195            severity => 'medium',
196            message => 'No dependency caching found - increases build times and costs',
197            fix => $cache_suggestion
198        };
199    }
200
201    # Check 2: Using unpinned action versions
202
37
36
    my @unpinned = find_unpinned_actions($workflow);
203
37
30
    if (@unpinned) {
204        push @issues, {
205            type => 'security',
206            severity => 'high',
207            message => 'Found ' . scalar(@unpinned) . " action(s) using \@master or \@main",
208            fix => "Replace \@master/\@main with specific version tags:\n" .
209
11
13
13
33
28
29
                   join("\n", map { "       $_" } map { s/\@(master|main)$/\@v5/r } @unpinned[0..min(2, $#unpinned)])
210        };
211    }
212
213    # Check for outdated action versions
214
37
38
    my @outdated = find_outdated_actions($workflow);
215
37
35
    if (@outdated) {
216        push @issues, {
217            type => 'maintenance',
218            severity => 'medium',
219            message => "Found " . scalar(@outdated) . " outdated action(s)",
220            fix => "Update to latest versions:\n" .
221
3
5
8
7
                   join("\n", map { "       $_" } @outdated[0..min(2, $#outdated)])
222        };
223    }
224
225    # Check 3: Overly broad triggers
226
37
26
    if (has_broad_triggers($workflow)) {
227
12
20
        push @issues, {
228            type => 'cost',
229            severity => 'medium',
230            message => 'Workflow triggers on all pushes - consider path/branch filters',
231            fix => "Add trigger filters:\n" .
232                   "     on:\n" .
233                   "       push:\n" .
234                   "         branches: [main, develop]\n" .
235                   "         paths:\n" .
236                   "           - 'src/**'\n" .
237                   "           - 'package.json'"
238        };
239    }
240
241    # Check 4: Missing concurrency controls
242
37
38
    unless ($workflow->{concurrency}) {
243
18
26
        push @issues, {
244            type => 'cost',
245            severity => 'low',
246            message => 'No concurrency group - old runs continue when superseded',
247            fix => "Add concurrency control:\n" .
248                   "     concurrency:\n" .
249                   "       group: \${{ github.workflow }}-\${{ github.ref }}\n" .
250                   "       cancel-in-progress: true"
251        };
252    }
253
254    # Check 5: Outdated runner versions
255
37
28
    if (has_outdated_runners($workflow)) {
256
8
12
        push @issues, {
257            type => 'maintenance',
258            severity => 'low',
259            message => 'Using older runner versions - consider updating',
260            fix => 'Update to ubuntu-latest, macos-latest, or windows-latest'
261        };
262    }
263
264# Check 6: Missing timeout-minutes
265
37
40
my $jobs = $workflow->{jobs} // {};
266
37
29
for my $job_name (keys %$jobs) {
267
36
24
    my $job = $jobs->{$job_name};
268
269
36
34
    unless (exists $job->{'timeout-minutes'}) {
270
18
31
        push @issues, {
271            type     => 'performance',
272            severity => 'low',
273            message  => "Job '$job_name' is missing timeout-minutes",
274            fix      => "Add:\n     timeout-minutes: 30",
275        };
276    }
277}
278
279
37
64
        return @issues;
280}
281
282 - 338
=head2 get_cache_suggestion($workflow)

Generate a caching suggestion based on detected project type.

=head3 Purpose

Inspect a workflow for known package-manager commands and return a ready-to-paste
C<actions/cache> YAML snippet that matches the detected ecosystem.

=head3 Arguments

=over 4

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

A parsed workflow hash.  The function inspects each job's C<steps[].run>
commands to detect C<npm>, C<pip>, C<cargo>, or C<bundle>.

=back

=head3 Returns

A non-empty string.  When the ecosystem is recognised the string is an
C<actions/cache> YAML step snippet; otherwise a generic guidance message.

=head3 Side Effects

None.  Pure function.

=head3 Usage Example

    my $suggestion = get_cache_suggestion($workflow);
    say $suggestion;

=head3 API SPECIFICATION

=head4 Input

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

=head4 Output

    { type => 'scalar' }

=head3 FORMAL SPECIFICATION

    Ecosystem ≔ npm | pip | cargo | bundler | unknown

    get_cache_suggestion : Workflow → ℤ*

    ecosystem(w) = npm     â‡’ result contains ~/.npm cache path
    ecosystem(w) = pip     â‡’ result contains ~/.cache/pip path
    ecosystem(w) = cargo   â‡’ result contains ~/.cargo path
    ecosystem(w) = bundler ⇒ result contains vendor/bundle path
    ecosystem(w) = unknown ⇒ result is a generic guidance message

=cut
339
340
37
37
37
9990
24
24
sub get_cache_suggestion($workflow) {
341
37
51
        my $detected_type = detect_project_type({ jobs => $workflow->{jobs} });
342
343
37
88
    my %cache_configs = (
344        npm => "- uses: actions/cache\@v5\n" .
345               "       with:\n" .
346               "         path: ~/.npm\n" .
347               "         key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}\n" .
348               "         restore-keys: |\n" .
349               "           \${{ runner.os }}-node-",
350
351        pip => "- uses: actions/cache\@v5\n" .
352               "       with:\n" .
353               "         path: ~/.cache/pip\n" .
354               "         key: \${{ runner.os }}-pip-\${{ hashFiles('**/requirements.txt') }}\n" .
355               "         restore-keys: |\n" .
356               "           \${{ runner.os }}-pip-",
357
358        cargo => "- uses: actions/cache\@v5\n" .
359                 "       with:\n" .
360                 "         path: |\n" .
361                 "           ~/.cargo/bin/\n" .
362                 "           ~/.cargo/registry/index/\n" .
363                 "           ~/.cargo/registry/cache/\n" .
364                 "           target/\n" .
365                 "         key: \${{ runner.os }}-cargo-\${{ hashFiles('**/Cargo.lock') }}",
366
367        bundler => "- uses: actions/cache\@v5\n" .
368                   "       with:\n" .
369                   "         path: vendor/bundle\n" .
370                   "         key: \${{ runner.os }}-gems-\${{ hashFiles('**/Gemfile.lock') }}\n" .
371                   "         restore-keys: |\n" .
372                   "           \${{ runner.os }}-gems-",
373    );
374
375
37
94
    return $cache_configs{$detected_type} //
376           "Add caching based on your dependency manager:\n" .
377           "       See: https://docs.github.com/en/actions/using-workflows/caching-dependencies";
378}
379
380# Helper functions
381
382
37
37
37
66926
22
19
sub has_caching($workflow) {
383
37
47
        my $jobs = $workflow->{jobs} or return 0;
384
385
36
42
    for my $job (values %$jobs) {
386
35
30
        my $steps = $job->{steps} or next;
387
35
29
        for my $step (@$steps) {
388
53
109
            return 1 if $step->{uses} && $step->{uses} =~ /actions\/cache/;
389        }
390    }
391
20
22
    return 0;
392}
393
394
36
36
36
2108
17
23
sub find_unpinned_actions($workflow) {
395
36
20
        my @unpinned;
396
36
60
        my $jobs = $workflow->{jobs} or return @unpinned;
397
398
36
30
    for my $job (values %$jobs) {
399
35
32
        my $steps = $job->{steps} or next;
400
35
22
        for my $step (@$steps) {
401
74
65
            next unless $step->{uses};
402
47
56
            if ($step->{uses} =~ /\@(?:master|main)$/) {
403
16
19
                push @unpinned, $step->{uses};
404            }
405        }
406    }
407
36
39
    return @unpinned;
408}
409
410
38
38
38
1561
17
30
sub has_broad_triggers($workflow) {
411
38
35
        my $on = $workflow->{on};
412
38
33
        return 0 unless $on;
413
414    # Check if push trigger has no path or branch filters
415
33
61
    if (ref $on eq 'HASH' && $on->{push}) {
416
21
28
        my $push = $on->{push};
417
21
70
        return 1 if ref $push eq '' || (!$push->{paths} && !$push->{branches});
418    }
419
420    # Simple array of triggers including 'push'
421
28
14
37
18
    if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) {
422
9
12
        return 1;
423    }
424
425
19
20
    return 0;
426}
427
428
44
44
44
4025
20
23
sub has_outdated_runners($workflow) {
429
44
34
        my $jobs = $workflow->{jobs} or return 0;
430
431
44
42
    for my $job (values %$jobs) {
432
43
37
        my $runs_on = $job->{'runs-on'} or next;
433
39
89
        return 1 if $runs_on =~ /\A(?:ubuntu-(?:18|16)\.04|macos-10\.15)\z/;
434    }
435
32
25
    return 0;
436}
437
438
42
42
42
1259
24
31
sub detect_project_type($workflow) {
439
42
48
        return 'unknown' unless ref $workflow eq 'HASH';
440
441
42
45
        my $jobs = $workflow->{jobs} or return 'unknown';
442
443
40
38
    for my $job (values %$jobs) {
444
38
36
        my $steps = $job->{steps} or next;
445
38
29
        for my $step (@$steps) {
446
36
38
            my $run = $step->{run} // '';
447
36
64
            return 'npm' if $run =~ /npm (?:install|ci)/;
448
26
31
            return 'pip' if $run =~ /pip install/;
449
22
26
            return 'cargo' if $run =~ /cargo (?:build|test)/;
450
19
23
            return 'bundler' if $run =~ /bundle install/;
451        }
452    }
453
19
16
    return 'unknown';
454}
455
456
18
18
18
18
2557
11
11
12
sub min($a, $b) {
457
18
30
    return $a < $b ? $a : $b;
458}
459
460 - 500
=head2 find_outdated_actions($workflow)

Return a list of human-readable upgrade notices for any action whose version
string is a key in C<App::GHGen::Fixer::ACTION_UPDATES>.

=head3 Purpose

Detect C<uses:> entries that reference a known-outdated action version.
The detection set is derived directly from C<%ACTION_UPDATES> in
C<App::GHGen::Fixer>, so it is always in sync with what C<update_actions>
can fix: every action flagged here can be auto-fixed, and every action the
Fixer knows about is flagged here.

=head3 Arguments

=over 4

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

A parsed workflow hash.

=back

=head3 Returns

A list of strings of the form C<"old/action\@vN E<rarr> new/action\@vM">,
one per outdated step found.  Returns an empty list when none are detected.

=head3 Side Effects

None.  Pure function.

=head3 FORMAL SPECIFICATION

    find_outdated_actions : Workflow → seq ℤ*

    outdated ≔ { "$old → $new" ∣ job ∈ w.jobs, step ∈ job.steps,
                  old ∈ dom(%ACTION_UPDATES), step.uses =~ /^\Qold\E/ }
    result ≔ seq(outdated)

=cut
501
502
37
37
37
4106
23
14
sub find_outdated_actions($workflow) {
503
37
28
        my @outdated;
504
37
35
        my $jobs = $workflow->{jobs} or return @outdated;
505
506        # Premise: %ACTION_UPDATES (from Fixer) is the single source of truth.
507        # Conclusion: every action this function flags can also be fixed by update_actions.
508
37
46
        for my $job (values %$jobs) {
509
36
34
                my $steps = $job->{steps} or next;
510
36
24
                for my $step (@$steps) {
511
83
94
                        next unless $step->{uses};
512
56
30
                        my $uses = $step->{uses};
513
56
60
                        for my $old (keys %ACTION_UPDATES) {
514
504
1444
                                if ($uses =~ /^\Q$old\E/) {
515
17
23
                                        push @outdated, "$old → $ACTION_UPDATES{$old}";
516                                }
517                        }
518                }
519        }
520
521
37
31
        return @outdated;
522}
523
524 - 536
=head1 AUTHOR

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

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

=head1 LICENCE

Copyright 2025-2026 Nigel Horne.

Usage is subject to license terms.

=cut
537
5381;