Netscoot.Core/Public/Repair-SolutionReferences.ps1

function Repair-SolutionReferences {
    <#
    .SYNOPSIS
        Scan a repository for broken solution membership and dangling ProjectReferences and repair them
        by re-pointing each entry at the project's new location.
 
    .DESCRIPTION
        Finds solution entries and `<ProjectReference>`s that point at a project file which no longer
        exists at the recorded path (usually because a project was moved or renamed without
        reconciling). Read-only by default: It returns one object per problem, each tagged with a
        Resolution of Relocatable, Missing, or Ambiguous.
 
        With -Fix it repairs every Relocatable entry: It searches the repository for a project file of the
        same name and re-points the entry at it through the dotnet CLI (remove the stale path, add
        the found one). When one project of that name exists it is used directly. When several do,
        the one that keeps the most of the original path's trailing folders is chosen, since a moved
        project usually keeps its own folder name. Entries it cannot resolve are left untouched and
        reported, Missing (no such project anywhere) or Ambiguous (several equally-good candidates).
        A relocatable .vcxproj is reported with its new location but not re-pointed, because the
        dotnet CLI cannot load a native project. Re-point it in Visual Studio.
 
        With -Prune it removes the Missing entries, the genuinely deleted ones, through the dotnet
        CLI. -Prune never touches Relocatable or Ambiguous entries. -Fix and -Prune can be combined.
 
    .PARAMETER RepositoryRoot
        Root to scan. Accepts pipeline input: a path string, or a directory item from Get-Item.
        Defaults to the enclosing git repository root of the current directory.
 
    .PARAMETER Fix
        Re-point each dangling entry at the moved project when its new location is unambiguous.
        Honors -WhatIf.
 
    .PARAMETER Prune
        Remove entries whose project cannot be found anywhere in the repository. Honors -WhatIf.
 
    .OUTPUTS
        Netscoot.RepairResult - one per dangling entry.
 
    .EXAMPLE
        # Report dangling entries only - read-only (each tagged Relocatable, Missing, or Ambiguous)
        Repair-SolutionReferences -RepositoryRoot .
        # Re-point relocatable entries at the project's new location (relocates, never deletes)
        Repair-SolutionReferences -RepositoryRoot . -Fix
        # Also remove entries whose project is gone for good - preview the whole thing first
        Repair-SolutionReferences -RepositoryRoot . -Fix -Prune -WhatIf
 
    .LINK
        Get-SolutionInventory
 
    .LINK
        Test-SolutionConsistency
 
    .LINK
        Sync-Solution
 
    .LINK
        Find-PathReference
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('Netscoot.RepairResult')]
    param(
        [Parameter(Position = 0, ValueFromPipeline)]
        [Netscoot.PathInputTransform()]
        [string]$RepositoryRoot,
        [switch]$Fix,
        [switch]$Prune
    )

    process {
        if (-not (Assert-DotnetAvailable -Cmdlet $PSCmdlet)) { return }
        if (-not $RepositoryRoot) { $RepositoryRoot = Get-RepositoryRoot -StartPath (Get-Location).Path }
        $RepositoryRoot = Resolve-FullPath $RepositoryRoot

        # One repository parse for this invocation: the project glob (for the leaf index and the
        # reference scan) and every solution come from the workspace, so no file is read twice.
        $workspace = Get-Workspace -RepositoryRoot $RepositoryRoot
        $managedProjects = @(Get-WorkspaceProjectFiles -Workspace $workspace)

        # Index existing project files (native included) by leaf name, so a dangling target can be
        # matched to where its project now lives.
        $byLeaf = @{}
        foreach ($pf in @(Get-WorkspaceProjectFiles -Workspace $workspace -IncludeNative)) {
            if (-not $byLeaf.ContainsKey($pf.Name)) { $byLeaf[$pf.Name] = [System.Collections.Generic.List[string]]::new() }
            $byLeaf[$pf.Name].Add($pf.FullName)
        }

        # First pass: collect the dangling entries (a path recorded somewhere points at a project
        # file that no longer exists there).
        $dangling = [System.Collections.Generic.List[object]]::new()
        foreach ($parsed in (Get-WorkspaceSolutions -Workspace $workspace)) {
            foreach ($entry in $parsed.Projects) {
                if ($entry.Stored -notmatch '\.(cs|fs|vb|vcx)proj$') { continue }
                if (-not (Test-Path -LiteralPath $entry.Abs)) {
                    $dangling.Add([pscustomobject]@{ Kind = 'Solution'; Container = $parsed.FullName; Missing = $entry.Stored; MissingAbs = $entry.Abs })
                }
            }
        }
        foreach ($proj in $managedProjects) {
            foreach ($ref in (Get-WorkspaceProjectRefs -Workspace $workspace -ProjectFile $proj.FullName)) {
                # Non-literal references (MSBuild property / glob / conditional) have no single
                # resolved path, so they cannot be "dangling" in a way we could repair - skip them.
                if (-not $ref.IsLiteral) { continue }
                if (-not (Test-Path -LiteralPath $ref.FullPath)) {
                    $dangling.Add([pscustomobject]@{ Kind = 'Reference'; Container = $proj.FullName; Missing = $ref.Raw; MissingAbs = $ref.FullPath })
                }
            }
        }

        # Second pass: classify each by where (if anywhere) its project now lives.
        $problems = [System.Collections.Generic.List[object]]::new()
        foreach ($d in $dangling) {
            $leaf = Split-Path -Leaf $d.MissingAbs
            $cands = @()
            if ($byLeaf.ContainsKey($leaf)) { $cands = @($byLeaf[$leaf]) }
            $n = $cands.Count
            if ($n -eq 0) {
                $resolution = 'Missing'; $newPath = $null
            } elseif ($n -eq 1) {
                $resolution = 'Relocatable'; $newPath = $cands[0]
            } else {
                # Several projects share this leaf name. Disambiguate by which candidate keeps the
                # most of the original path's trailing folders (a moved project usually keeps its
                # own folder name); only auto-resolve when that best match is unique.
                $best = Select-BestSuffixMatch -Original $d.MissingAbs -Candidates $cands
                if ($best) { $resolution = 'Relocatable'; $newPath = $best }
                else { $resolution = 'Ambiguous'; $newPath = $null }
            }
            $problems.Add([pscustomobject]@{
                    PSTypeName = 'Netscoot.RepairResult'
                    Kind       = $d.Kind
                    Resolution = $resolution
                    Missing    = $d.Missing
                    NewPath    = $newPath
                    Container  = $d.Container
                    MissingAbs = $d.MissingAbs
                    Candidates = $cands
                })
        }

        if ($problems.Count -eq 0) {
            Write-Host 'No dangling solution entries or project references found.' -ForegroundColor Green
            return
        }

        Write-Host "Found $($problems.Count) dangling entr$(if ($problems.Count -eq 1) { 'y' } else { 'ies' }):" -ForegroundColor Yellow
        ($problems | Format-Table Kind, Resolution, Missing, Container -AutoSize | Out-String) | Write-Host

        if (-not ($Fix -or $Prune)) {
            Write-Host 'Run with -Fix to re-point movable entries, or -Prune to remove ones whose project is gone.' -ForegroundColor Cyan
            return $problems
        }

        foreach ($p in $problems) {
            if ($Fix -and $p.Resolution -eq 'Relocatable' -and (Test-IsNativeProject $p.NewPath)) {
                # The dotnet CLI cannot load a .vcxproj to re-add it.
                Write-Warning "$($p.Missing) moved to $($p.NewPath). Re-point it in Visual Studio (a native project cannot be re-added through the dotnet CLI)."
            } elseif ($Fix -and $p.Resolution -eq 'Relocatable') {
                if ($p.Kind -eq 'Solution') {
                    if ($PSCmdlet.ShouldProcess($p.Container, "re-point $($p.Missing) -> $($p.NewPath)")) {
                        Invoke-Dotnet sln $p.Container remove $p.MissingAbs
                        Invoke-Dotnet sln $p.Container add $p.NewPath
                    }
                } else {
                    if ($PSCmdlet.ShouldProcess($p.Container, "re-point reference $($p.Missing) -> $($p.NewPath)")) {
                        Invoke-Dotnet remove $p.Container reference $p.MissingAbs
                        Invoke-Dotnet add $p.Container reference $p.NewPath
                    }
                }
            } elseif ($Prune -and $p.Resolution -eq 'Missing') {
                if ($p.Kind -eq 'Solution') {
                    if ($PSCmdlet.ShouldProcess($p.Container, "remove gone entry $($p.Missing)")) { Invoke-Dotnet sln $p.Container remove $p.MissingAbs }
                } else {
                    if ($PSCmdlet.ShouldProcess($p.Container, "remove gone reference $($p.Missing)")) { Invoke-Dotnet remove $p.Container reference $p.MissingAbs }
                }
            } else {
                $why = switch ($p.Resolution) {
                    'Relocatable' { 'movable, run with -Fix' }
                    'Missing' { 'project not found, run with -Prune to remove' }
                    'Ambiguous' { 'more than one candidate, resolve by hand' }
                }
                Write-Host " skipped [$($p.Kind)] $($p.Missing): $why" -ForegroundColor DarkYellow
            }
        }
        return $problems
    }
}