MSIX.Bulk.ps1

# =============================================================================
# Bulk / fleet analysis
# -----------------------------------------------------------------------------
# The module's unit of work was one package. A packaging team's unit of work is
# a CATALOGUE - "we have 200 apps to migrate, which ones break and which can you
# fix?". Answering that with a foreach around Invoke-MsixInvestigation does not
# work in practice:
#
# * one corrupt package aborts the whole run;
# * nothing is written until the end, so a crash at package 180 loses 180
# results;
# * there is no way to resume, and a 13,000-package corpus takes days;
# * there is no aggregate a lead can act on.
#
# This provides the fleet layer: isolated per package, streamed to disk as it
# goes, resumable, and ending in a rollup. The same cmdlet serves both the
# customer case (a catalogue) and the project's own field-proofing gate (a
# winget-scale corpus with a published pass rate).
# =============================================================================

function Invoke-MsixBulkAnalysis {
    <#
    .SYNOPSIS
        Analyses many MSIX packages and produces one row per package plus a
        rollup: which are clean, which can be auto-fixed, which need a human.
 
    .DESCRIPTION
        Runs the standard investigate -> plan pipeline over a whole folder (or
        an explicit list) of .msix files and emits a comparable verdict for each.
 
        Design points that matter at scale:
 
          ISOLATION Every package runs inside its own try/catch. A corrupt or
                      malicious package produces an 'Error' row; it never aborts
                      the run.
          STREAMING Each row is appended to -ReportPath as soon as it is known,
                      so killing the run (or losing power) keeps everything
                      completed so far.
          RESUMABLE -Resume reads the existing report and skips packages
                      already recorded, so a multi-day corpus run can be stopped
                      and restarted freely.
          NO DUPLICATED LOGIC
                      The AutoFixable verdict is decided by asking the REAL
                      planner (Invoke-MsixAutoFixFromAnalysis -DryRun) what it
                      would do, rather than re-deriving the fixable category
                      list here. A second copy of that mapping would silently
                      drift from the first.
 
        Deliberately SERIAL for now. The module keeps session state in script
        scope (the resolved tools root, the memoized offreg probe, the log file
        handle), so running analyses concurrently inside one process would race
        on it. Throughput comes from -Resume plus overnight runs until that
        state is made concurrency-safe; correctness first.
 
    .PARAMETER Path
        Folders and/or .msix files. Folders are searched recursively. Wildcards
        are supported.
 
    .PARAMETER ReportPath
        CSV to stream results into. Written row-by-row as the run proceeds.
 
    .PARAMETER Resume
        Skip packages already present in -ReportPath. Requires -ReportPath.
 
    .PARAMETER SkipAutoFixPlan
        Do not ask the planner what it would fix. Faster, but every package with
        findings is reported as 'NeedsReview' because fixability is unknown.
 
    .PARAMETER First
        Process at most this many packages. Useful for a smoke run over a corpus.
 
    .OUTPUTS
        [pscustomobject] per package: Id, Package, SizeMB, Verdict, Findings,
        Errors, Warnings, Categories, PlannedFixes, DurationSec, Notes, When.
 
    .EXAMPLE
        # A packaging team's catalogue, with a report to hand to the lead
        Invoke-MsixBulkAnalysis -Path 'D:\packages' -ReportPath .\fleet.csv
 
    .EXAMPLE
        # Corpus run: start it, stop it, resume it as often as you like
        Invoke-MsixBulkAnalysis -Path 'D:\corpus\out' -ReportPath .\scorecard.csv -Resume
 
    .EXAMPLE
        # Just the verdict spread, no report file
        Invoke-MsixBulkAnalysis -Path 'D:\packages' | Group-Object Verdict
 
    .LINK
        https://github.com/sanderdewit/msix
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string[]]$Path,
        [string]$ReportPath,
        [switch]$Resume,
        [switch]$SkipAutoFixPlan,
        [int]$First
    )

    if ($Resume -and -not $ReportPath) {
        throw '-Resume needs -ReportPath: the existing report is what records which packages are already done.'
    }

    # ── Resolve the input set ─────────────────────────────────────────────
    $packages = New-Object System.Collections.Generic.List[string]
    foreach ($p in $Path) {
        $resolved = @(Resolve-Path -Path $p -ErrorAction SilentlyContinue)
        if (-not $resolved) {
            Write-MsixLog -Level Warning -Message "No match for input path '$p'."
            continue
        }
        foreach ($r in $resolved) {
            if (Test-Path -LiteralPath $r.Path -PathType Container) {
                foreach ($f in @(Get-ChildItem -LiteralPath $r.Path -Filter '*.msix' -File -Recurse -ErrorAction SilentlyContinue)) {
                    $packages.Add($f.FullName)
                }
            } elseif ($r.Path -like '*.msix') {
                $packages.Add($r.Path)
            }
        }
    }
    $packages = @($packages | Sort-Object -Unique)
    if (-not $packages) { throw "No .msix files found under: $($Path -join ', ')" }

    # ── Resume: which are already recorded? ───────────────────────────────
    $done = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
    if ($Resume -and (Test-Path -LiteralPath $ReportPath)) {
        try {
            foreach ($row in @(Import-Csv -LiteralPath $ReportPath)) {
                if ($row.Package) { [void]$done.Add($row.Package) }
            }
            Write-MsixLog -Level Info -Message "Resuming: $($done.Count) package(s) already recorded in '$ReportPath'."
        } catch {
            Write-MsixLog -Level Warning -Message "Could not read '$ReportPath' to resume ($($_.Exception.Message)); starting from the beginning."
        }
    }

    $queue = @($packages | Where-Object { -not $done.Contains($_) })
    if ($PSBoundParameters.ContainsKey('First') -and $First -gt 0) {
        $queue = @($queue | Select-Object -First $First)
    }

    Write-MsixLog -Level Info -Message "Bulk analysis: $($queue.Count) package(s) to process ($($packages.Count) found, $($done.Count) already done)."

    $headerWritten = $ReportPath -and (Test-Path -LiteralPath $ReportPath)
    $i = 0
    $tally = [ordered]@{}

    foreach ($pkg in $queue) {
        $i++
        $sw = [Diagnostics.Stopwatch]::StartNew()
        Write-Progress -Activity 'MSIX bulk analysis' -Status "$i / $($queue.Count): $(Split-Path -Leaf $pkg)" `
                       -PercentComplete ([int](100 * $i / [Math]::Max(1, $queue.Count)))

        $row = _MsixAnalyseOnePackage -PackagePath $pkg -SkipAutoFixPlan:$SkipAutoFixPlan
        $sw.Stop()
        $row | Add-Member -NotePropertyName DurationSec -NotePropertyValue ([math]::Round($sw.Elapsed.TotalSeconds, 1)) -Force

        # Stream the row out immediately: a run killed at package 180 must keep
        # the first 179 results.
        if ($ReportPath) {
            try {
                $dir = Split-Path -Parent -Path $ReportPath
                if ($dir -and -not (Test-Path -LiteralPath $dir)) {
                    New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false | Out-Null
                }
                if ($headerWritten) {
                    $row | Export-Csv -LiteralPath $ReportPath -NoTypeInformation -Append -Encoding UTF8
                } else {
                    $row | Export-Csv -LiteralPath $ReportPath -NoTypeInformation -Encoding UTF8
                    $headerWritten = $true
                }
            } catch {
                Write-MsixLog -Level Warning -Message "Could not append to '$ReportPath': $($_.Exception.Message)"
            }
        }

        $v = [string]$row.Verdict
        if (-not $tally.Contains($v)) { $tally[$v] = 0 }
        $tally[$v] = $tally[$v] + 1
        $row
    }
    Write-Progress -Activity 'MSIX bulk analysis' -Completed

    # ── Rollup ────────────────────────────────────────────────────────────
    Write-MsixLog -Level Info -Message '─── Bulk analysis summary ───'
    foreach ($k in $tally.Keys) {
        Write-MsixLog -Level Info -Message (" {0,-12} {1}" -f $k, $tally[$k])
    }
    if ($ReportPath) {
        Write-MsixLog -Level Info -Message "Report: $ReportPath"
    }
}

function _MsixAnalyseOnePackage {
    <#
    .SYNOPSIS
        Analyses ONE package and returns a single comparable result row.
        Never throws - a failure becomes an 'Error' verdict, because one bad
        package must not end a fleet run.
    #>

    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$PackagePath,
        [switch]$SkipAutoFixPlan
    )

    $row = [ordered]@{
        Id           = ''
        Package      = $PackagePath
        SizeMB       = ''
        Verdict      = 'Error'
        Findings     = 0
        Errors       = 0
        Warnings     = 0
        Categories   = ''
        PlannedFixes = ''
        DurationSec  = 0
        Notes        = ''
        When         = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
    }

    try {
        $fi = Get-Item -LiteralPath $PackagePath -ErrorAction Stop
        $row.SizeMB = [math]::Round($fi.Length / 1MB, 1)
        $row.Id     = $fi.BaseName

        # Identity is best-effort: a package whose manifest cannot be read is
        # still worth reporting as Blocked rather than silently skipped.
        try {
            [xml]$mf = Get-MsixManifest -Path $PackagePath
            if ($mf.Package.Identity.Name) { $row.Id = $mf.Package.Identity.Name }
        } catch {
            Write-MsixLog -Level Debug -Message "Identity unreadable for '$PackagePath': $($_.Exception.Message)"
        }

        $report = Invoke-MsixInvestigation -PackagePath $PackagePath -ErrorAction Stop
        $findings = @($report.Findings)

        $row.Findings   = $findings.Count
        $row.Errors     = @($findings | Where-Object { $_.Severity -eq 'Error' }).Count
        $row.Warnings   = @($findings | Where-Object { $_.Severity -eq 'Warning' }).Count
        $row.Categories = (@($findings | ForEach-Object { $_.Category } | Sort-Object -Unique) -join '; ')

        # A scanner that could not run means this report is INCOMPLETE, and an
        # incomplete report must not be presentable as a clean one (#140).
        $scannerErrors = @($findings | Where-Object { $_.Category -eq 'ScannerError' -or $_.Category -eq 'OfflineRegistryUnavailable' })

        if ($findings.Count -eq 0) {
            $row.Verdict = 'Clean'
        } elseif ($SkipAutoFixPlan) {
            $row.Verdict = 'NeedsReview'
        } else {
            # Ask the REAL planner what it would do rather than re-deriving the
            # fixable-category list here, which would drift.
            try {
                $plan = Invoke-MsixAutoFixFromAnalysis -Report $report -PackagePath $PackagePath -DryRun -ErrorAction Stop
                $stages = @($plan.Plan)
                $row.PlannedFixes = (@($stages | ForEach-Object { $_.Stage }) -join '; ')
                if ($stages.Count -gt 0) {
                    # Actionable findings remain only if something needs a human
                    # decision the planner cannot make (paths, CLSIDs, certs).
                    $row.Verdict = 'AutoFixable'
                } else {
                    $row.Verdict = 'NeedsManual'
                }
            } catch {
                $row.Verdict = 'NeedsReview'
                $row.Notes   = _MsixShortNote -Text "Planner failed: $($_.Exception.Message)"
            }
        }

        if ($scannerErrors.Count -gt 0) {
            # Downgrade: we cannot claim Clean/AutoFixable off a partial scan.
            $row.Verdict = 'Incomplete'
            $row.Notes   = (($row.Notes, "Incomplete scan: $((@($scannerErrors | ForEach-Object { $_.Category } | Sort-Object -Unique)) -join ', ')") |
                            Where-Object { $_ }) -join ' | '
        }
    } catch {
        $row.Verdict = 'Error'
        $row.Notes   = _MsixShortNote -Text $_.Exception.Message
        Write-MsixLog -Level Warning -Message "Bulk: '$PackagePath' failed - $($row.Notes)"
    }

    return [pscustomobject]$row
}

function _MsixShortNote {
    <#
    .SYNOPSIS
        Collapses a multi-line tool error into one readable CSV cell.
 
    .DESCRIPTION
        Tool failures carry the whole of MakeAppx's stdout, which turns a
        scorecard row into an unreadable wall. Keep the first meaningful line and
        cap the length; the full text is already in the module log.
    #>

    [OutputType([string])]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, [int]$MaxLength = 240)

    $line = @($Text -split "`r?`n" | Where-Object { $_.Trim() }) | Select-Object -First 1
    if (-not $line) { return '' }
    $line = $line.Trim()
    if ($line.Length -gt $MaxLength) { $line = $line.Substring(0, $MaxLength - 1) + [char]0x2026 }
    return $line
}