App-GHGen
view release on metacpan or search on metacpan
lib/App/GHGen/Analyzer.pm view on Meta::CPAN
package App::GHGen::Analyzer;
use v5.36;
use warnings;
use strict;
use YAML::XS qw(LoadFile);
use Path::Tiny;
use Exporter 'import';
our @EXPORT_OK = qw(
analyze_workflow
find_workflows
get_cache_suggestion
);
our $VERSION = '0.06';
=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
sub find_workflows() {
my $workflows_dir = path('.github/workflows');
return () unless $workflows_dir->exists && $workflows_dir->is_dir;
return sort $workflows_dir->children(qr/\.ya?ml$/i);
}
=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
sub analyze_workflow($workflow, $filename) {
my @issues;
# Check 1: Missing dependency caching
unless (has_caching($workflow)) {
my $cache_suggestion = get_cache_suggestion($workflow);
push @issues, {
type => 'performance',
severity => 'medium',
message => 'No dependency caching found - increases build times and costs',
fix => $cache_suggestion
};
}
# Check 2: Using unpinned action versions
lib/App/GHGen/Analyzer.pm view on Meta::CPAN
# Check 5: Outdated runner versions
if (has_outdated_runners($workflow)) {
push @issues, {
type => 'maintenance',
severity => 'low',
message => 'Using older runner versions - consider updating',
fix => 'Update to ubuntu-latest, macos-latest, or windows-latest'
};
}
# Check 6: Missing timeout-minutes
my $jobs = $workflow->{jobs} // {};
for my $job_name (keys %$jobs) {
my $job = $jobs->{$job_name};
unless (exists $job->{'timeout-minutes'}) {
push @issues, {
type => 'performance',
severity => 'low',
message => "Job '$job_name' is missing timeout-minutes",
fix => "Add:\n timeout-minutes: 30",
};
}
}
return @issues;
}
=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
sub get_cache_suggestion($workflow) {
my $detected_type = detect_project_type({ jobs => $workflow->{jobs} });
my %cache_configs = (
npm => "- uses: actions/cache\@v5\n" .
" with:\n" .
" path: ~/.npm\n" .
" key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}\n" .
" restore-keys: |\n" .
" \${{ runner.os }}-node-",
pip => "- uses: actions/cache\@v5\n" .
" with:\n" .
" path: ~/.cache/pip\n" .
" key: \${{ runner.os }}-pip-\${{ hashFiles('**/requirements.txt') }}\n" .
" restore-keys: |\n" .
" \${{ runner.os }}-pip-",
cargo => "- uses: actions/cache\@v5\n" .
" with:\n" .
" path: |\n" .
" ~/.cargo/bin/\n" .
" ~/.cargo/registry/index/\n" .
" ~/.cargo/registry/cache/\n" .
" target/\n" .
" key: \${{ runner.os }}-cargo-\${{ hashFiles('**/Cargo.lock') }}",
bundler => "- uses: actions/cache\@v5\n" .
" with:\n" .
" path: vendor/bundle\n" .
" key: \${{ runner.os }}-gems-\${{ hashFiles('**/Gemfile.lock') }}\n" .
" restore-keys: |\n" .
" \${{ runner.os }}-gems-",
);
( run in 1.854 second using v1.01-cache-2.11-cpan-9169edd2b0e )