Public/Get-WingetHealthScore.ps1
|
function Get-WingetHealthScore { <# .SYNOPSIS Rate package trustworthiness and maintenance health. .DESCRIPTION Analyzes a package across multiple health dimensions and produces a composite 0-100 score with letter grade (A+ through F). Checks: - Freshness: How recently was the package updated in winget-pkgs? - Publisher Activity: Is the publisher's GitHub repo active? - Version Maturity: Stable releases vs pre-release/alpha/beta - Manifest Quality: Complete metadata (license, homepage, description) - Update Cadence: Regular updates vs abandoned - Source Trust: Official source vs third-party Answers the question: "Should I trust this package?" .PARAMETER PackageId One or more package IDs to analyze. .PARAMETER AllInstalled Score all installed packages (batch audit). .PARAMETER MinScore Only show packages below this score (for auditing). .PARAMETER Detailed Show full breakdown of each scoring dimension. .PARAMETER ExportReport Save health report to JSON. .EXAMPLE Get-WingetHealthScore -PackageId "Git.Git" Shows health score and grade for Git. .EXAMPLE Get-WingetHealthScore -PackageId "SomeObscure.Tool" -Detailed Full breakdown of why a package scored low. .EXAMPLE Get-WingetHealthScore -AllInstalled -MinScore 50 Audit: show all installed packages scoring below 50. .NOTES Author: Matthew Bubb Scores are heuristic-based using public GitHub/winget-pkgs data. #> [CmdletBinding(DefaultParameterSetName = 'ById')] param( [Parameter(ParameterSetName = 'ById', Mandatory, Position = 0, ValueFromPipeline)] [string[]]$PackageId, [Parameter(ParameterSetName = 'AllInstalled', Mandatory)] [switch]$AllInstalled, [ValidateRange(0, 100)] [int]$MinScore = 100, [switch]$Detailed, [string]$ExportReport ) begin { $results = [System.Collections.Generic.List[PSCustomObject]]::new() function Get-SingleHealthScore { param([string]$Id) $score = @{ Freshness = 0 # 0-25: How recently updated PublisherActivity = 0 # 0-20: GitHub activity VersionMaturity = 0 # 0-20: Stable vs pre-release ManifestQuality = 0 # 0-20: Metadata completeness UpdateCadence = 0 # 0-15: Regular update pattern } $details = @{ LastModified = $null LatestVersion = $null PublisherRepo = $null VersionCount = 0 HasLicense = $false HasHomepage = $false HasDescription = $false IsPreRelease = $false SourceTrust = 'Unknown' } # --- Fetch manifest data from GitHub --- try { $versions = @(Get-WingetPkgsVersions -PackageId $Id) if ($versions.Count -eq 0) { throw "No versions found" } $details.VersionCount = $versions.Count $latestVersion = $versions[0].Name $details.LatestVersion = $latestVersion # FRESHNESS: date of the most recent commit touching this package $path = Get-WingetPkgsPackagePath -PackageId $Id $commits = Invoke-RestMethod -Uri "https://api.github.com/repos/microsoft/winget-pkgs/commits?path=$path&per_page=1" -Headers (Get-WingetPkgsHeaders) -ErrorAction SilentlyContinue Update-GitHubApiRequestCount -RequestCount 1 | Out-Null if ($commits -and $commits[0].commit.committer.date) { $details.LastModified = [datetime]$commits[0].commit.committer.date $age = ((Get-Date) - $details.LastModified).TotalDays $score.Freshness = if ($age -le 30) { 25 } elseif ($age -le 90) { 20 } elseif ($age -le 180) { 15 } elseif ($age -le 365) { 10 } elseif ($age -le 730) { 5 } else { 2 } } else { $score.Freshness = 10 # unknown: neutral } # MANIFEST QUALITY: the descriptive fields live in the locale manifest $manifest = Get-WingetPkgsManifest -PackageId $Id -Version $latestVersion $locale = $manifest.Locale $details.HasLicense = [bool](Get-WingetYamlValue -Yaml $locale -Key 'License') $details.HasHomepage = [bool]((Get-WingetYamlValue -Yaml $locale -Key 'PackageUrl') -or (Get-WingetYamlValue -Yaml $locale -Key 'PublisherUrl')) $details.HasDescription = [bool]((Get-WingetYamlValue -Yaml $locale -Key 'ShortDescription') -or (Get-WingetYamlValue -Yaml $locale -Key 'Description')) $details.PublisherRepo = Get-WingetYamlValue -Yaml $locale -Key 'PublisherUrl' if (-not $details.PublisherRepo) { $details.PublisherRepo = Get-WingetYamlValue -Yaml $locale -Key 'PackageUrl' } $qualityScore = 0 if ($details.HasLicense) { $qualityScore += 7 } if ($details.HasHomepage) { $qualityScore += 7 } if ($details.HasDescription) { $qualityScore += 6 } $score.ManifestQuality = $qualityScore # VERSION MATURITY: judge the version string and package ID only # (searching the whole manifest matched words like "Source") $details.IsPreRelease = ($latestVersion -match '(?i)(alpha|beta|preview|nightly|canary|insider|dev|\brc\d*\b|-rc)') -or ($Id -match '(?i)\.(Preview|Beta|Nightly|Canary|Insiders?|Dev)$') if ($details.IsPreRelease) { $score.VersionMaturity = 8 } elseif ((Compare-WingetVersion -ReferenceVersion $latestVersion -DifferenceVersion '1.0') -lt 0) { $score.VersionMaturity = 12 # 0.x releases } elseif ($versions.Count -ge 3) { $score.VersionMaturity = 20 } else { $score.VersionMaturity = 15 } # UPDATE CADENCE: how many releases have been published to winget if ($versions.Count -ge 30) { $score.UpdateCadence = 15 } elseif ($versions.Count -ge 15) { $score.UpdateCadence = 12 } elseif ($versions.Count -ge 5) { $score.UpdateCadence = 9 } elseif ($versions.Count -ge 2) { $score.UpdateCadence = 6 } else { $score.UpdateCadence = 3 } # PUBLISHER ACTIVITY: recent commit + sustained release history $activity = 0 if ($details.LastModified -and ((Get-Date) - $details.LastModified).TotalDays -le 180) { $activity += 10 } elseif ($details.LastModified -and ((Get-Date) - $details.LastModified).TotalDays -le 365) { $activity += 5 } if ($versions.Count -ge 10) { $activity += 10 } elseif ($versions.Count -ge 3) { $activity += 5 } $score.PublisherActivity = $activity $details.SourceTrust = 'winget-pkgs (community repository)' } catch { $status = [int]$_.Exception.Response.StatusCode if ($status -in 403, 429) { Write-Warning "GitHub rate limit reached while scoring $Id. Run New-WingetBatchGitHubToken for 5,000 requests/hour." $details.SourceTrust = 'Unknown (rate limited)' } else { # Package not found on GitHub - low trust $details.SourceTrust = 'Not found in winget-pkgs' } $score.Freshness = 3 $score.PublisherActivity = 3 $score.VersionMaturity = 5 $score.ManifestQuality = 3 $score.UpdateCadence = 2 } # --- Composite Score --- $total = $score.Freshness + $score.PublisherActivity + $score.VersionMaturity + $score.ManifestQuality + $score.UpdateCadence # Letter grade $grade = if ($total -ge 90) { 'A+' } elseif ($total -ge 80) { 'A' } elseif ($total -ge 70) { 'B' } elseif ($total -ge 60) { 'C' } elseif ($total -ge 50) { 'D' } else { 'F' } $gradeColor = if ($total -ge 80) { 'Green' } elseif ($total -ge 60) { 'Yellow' } else { 'Red' } return @{ PackageId = $Id Score = $total Grade = $grade GradeColor = $gradeColor Breakdown = $score Details = $details } } } process { $ids = @() if ($AllInstalled) { # Only winget-sourced packages have winget-pkgs manifests $installed = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.Source -eq 'winget' } $ids = @($installed | ForEach-Object { $_.Id }) if (-not (Get-WingetBatchGitHubToken) -and $ids.Count -gt 15) { Write-Warning "Scoring $($ids.Count) packages needs about $($ids.Count * 3) GitHub API requests; without a token the limit is 60/hour. Run New-WingetBatchGitHubToken first." } Write-Host "`n Scoring $($ids.Count) installed packages...`n" -ForegroundColor Cyan } else { $ids = $PackageId } $i = 0 foreach ($id in $ids) { $i++ if ($AllInstalled) { Write-Progress -Activity "Health Scoring" -Status "[$i/$($ids.Count)] $id" -PercentComplete (($i / $ids.Count) * 100) } $health = Get-SingleHealthScore -Id $id # Filter by min score if ($health.Score -lt $MinScore -or -not $AllInstalled) { $results.Add([PSCustomObject]@{ PackageId = $health.PackageId Score = $health.Score Grade = $health.Grade Freshness = $health.Breakdown.Freshness PublisherActivity = $health.Breakdown.PublisherActivity VersionMaturity = $health.Breakdown.VersionMaturity ManifestQuality = $health.Breakdown.ManifestQuality UpdateCadence = $health.Breakdown.UpdateCadence Details = $health.Details }) # Display $bar = ('█' * [Math]::Round($health.Score / 5)).PadRight(20, '░') Write-Host " $($health.Grade) " -NoNewline -ForegroundColor $health.GradeColor Write-Host "$bar " -NoNewline -ForegroundColor $health.GradeColor Write-Host "$($health.Score)/100 " -NoNewline -ForegroundColor White Write-Host $id -ForegroundColor Cyan if ($Detailed) { Write-Host " Freshness: $($health.Breakdown.Freshness)/25" -ForegroundColor DarkGray Write-Host " Publisher: $($health.Breakdown.PublisherActivity)/20" -ForegroundColor DarkGray Write-Host " Version Maturity: $($health.Breakdown.VersionMaturity)/20" -ForegroundColor DarkGray Write-Host " Manifest Quality: $($health.Breakdown.ManifestQuality)/20" -ForegroundColor DarkGray Write-Host " Update Cadence: $($health.Breakdown.UpdateCadence)/15" -ForegroundColor DarkGray Write-Host " Source: $($health.Details.SourceTrust)" -ForegroundColor DarkGray if ($health.Details.LastModified) { Write-Host " Last updated: $($health.Details.LastModified.ToString('yyyy-MM-dd')) (v$($health.Details.LatestVersion))" -ForegroundColor DarkGray } if ($health.Details.PublisherRepo) { Write-Host " URL: $($health.Details.PublisherRepo)" -ForegroundColor DarkGray } Write-Host "" } } } if ($AllInstalled) { Write-Progress -Activity "Health Scoring" -Completed } } end { # Summary if ($results.Count -gt 1) { $avg = [Math]::Round(($results | Measure-Object Score -Average).Average, 1) $lowCount = ($results | Where-Object { $_.Score -lt 50 }).Count Write-Host "" Write-Host " Summary: $($results.Count) packages scored | Average: $avg/100 | Below 50: $lowCount" -ForegroundColor DarkGray Write-Host "" } # Export if ($ExportReport) { $report = @{ Timestamp = (Get-Date).ToString('o') Hostname = $env:COMPUTERNAME PackageCount = $results.Count AverageScore = [Math]::Round(($results | Measure-Object Score -Average).Average, 1) Packages = @($results | ForEach-Object { @{ Id = $_.PackageId; Score = $_.Score; Grade = $_.Grade } }) } $report | ConvertTo-Json -Depth 5 | Set-Content -Path $ExportReport -Encoding UTF8 Write-Host " Report saved: $ExportReport" -ForegroundColor Green } return $results } } |