Private/Get-WingetLeftover.ps1

function Get-WingetLeftover {
    <#
        .SYNOPSIS
        Works out which packages an upgrade pass left behind, and why.
 
        .DESCRIPTION
        "winget upgrade --all" returns one exit code for the whole pass, so a
        partial failure says only that something did not upgrade. This says
        which, and separates two outcomes that need different answers from the
        person reading it:
 
          Attempted winget tried and the install failed. Worth retrying, and
                      often fixable -- a package whose executable is running
                      cannot be replaced until it is closed.
 
          Skipped winget listed it and never tried. Usually "a newer package
                      version is available in a configured source, but it does
                      not apply to your system or requirements", which is
                      permanent until the vendor ships something that applies.
                      Reporting that as a failure every run teaches people to
                      ignore the ones that are not.
 
        Attempted is decided by winget's own "Found <name> [<id>]" line, which it
        writes when it begins a package. Nothing else in the output is reliable:
        the rest is localised prose.
 
        .PARAMETER Before
        The upgrade table from before the pass.
 
        .PARAMETER After
        The upgrade table from after it.
 
        .PARAMETER Output
        What the pass printed, for deciding attempted from skipped.
 
        .EXAMPLE
        Get-WingetLeftover -Before $before -After $after -Output $captured
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [object[]] $Before = @(),
        [object[]] $After = @(),
        [string] $Output
    )

    $stillListed = @($After | Where-Object { $_.Id })
    if (-not $stillListed.Count) { return }

    foreach ($package in $stillListed) {
        # Compared against the same listing style it came from, so a truncated
        # Id matches a truncated Id.
        $wasListed = [bool] @($Before | Where-Object { $_.Id -eq $package.Id }).Count

        # Only count it as attempted when winget named it. A substring search of
        # the whole output would match the upgrade table itself, which lists
        # every package including the ones never tried.
        $attempted = $false
        $reason = $null

        if ($Output -and $package.Id) {
            $marker = [regex]::Escape($package.Id)
            if ($Output -match "\[$marker\]") {
                $attempted = $true

                # winget says "Access is denied" when it cannot replace a file,
                # which on Windows almost always means the executable is running.
                # That is the one failure a person can act on immediately, so it
                # is worth naming rather than leaving in the step log.
                if ($Output -match '(?i)access is denied') {
                    $reason = 'the install failed; a file was in use, so close the program and run again'
                } else {
                    $reason = 'the install failed'
                }
            }
        }

        if (-not $attempted) {
            $reason = if ($wasListed) {
                'winget listed it and did not attempt it, usually because the newer package does not apply to this system'
            } else {
                'it appeared during this run'
            }
        }

        [pscustomobject]@{
            Name      = $package.Name
            Id        = $package.Id
            Version   = $package.Version
            Available = $package.Available
            Attempted = $attempted
            Reason    = $reason
        }
    }
}