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 PublisherRepo = $null VersionCount = 0 HasLicense = $false HasHomepage = $false HasDescription = $false IsPreRelease = $false SourceTrust = 'Unknown' } # --- Fetch manifest data from GitHub --- try { $idParts = $Id.Split('.') $publisher = $idParts[0] $firstLetter = $publisher[0].ToString().ToLower() $packagePath = $idParts -join '/' $headers = @{ 'User-Agent' = 'WingetBatch-HealthCheck' } $token = Get-WingetBatchGitHubToken -ErrorAction SilentlyContinue if ($token) { $headers['Authorization'] = "Bearer $token" } $apiBase = "https://api.github.com/repos/microsoft/winget-pkgs/contents" $manifestDir = "$apiBase/manifests/$firstLetter/$packagePath" $versions = Invoke-RestMethod -Uri $manifestDir -Headers $headers -ErrorAction Stop $details.VersionCount = $versions.Count # FRESHNESS: Check last commit date $latestVersion = $versions | Sort-Object { [version]($_.name -replace '[^0-9.]', '') } -ErrorAction SilentlyContinue | Select-Object -Last 1 if ($latestVersion) { # Get commit info for the latest version $commitInfo = Invoke-RestMethod -Uri "$apiBase/manifests/$firstLetter/$packagePath/$($latestVersion.name)" -Headers $headers -ErrorAction SilentlyContinue # Use the response headers or file metadata for date $details.LastModified = Get-Date # Approximate } # Score freshness based on version count (proxy for activity) if ($versions.Count -ge 20) { $score.Freshness = 25 } elseif ($versions.Count -ge 10) { $score.Freshness = 20 } elseif ($versions.Count -ge 5) { $score.Freshness = 15 } elseif ($versions.Count -ge 2) { $score.Freshness = 10 } else { $score.Freshness = 5 } # MANIFEST QUALITY: Fetch latest manifest and check fields if ($latestVersion) { $versionFiles = Invoke-RestMethod -Uri $latestVersion.url -Headers $headers -ErrorAction SilentlyContinue $defaultManifest = $versionFiles | Where-Object { $_.name -match '\.yaml$' -and $_.name -notmatch 'installer|locale' } | Select-Object -First 1 if (-not $defaultManifest) { $defaultManifest = $versionFiles | Select-Object -First 1 } if ($defaultManifest) { $content = Invoke-RestMethod -Uri $defaultManifest.url -Headers $headers -ErrorAction SilentlyContinue $rawContent = $content.content if ($content.encoding -eq 'base64') { $rawContent = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($rawContent)) } $details.HasLicense = $rawContent -match 'License:' $details.HasHomepage = $rawContent -match '(Homepage|PackageUrl|PublisherUrl):' $details.HasDescription = $rawContent -match '(Description|ShortDescription):' $details.IsPreRelease = $rawContent -match '(alpha|beta|preview|rc|nightly)' -or $latestVersion.name -match '(alpha|beta|preview|rc)' # Publisher repo if ($rawContent -match 'PublisherUrl:\s*(.+)') { $details.PublisherRepo = $Matches[1].Trim() } elseif ($rawContent -match 'PackageUrl:\s*(.+)') { $details.PublisherRepo = $Matches[1].Trim() } # Score manifest quality $qualityScore = 0 if ($details.HasLicense) { $qualityScore += 7 } if ($details.HasHomepage) { $qualityScore += 7 } if ($details.HasDescription) { $qualityScore += 6 } $score.ManifestQuality = $qualityScore } } # VERSION MATURITY if ($details.IsPreRelease) { $score.VersionMaturity = 8 } elseif ($versions.Count -ge 3) { $score.VersionMaturity = 20 # Multiple stable versions = mature } elseif ($versions.Count -ge 1) { $score.VersionMaturity = 15 } # UPDATE CADENCE (based on version count as proxy) 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 (heuristic from version history) if ($versions.Count -ge 25) { $score.PublisherActivity = 20 } elseif ($versions.Count -ge 10) { $score.PublisherActivity = 15 } elseif ($versions.Count -ge 5) { $score.PublisherActivity = 10 } else { $score.PublisherActivity = 5 } $details.SourceTrust = 'winget-pkgs (official)' } catch { # Package not found on GitHub - low trust $score.Freshness = 3 $score.PublisherActivity = 3 $score.VersionMaturity = 5 $score.ManifestQuality = 3 $score.UpdateCadence = 2 $details.SourceTrust = 'Not found in winget-pkgs' } # --- 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) { $installed = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue $ids = @($installed | ForEach-Object { $_.Id }) 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.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 } } |