MSIX.AutoFix.ps1

# =============================================================================
# MSIX auto-fix orchestrators (split from MSIX.Heuristics.ps1 in issue #38)
# -----------------------------------------------------------------------------
# Invoke-MsixAutoFix chains a TMEditX-style curated set of mutators.
# Invoke-MsixAutoFixFromAnalysis plans the same set from an analyzer
# report (Get-MsixStaticAnalysis / Get-MsixCompatibilityReport).
# Mutators live in MSIX.PackageMutators.ps1; scanners in MSIX.Scanners.ps1.
# =============================================================================

#region AutoFix orchestrator -----------------------------------------------

function Invoke-MsixAutoFix {
    <#
    .SYNOPSIS
        Runs a curated set of TMEditX-style auto-fixes against a package in a
        deterministic order, signing only at the very end.
 
    .DESCRIPTION
        Stages (modelled on TMEditX's AutoFixStage enum):
 
          PrePsf
            - RemoveUninstallers strip uninstall*.exe and friends
            - RemoveUpdaters strip auto-updater binaries + Tasks XMLs
            - BumpVersion bump the package version
          Recommended
            - AddCapabilities add common capabilities
            - AddAliases register AppExecutionAlias for top-level exes
            - InjectPsf run Add-MsixPsfV2 with the fixups you supply
            - BundleVcRuntimes copy missing VC runtime DLLs in
          Optional
            - AddSplashImage show a splash while a startScript runs
 
        Every stage writes back into the SAME file (or -OutputPath if set) and
        passes -SkipSigning so we sign once at the end. Use -DryRun to see
        which stages would fire without mutating the package.
 
    .PARAMETER PackagePath
        .msix to mutate.
 
    .PARAMETER Capabilities
        Names to add via Add-MsixCapability (rescap or standard, looked up
        against Get-MsixKnownCapability).
 
    .PARAMETER PsfFixups / PsfAppOptions / PsfWorkingDirectory / PsfAdditionalFiles
        Forwarded to Add-MsixPsfV2.
 
    .PARAMETER AddAliases
        If set, runs Add-MsixAlias for top-level user-facing executables.
        When -AliasAppIds is supplied, aliases are added only for those apps;
        otherwise Get-MsixAliasCandidate selects candidates automatically and
        skips apps that already have an alias.
 
    .PARAMETER AliasAppIds
        Optional list of Application IDs to alias. Implies -AddAliases.
        When omitted, Get-MsixAliasCandidate makes the selection.
 
    .PARAMETER VcRuntimeSourceFolder
        If set, runs Add-MsixVcRuntimeBundle with this source folder.
 
    .PARAMETER SplashImagePath / SplashAppId
        If set, runs Add-MsixSplashScreen after PSF.
 
    .PARAMETER VersionBumpComponent
        If set, runs Update-MsixPackageVersion before any other stage.
 
    .PARAMETER RemoveUninstallers
        If $true, strips uninstaller-looking files first.
 
    .PARAMETER RemoveUpdaters
        If $true, strips auto-updater binaries and scheduled-task XMLs.
 
    .PARAMETER OutputPath
        If set, all writes go here instead of overwriting -PackagePath.
 
    .PARAMETER DryRun
        Report which stages would fire, then return — no mutation, no signing.
 
    .PARAMETER Pfx / PfxPassword
        Signing certificate for the final pass.
 
    .EXAMPLE
        Invoke-MsixAutoFix -PackagePath app.msix `
            -RemoveUninstallers `
            -Capabilities runFullTrust,internetClient `
            -PsfFixups @( New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log' ) `
            -VersionBumpComponent Build `
            -Pfx cert.pfx -PfxPassword 'P@ss'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSReviewUnusedParameter', '',
        Justification = 'Parameters are captured by the per-stage scriptblocks built up via _Stage.')]
    param(
        [Parameter(Mandatory)] [string]$PackagePath,

        # PrePsf stage
        [switch]$RemoveUninstallers,
        [switch]$RemoveUpdaters,
        [switch]$RemoveDesktopShortcuts,
        [ValidateSet('Major','Minor','Build','Revision')]
        [string]$VersionBumpComponent,

        # Recommended stage
        [string[]]$Capabilities,
        [switch]$AddFontExtension,
        [switch]$AddAliases,
        [string[]]$AliasAppIds,
        [hashtable[]]$PsfFixups,
        [hashtable[]]$PsfAppOptions,
        [string]$PsfWorkingDirectory,
        [string[]]$PsfAdditionalFiles,
        [string]$VcRuntimeSourceFolder,

        # Optional stage
        [string]$SplashImagePath,
        [ValidatePattern(
            '^[A-Za-z_][A-Za-z0-9_.-]*$',
            ErrorMessage = 'SplashAppId must be an XML NCName: start with a letter or underscore, then letters, digits, underscore, dot, or hyphen.'
        )]
        [string]$SplashAppId,

        # Output / signing
        [string]$OutputPath,
        [switch]$DryRun,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    $stages = New-Object System.Collections.Generic.List[object]
    function _Stage([string]$Name, [scriptblock]$Action) {
        $stages.Add([pscustomobject]@{ Name = $Name; Action = $Action })
    }

    # All intermediate stages must write through OutputPath if set so the
    # original is preserved; subsequent stages then read from OutputPath.
    $current = $PackagePath
    if ($OutputPath -and -not $DryRun) {
        Copy-Item $PackagePath $OutputPath -Force
        $current = $OutputPath
    }

    if ($RemoveUninstallers) {
        _Stage 'PrePsf:RemoveUninstallers' {
            Remove-MsixUninstallerArtifact -PackagePath $current -SkipSigning
        }
    }
    if ($RemoveUpdaters) {
        _Stage 'PrePsf:RemoveUpdaters' {
            Remove-MsixUpdaterArtifact -PackagePath $current -SkipSigning
        }
    }
    if ($RemoveDesktopShortcuts) {
        _Stage 'PrePsf:RemoveDesktopShortcuts' {
            Remove-MsixDesktopShortcut -PackagePath $current -SkipSigning
        }
    }
    if ($VersionBumpComponent) {
        _Stage 'PrePsf:BumpVersion' {
            Update-MsixPackageVersion -PackagePath $current -Component $VersionBumpComponent -SkipSigning
        }
    }
    if ($Capabilities) {
        _Stage 'Recommended:AddCapabilities' {
            Add-MsixCapability -PackagePath $current -Names $Capabilities -SkipSigning
        }
    }
    if ($AddFontExtension) {
        _Stage 'Recommended:AddFontExtension' {
            $fonts = Get-MsixFontCandidate -PackagePath $current
            if ($fonts) {
                Add-MsixFontExtension -PackagePath $current -FontPaths @($fonts | Select-Object -ExpandProperty Path) -SkipSigning
            } else {
                Write-MsixLog Info 'AddFontExtension: no font files found in package.'
            }
        }
    }
    if ($AddAliases -or $AliasAppIds) {
        _Stage 'Recommended:AddAliases' {
            # If explicit AliasAppIds were supplied, honour them; otherwise let
            # Get-MsixAliasCandidate pick the top-level user-facing executables.
            if ($AliasAppIds) {
                Add-MsixAlias -PackagePath $current -AppIds $AliasAppIds -SkipSigning
            } else {
                $candidates = @(Get-MsixAliasCandidate -PackagePath $current |
                    Where-Object { -not $_.AlreadyHasAlias })
                if ($candidates) {
                    Add-MsixAlias -PackagePath $current `
                        -AppIds @($candidates | Select-Object -ExpandProperty AppId) `
                        -SkipSigning
                } else {
                    Write-MsixLog Info 'AddAliases: no eligible alias candidates (all apps already aliased or filtered out).'
                }
            }
        }
    }
    if ($PsfFixups -or $PsfAppOptions) {
        _Stage 'Recommended:InjectPsf' {
            $psfArgs = @{
                PackagePath = $current
                Fixups      = $PsfFixups
                SkipSigning = $true
            }
            if ($PsfAppOptions)         { $psfArgs['AppOptions']        = $PsfAppOptions }
            if ($PsfWorkingDirectory)   { $psfArgs['WorkingDirectory']  = $PsfWorkingDirectory }
            if ($PsfAdditionalFiles)    { $psfArgs['AdditionalFiles']   = $PsfAdditionalFiles }
            Add-MsixPsfV2 @psfArgs
        }
    }
    if ($VcRuntimeSourceFolder) {
        _Stage 'Recommended:BundleVcRuntimes' {
            Add-MsixVcRuntimeBundle -PackagePath $current -SourceFolder $VcRuntimeSourceFolder -SkipSigning
        }
    }
    if ($SplashImagePath -and $SplashAppId) {
        _Stage 'Optional:AddSplashImage' {
            Add-MsixSplashScreen -PackagePath $current -ImagePath $SplashImagePath -AppId $SplashAppId -SkipSigning
        }
    }

    if ($DryRun) {
        Write-MsixLog Info "DryRun: would run $($stages.Count) stages."
        return [pscustomobject]@{
            PackagePath = $PackagePath
            Stages      = $stages.Name
            DryRun      = $true
        }
    }

    if ($PSCmdlet.ShouldProcess($current, "Apply $($stages.Count) auto-fix stages")) {
        foreach ($s in $stages) {
            Write-MsixLog Info "==> $($s.Name)"
            & $s.Action
        }
    }

    # Sign once at the end
    if (-not $stages -or -not $stages.Count) {
        Write-MsixLog Info 'No stages selected; nothing to do.'
        return
    }
    Write-MsixLog Info '==> Sign'
    Invoke-MsixSigning -PackagePath $current -Pfx $Pfx -PfxPassword $PfxPassword

    return [pscustomobject]@{
        PackagePath = $current
        Stages      = $stages.Name
        DryRun      = $false
    }
}


function Invoke-MsixAutoFixFromAnalysis {
    <#
    .SYNOPSIS
        Takes the report produced by Invoke-MsixInvestigation /
        Get-MsixCompatibilityReport and translates each finding into the
        right fixer cmdlet, then runs them sequentially with one signing
        pass at the end. The connect-the-dots layer between analysis and
        remediation.
 
    .DESCRIPTION
        Maps Findings.Category to a concrete cmdlet:
 
          UninstallerArtifact -> Remove-MsixUninstallerArtifact
          UpdaterArtifact -> Remove-MsixUpdaterArtifact (skip with -IgnoreUpdaters)
          AppExecutionAlias -> Add-MsixAlias (only AppIds without an existing alias)
          VcRuntime -> Add-MsixVcRuntimeBundle (needs -VcRuntimeSourceFolder)
          ManifestFix:FileSystemWriteVirt.. -> Set-MsixFileSystemWriteVirtualization
          ManifestFix:RegistryWriteVirt.. -> Set-MsixRegistryWriteVirtualization
          ManifestFix:StartupTask -> Add-MsixStartupTask (needs -StartupTaskAppId / -StartupTaskName)
          ManifestFix:LoaderSearchPathOverride -> Add-MsixLoaderSearchPathOverride (needs -LoaderPaths)
          FileRedirectionFixup -> Add-MsixPsfV2 with the SuggestedFixups already in the report
 
        Categories that always need extra inputs (VcRuntime, StartupTask,
        LoaderSearchPathOverride) are skipped with a warning unless the
        relevant -* parameter is supplied.
 
        -DryRun lists the planned fixes without doing anything.
 
    .PARAMETER Report
        Output of Invoke-MsixInvestigation or Get-MsixCompatibilityReport.
 
    .PARAMETER PackagePath
        Override (default: $Report.PackagePath).
 
    .PARAMETER PreferManifestOverPsf
        When both a PSF and a manifest fix are suggested for the same symptom,
        pick the manifest one (modern Windows builds only).
        Default: $true.
 
    .PARAMETER VcRuntimeSourceFolder
        VS Redist folder; required when a VcRuntime finding is in the report.
 
    .PARAMETER StartupTaskAppId / StartupTaskName
        Required when a ManifestFix:StartupTask finding is in the report.
 
    .PARAMETER LoaderPaths
        Required when a ManifestFix:LoaderSearchPathOverride finding is in the report.
 
    .PARAMETER MinConfidence
        Confidence floor (0.0–1.0). Findings whose Confidence is below
        this threshold are kept in the report but NOT auto-fixed. Default
        0.85. Drops to the legacy "every finding auto-fixes" behaviour
        when you pass 0.0. Findings emitted by analyzers that haven't
        been migrated to the evidence model yet (no EvidenceItems) are
        treated as confident — that way nothing regresses while the
        migration is incremental.
 
    .PARAMETER IgnoreUpdaters
        When set, omit the RemoveUpdaters stage from the plan even if the
        report contains UpdaterArtifact findings. Use to keep package
        auto-update binaries in place (e.g. for testing) without filtering
        the report by hand.
 
    .PARAMETER IgnorePluginDirectories
        When set, omit the PluginDirectory stage from the plan even if the
        report contains PluginDirectory findings. Useful when the operator
        has already chosen a different plugin strategy (e.g. host-side
        AppData seeding via PSADT scripts).
 
    .PARAMETER LegacyPluginFix
        Apply plugin-directory write-passthrough via PSF FileRedirection
        instead of the modern desktop6:FileSystemWriteVirtualization +
        ExcludedDirectory route. Default behaviour targets Win10 19041+
        which covers everything from Win10 2004 onward; pass this switch
        when the target fleet still has earlier builds.
 
    .PARAMETER DryRun
        Print the plan and return without mutating.
 
    .PARAMETER OutputPath / Pfx / PfxPassword / SkipSigning (alias NoSign)
        Forwarded to the underlying fixers. Signing only happens once at the end.
 
    .EXAMPLE
        $report = Invoke-MsixInvestigation -PackagePath app.msix
        Invoke-MsixAutoFixFromAnalysis -Report $report `
            -VcRuntimeSourceFolder 'C:\…\VC143.CRT' `
            -Pfx cert.pfx -PfxPassword 'P@ss'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] $Report,
        [string]$PackagePath,
        [bool]$PreferManifestOverPsf = $true,
        [string]$VcRuntimeSourceFolder,
        [string]$StartupTaskAppId,
        [string]$StartupTaskName,
        [string[]]$LoaderPaths,
        [switch]$IgnoreUpdaters,
        [switch]$IgnorePluginDirectories,
        [switch]$LegacyPluginFix,
        [switch]$IgnoreNestedPackages,
        # Confidence threshold below which a finding is NOT auto-fixed.
        # Findings between [MinConfidenceReport, MinConfidence) still
        # appear in the printed plan as "recommendation only".
        # Default 0.85 (high-confidence autofix only).
        [ValidateRange(0.0, 1.0)]
        [double]$MinConfidence = 0.85,
        [switch]$DryRun,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not $PackagePath) { $PackagePath = $Report.PackagePath }
    if (-not $PackagePath) { throw 'PackagePath could not be inferred from the report.' }

    # Normalise the report's findings into the evidence-graph shape.
    # Legacy analyzers that emitted plain pscustomobjects get promoted on
    # the fly (synthetic evidence based on Severity). Same-category +
    # same-AppId findings collapse into one with combined evidence so
    # we don't repeat fixers when multiple analyzers agreed.
    $rawFindings    = @($Report.Findings)
    $mergedFindings = if ($rawFindings.Count -gt 0) {
        Merge-MsixFinding -Findings $rawFindings
    } else { @() }

    # MinConfidence gate. Three classes:
    # - PromotedFromLegacy : always pass. These come from analyzers that
    # pre-date the evidence model and were classified by severity only.
    # Gating them on confidence would silently regress every existing
    # analyzer's behaviour.
    # - No EvidenceItems : always pass. Defensive — shouldn't happen
    # post-merge but never drop a finding just because it has zero
    # evidence items.
    # - New-shape with explicit EvidenceItems : must clear MinConfidence.
    $confidentFindings = @($mergedFindings | Where-Object {
        $items = @($_.EvidenceItems)
        ($_.PSObject.Properties.Match('PromotedFromLegacy').Count -gt 0 -and $_.PromotedFromLegacy) -or `
        ($items.Count -eq 0) -or `
        ([double]$_.Confidence -ge $MinConfidence)
    })

    # Categorise the autofixable findings into a stable plan
    $plan = New-Object System.Collections.Generic.List[object]

    $byCat = @{}
    foreach ($f in @($confidentFindings)) {
        if ($f -and $f.Category) { $byCat[$f.Category] = $true }
    }

    # Stage 1 — strip uninstaller artefacts (files + registry)
    if ($byCat.ContainsKey('UninstallerArtifact') -or $byCat.ContainsKey('UninstallRegistry')) {
        $plan.Add([pscustomobject]@{
            Stage  = 'RemoveUninstallers'
            Reason = 'Findings include uninstaller-looking files and/or leftover Uninstall registry keys'
            Action = { Remove-MsixUninstallerArtifact -PackagePath $current -SkipSigning }
        })
    }

    # Stage 1b — strip auto-updater binaries and scheduled-task XMLs
    if ($byCat.ContainsKey('UpdaterArtifact') -and -not $IgnoreUpdaters) {
        $plan.Add([pscustomobject]@{
            Stage  = 'RemoveUpdaters'
            Reason = 'Findings include auto-updater binaries or scheduled-task XMLs'
            Action = { Remove-MsixUpdaterArtifact -PackagePath $current -SkipSigning }
        })
    }

    # Stage 2 — manifest-only virtualization (preferred over PSF when matching)
    $hasFsManifestFix  = $byCat.ContainsKey('ManifestFix:FileSystemWriteVirtualization')
    $hasRegManifestFix = $byCat.ContainsKey('ManifestFix:RegistryWriteVirtualization')
    $hasStartupFix     = $byCat.ContainsKey('ManifestFix:StartupTask')
    $hasLoaderFix      = $byCat.ContainsKey('ManifestFix:LoaderSearchPathOverride')

    if ($hasFsManifestFix -and $PreferManifestOverPsf) {
        $plan.Add([pscustomobject]@{
            Stage  = 'FileSystemWriteVirtualization'
            Reason = 'Package writes to install dir; manifest fix is simpler than PSF'
            Action = { Set-MsixFileSystemWriteVirtualization -PackagePath $current -SkipSigning }
        })
    }
    if ($hasRegManifestFix -and $PreferManifestOverPsf) {
        $plan.Add([pscustomobject]@{
            Stage  = 'RegistryWriteVirtualization'
            Reason = 'Package writes to HKLM; manifest fix is simpler than RegLegacy Hklm2Hkcu'
            Action = { Set-MsixRegistryWriteVirtualization -PackagePath $current -SkipSigning }
        })
    }

    # Stage 2.5 — plugin/theme/extension directories.
    # Modern path: enable desktop6:FileSystemWriteVirtualization + add each
    # plugin dir to <virtualization:ExcludedDirectories> so writes there
    # pass through to the host filesystem and survive across sessions.
    # Legacy path: PSF FileRedirection mapping the dir to per-user AppData.
    if ($byCat.ContainsKey('PluginDirectory') -and -not $IgnorePluginDirectories) {
        $pluginFindings = @($Report.Findings | Where-Object Category -eq 'PluginDirectory')
        $pluginDirs = @($pluginFindings | ForEach-Object { $_.Evidence } | Where-Object { $_ } | Sort-Object -Unique)
        if ($pluginDirs) {
            $capturedPluginDirs = $pluginDirs
            if ($LegacyPluginFix) {
                # Wide-compat: PSF FileRedirection per plugin folder.
                $plan.Add([pscustomobject]@{
                    Stage  = 'PluginDirectory'
                    Reason = "PSF FileRedirection passthrough for $($capturedPluginDirs.Count) extension folder(s): $($capturedPluginDirs -join ', ')"
                    Action = {
                        $fixups = @(foreach ($d in $capturedPluginDirs) {
                            # Normalise '\' to '/' for the PSF base path; '.*' covers
                            # everything underneath since plugin payloads vary.
                            New-MsixPsfFileRedirectionConfig -Base ($d -replace '\\','/') -Patterns '.*'
                        })
                        Add-MsixPsfV2 -PackagePath $current -Fixups $fixups -SkipSigning
                    }
                })
            } else {
                # Modern path (Win10 19041+): selective virtualization carve-out.
                # Set-MsixFileSystemWriteVirtualization defaults excluded dirs to
                # LocalAppData + RoamingAppData; extend that list with the plugin
                # folders so the explicit declaration is single-call.
                $plan.Add([pscustomobject]@{
                    Stage  = 'PluginDirectory'
                    Reason = "Selective virtualization passthrough for $($capturedPluginDirs.Count) extension folder(s): $($capturedPluginDirs -join ', ')"
                    Action = {
                        $excluded = @('$(KnownFolder:LocalAppData)','$(KnownFolder:RoamingAppData)') + @($capturedPluginDirs | ForEach-Object { $_ -replace '\\','/' })
                        # We DISABLE legacy virtualization and exclude these
                        # specific directories from the virtualization layer
                        # so writes inside them land on the real filesystem.
                        Set-MsixFileSystemWriteVirtualization -PackagePath $current `
                            -ExcludedDirectories $excluded -SkipSigning
                    }
                })
            }
        }
    }
    if ($hasStartupFix) {
        if ($StartupTaskAppId -and $StartupTaskName) {
            $plan.Add([pscustomobject]@{
                Stage  = 'StartupTask'
                Reason = 'Replace HKLM\Run autostart with windows.startupTask'
                Action = {
                    Add-MsixStartupTask -PackagePath $current `
                        -AppId $StartupTaskAppId -TaskId "$StartupTaskAppId-AutoStart" `
                        -DisplayName $StartupTaskName -Enabled $true -SkipSigning
                }
            })
        } else {
            Write-MsixLog Warning 'Skipping StartupTask: -StartupTaskAppId and -StartupTaskName are required.'
        }
    }
    if ($hasLoaderFix) {
        if ($LoaderPaths) {
            $plan.Add([pscustomobject]@{
                Stage  = 'LoaderSearchPathOverride'
                Reason = 'Replace DLL load failures with manifest declaration'
                Action = { Add-MsixLoaderSearchPathOverride -PackagePath $current -Paths $LoaderPaths -SkipSigning }
            })
        } else {
            Write-MsixLog Warning 'Skipping LoaderSearchPathOverride: -LoaderPaths is required.'
        }
    }

    # Stage 2b — remove desktop shortcuts
    if ($byCat.ContainsKey('DesktopShortcuts')) {
        $plan.Add([pscustomobject]@{
            Stage  = 'RemoveDesktopShortcuts'
            Reason = 'Package ships .lnk files under VFS desktop folders'
            Action = { Remove-MsixDesktopShortcut -PackagePath $current -SkipSigning }
        })
    }

    # Stage 2c — register fonts via uap4:SharedFonts
    if ($byCat.ContainsKey('ManifestFix:SharedFonts')) {
        $plan.Add([pscustomobject]@{
            Stage  = 'AddFontExtension'
            Reason = 'Package ships font files not registered via uap4:SharedFonts'
            Action = {
                $fonts = Get-MsixFontCandidate -PackagePath $current
                if ($fonts) {
                    $fontPaths = @($fonts | Select-Object -ExpandProperty Path)
                    Add-MsixFontExtension -PackagePath $current -FontPaths $fontPaths -SkipSigning
                }
            }
        })
    }

    # Stage 2d — add capability hints
    $capHintFindings = @($Report.Findings | Where-Object Category -eq 'CapabilityHints')
    if ($capHintFindings) {
        $capHintNames = @($capHintFindings.Evidence -split ',\s*' | Where-Object { $_ } | Sort-Object -Unique)
        if ($capHintNames) {
            $plan.Add([pscustomobject]@{
                Stage  = 'AddCapabilityHints'
                Reason = "PE-import hints suggest capabilities: $($capHintNames -join ', ')"
                Action = { Add-MsixCapability -PackagePath $current -Names $capHintNames -SkipSigning }
            })
        }
    }

    # Stage 2e — plain command-based shell verbs (HKCR\*\shell\<verb>\command)
    # These verbs have no CLSID, so desktop9:fileExplorerClassicContextMenuHandler
    # cannot be applied directly. The correct fix is to wrap the command as a COM
    # surrogate server (IContextMenu), register it via Add-MsixLegacyContextMenu, and
    # update the CLSID references in Registry.dat — a manual operation.
    # ExplorerCommandHandler verbs (which DO have a CLSID) are already classified as
    # ShellExt during detection and handled by stage 2g below.
    if ($byCat.ContainsKey('ShellVerb')) {
        $shellVerbFinding = @($Report.Findings | Where-Object Category -eq 'ShellVerb') | Select-Object -First 1
        $verbNames = ($shellVerbFinding.ShellEntries | ForEach-Object { $_.VerbName } | Where-Object { $_ }) -join ', '
        Write-MsixLog Info "ShellVerb: $($shellVerbFinding.ShellEntries.Count) plain command shell verb(s) detected ($verbNames). Cannot be auto-fixed — desktop9:fileExplorerClassicContextMenuHandler requires a COM CLSID. Convert to a COM surrogate server and use Add-MsixLegacyContextMenu."
    }

    # Stage 2f.5 — merge nested (sparse) shell-extension packages
    # Sparse inner .msix packages cannot be activated post-install: the COM
    # surrogate host can't traverse the inner zip. The fix is to lift their
    # manifest declarations + payload into the outer package BEFORE the
    # ShellExt / AddLegacyContextMenu stage so any downstream detection sees
    # the merged declarations.
    if ($byCat.ContainsKey('NestedPackage') -and -not $IgnoreNestedPackages) {
        $nested = @($Report.Findings | Where-Object Category -eq 'NestedPackage')
        foreach ($n in $nested) {
            $captured = $n.Evidence  # package-relative path of the nested .msix
            $plan.Add([pscustomobject]@{
                Stage  = 'ImportSparseShellExtension'
                Reason = "Merge nested package '$captured' into outer manifest"
                Action = {
                    Import-MsixSparseShellExtension -PackagePath $current -NestedPackagePath $captured -SkipSigning
                }
            })
        }
    }

    # Stage 2g — COM shellex context menu via desktop4 + desktop5 (TMEditX pattern)
    if ($byCat.ContainsKey('ShellExt')) {
        $shellExtFinding = @($Report.Findings | Where-Object Category -eq 'ShellExt') | Select-Object -First 1
        $autoFixable     = @($shellExtFinding.ShellEntries | Where-Object { $_.Clsid -and $_.VfsDllPath })
        if ($autoFixable) {
            $capturedShellEntries = $autoFixable   # capture for closure
            $plan.Add([pscustomobject]@{
                Stage  = 'AddLegacyContextMenu'
                Reason = "Register $($capturedShellEntries.Count) shellex COM handler(s) via desktop4/desktop5"
                Action = {
                    foreach ($entry in $capturedShellEntries) {
                        $ft = @(if ($entry.Target -eq '*') { '*' } else { $entry.Target })
                        Add-MsixLegacyContextMenu -PackagePath $current `
                            -ShellExtDll $entry.VfsDllPath `
                            -Clsid $entry.Clsid `
                            -DisplayName $entry.HandlerName `
                            -FileTypes $ft `
                            -SkipSigning
                    }
                }
            })

            # Stage 2g.b — strip the OLD shellex/shell registry entries from
            # Registry.dat now that the modern manifest declaration handles
            # them. Without this, the package's HKCR\<target>\shellex\... and
            # HKCR\<target>\shell\... entries persist and the OS registers the
            # handler TWICE — surfacing as duplicate items in File Explorer's
            # right-click menu (issue #28).
            $plan.Add([pscustomobject]@{
                Stage  = 'StripLegacyShellRegistry'
                Reason = "Remove old Registry.dat shell/shellex entries for $($capturedShellEntries.Count) handler(s) so they don't double-register alongside the new desktop4 declaration"
                Action = {
                    Remove-MsixShellRegistryArtifact -PackagePath $current `
                        -Entries $capturedShellEntries -SkipSigning
                }
            })
        } else {
            Write-MsixLog Info "ShellExt: CLSID/VFS DLL path could not be resolved (the bundled DLL may not be Authenticode-stamped with the CLSID, or the package omits the COM class registration). Call Add-MsixLegacyContextMenu manually with the CLSID and -ShellExtDll path."
        }
    }

    # Stage 2h — COM InProcessServer declaration (com:Extension, windows.comServer)
    if ($byCat.ContainsKey('ComServer')) {
        $comFinding = @($Report.Findings | Where-Object Category -eq 'ComServer') | Select-Object -First 1
        # Entries that have a VFS DLL path (package-bundled, auto-fixable)
        # and are not already handled by the ShellExt stage (SurrogateServer)
        $shellExtClsids = @()
        if ($byCat.ContainsKey('ShellExt')) {
            $seF = @($Report.Findings | Where-Object Category -eq 'ShellExt') | Select-Object -First 1
            $shellExtClsids = @($seF.ShellEntries.Clsid | Where-Object { $_ })
        }
        $autoComServers = @($comFinding.ComEntries | Where-Object {
            $_.VfsDllPath -and $_.Clsid -notin $shellExtClsids
        })
        if ($autoComServers) {
            $capturedComServers = $autoComServers
            $plan.Add([pscustomobject]@{
                Stage  = 'AddComServer'
                Reason = "Declare $($capturedComServers.Count) bundled COM InProcessServer(s) in the manifest"
                Action = {
                    $serverSpecs = @($capturedComServers | ForEach-Object {
                        @{ Clsid = $_.Clsid; VfsDllPath = $_.VfsDllPath; ThreadingModel = $_.ThreadingModel }
                    })
                    Add-MsixComServerExtension -PackagePath $current -Servers $serverSpecs -SkipSigning
                }
            })
        } else {
            Write-MsixLog Info "ComServer: no auto-fixable InProc servers (none of the detected CLSIDs resolved to a VFS-bundled DLL)."
        }
    }

    # Stage 2i — AppExecutionAlias suggestions
    # Get-MsixAliasCandidate emits one AppExecutionAlias finding per top-level
    # user-facing exe that lacks an alias. Auto-fix: register the alias for the
    # AppIds carried on those findings.
    if ($byCat.ContainsKey('AppExecutionAlias')) {
        $aliasFindings = @($Report.Findings | Where-Object Category -eq 'AppExecutionAlias')
        $aliasAppIds   = @($aliasFindings | ForEach-Object { $_.AppId } | Where-Object { $_ } | Sort-Object -Unique)
        if ($aliasAppIds) {
            $capturedAliasIds = $aliasAppIds
            $plan.Add([pscustomobject]@{
                Stage  = 'AddAliases'
                Reason = "Register AppExecutionAlias for $($capturedAliasIds.Count) app(s): $($capturedAliasIds -join ', ')"
                Action = { Add-MsixAlias -PackagePath $current -AppIds $capturedAliasIds -SkipSigning }
            })
        }
    }

    # Stage 3 — VC runtime bundle
    if ($byCat.ContainsKey('VcRuntime')) {
        if ($VcRuntimeSourceFolder) {
            $plan.Add([pscustomobject]@{
                Stage  = 'BundleVcRuntimes'
                Reason = 'Package references VC runtime DLLs that are not bundled'
                Action = { Add-MsixVcRuntimeBundle -PackagePath $current -SourceFolder $VcRuntimeSourceFolder -SkipSigning }
            })
        } else {
            Write-MsixLog Warning 'Skipping VcRuntime bundle: -VcRuntimeSourceFolder is required.'
        }
    }

    # Stage 4 — PSF fixups (only those NOT already covered by a manifest fix)
    if ($Report.SuggestedFixups -and $Report.SuggestedFixups.Count -gt 0) {
        $skipPsfFs  = $hasFsManifestFix  -and $PreferManifestOverPsf
        $skipPsfReg = $hasRegManifestFix -and $PreferManifestOverPsf
        $kept = @($Report.SuggestedFixups | Where-Object {
            -not (
                ($skipPsfFs  -and $_.dll -in 'FileRedirectionFixup.dll','MFRFixup.dll') -or
                ($skipPsfReg -and $_.dll -eq 'RegLegacyFixups.dll')
            )
        })
        if ($kept.Count -gt 0) {
            $plan.Add([pscustomobject]@{
                Stage  = 'InjectPsf'
                Reason = "Apply $($kept.Count) PSF fixup(s) from analysis"
                Action = { Add-MsixPsfV2 -PackagePath $current -Fixups $kept -SkipSigning }
            })
        }
    }

    if (-not $plan -or -not $plan.Count) {
        Write-MsixLog Info 'Nothing actionable in the report. Either no findings or all need manual parameters.'
        return [pscustomobject]@{
            PackagePath = $PackagePath
            Plan        = @()
            DryRun      = [bool]$DryRun
        }
    }

    # Emit the plan
    Write-MsixLog Info '─── AutoFix plan ───'
    foreach ($p in $plan) {
        Write-MsixLog Info " $($p.Stage) ($($p.Reason))"
    }

    if ($DryRun) {
        return [pscustomobject]@{
            PackagePath = $PackagePath
            Plan        = $plan
            DryRun      = $true
        }
    }

    # Stage execution — write to OutputPath if asked, otherwise overwrite in-place
    $current = $PackagePath
    if ($OutputPath -and ($OutputPath -ne $PackagePath)) {
        Copy-Item $PackagePath $OutputPath -Force
        $current = $OutputPath
    }

    foreach ($p in $plan) {
        Write-MsixLog Info "==> $($p.Stage)"
        & $p.Action
    }

    if (-not $SkipSigning) {
        Write-MsixLog Info '==> Sign'
        Invoke-MsixSigning -PackagePath $current -Pfx $Pfx -PfxPassword $PfxPassword
    } else {
        Write-MsixLog Info 'NoSign requested; package left unsigned.'
    }

    return [pscustomobject]@{
        PackagePath = $current
        Plan        = $plan
        DryRun      = $false
    }
}
#endregion