Netscoot.Core/Public/Test-EditorSolutionGuard.ps1

function Test-EditorSolutionGuard {
    <#
    .SYNOPSIS
        Check that a repository's editor configuration will keep a .slnx consolidation durable -
        i.e. that VS Code's C# Dev Kit will not silently re-mint a legacy .sln next to it.
 
    .DESCRIPTION
        Consolidating to a single .slnx is not durable on its own. VS Code's C# Dev Kit
        AUTO-GENERATES a legacy .sln next to a .slnx on folder open unless
        'dotnet.automaticallyCreateSolutionInWorkspace' is false, so a regenerated .sln reappears and
        drifts (the exact stale-duplicate that Test-SolutionConsistency detects after the fact).
        When at least one .slnx exists in the repository, this inspects the repository-root editor
        config and reports whether the guards that keep the consolidation durable are in place:
 
          AutoCreateGuard .vscode/settings.json must set
                           'dotnet.automaticallyCreateSolutionInWorkspace' to false (else Dev Kit
                           re-mints the .sln).
          DefaultSolution 'dotnet.defaultSolution' should point at a real, existing solution (ideally
                           the .slnx). Missing, or pointing at a deleted/nonexistent file, means Dev
                           Kit chooses which solution loads - possibly a stray .sln.
          GitignoreGuard .gitignore should ignore *.sln so a regenerated one cannot be committed.
 
        Read-only: it never edits settings, .gitignore, or any solution. It emits one result object
        per check and surfaces findings through the standard streams so behavior follows invocation:
        by default it writes a Warning for each failed guard; -Strict escalates each Warning-level
        finding to a non-terminating error (honoring -ErrorAction). Info-level findings (e.g. a
        missing .gitignore guard) are emitted as objects and shown under -Verbose, never as warnings.
 
        This is editor-specific (VS Code C# Dev Kit) because that is what governs solution drift in
        practice; the checks only run when the repository actually contains a .slnx.
 
    .PARAMETER RepositoryRoot
        Root to inspect. Accepts pipeline input: a path string, or a file/directory item from
        Get-Item / Get-ChildItem. Defaults to the enclosing git repository root.
 
    .PARAMETER Strict
        Escalate each Warning-level guard finding to a non-terminating error (for CI gating).
 
    .OUTPUTS
        Netscoot.EditorSolutionGuard - one per check performed.
 
    .EXAMPLE
        # Check the current repository's editor guards
        Test-EditorSolutionGuard
        # Gate CI on the consolidation being durable
        Test-EditorSolutionGuard -RepositoryRoot . -Strict
        # Inspect a specific repository from the pipeline
        Get-Item ./repo | Test-EditorSolutionGuard
 
    .LINK
        Test-SolutionConsistency
 
    .LINK
        Get-SolutionInventory
    #>

    [CmdletBinding()]
    [OutputType('Netscoot.EditorSolutionGuard')]
    param(
        [Parameter(Position = 0, ValueFromPipeline)]
        [Netscoot.PathInputTransform()]
        [string]$RepositoryRoot,
        [switch]$Strict
    )

    process {
        if (-not $RepositoryRoot) { $RepositoryRoot = Get-RepositoryRoot -StartPath (Get-Location).Path }
        $root = (Resolve-FullPath $RepositoryRoot).TrimEnd('\', '/')

        # The guard only applies when a .slnx is (or is becoming) the source of truth.
        $slnx = @(Find-Solutions -Root $root | Where-Object { $_.Extension -eq '.slnx' })
        if (-not $slnx.Count) {
            Write-Verbose "No .slnx under $root; the editor solution guard only applies when a .slnx is the source of truth."
            return
        }

        # Records accumulate in this list (mutated in-place by _emit, which reads it from the
        # enclosing scope - no variable reassignment, so no scope quirk). The list also drives the
        # final "all good" decision, so no separate counter is needed.
        $records = [System.Collections.Generic.List[object]]::new()
        function _emit([string]$Check, [string]$Severity, [string]$Detail) {
            $record = [pscustomobject]@{
                PSTypeName = 'Netscoot.EditorSolutionGuard'
                Check      = $Check
                Severity   = $Severity
                Detail     = $Detail
            }
            switch ($Severity) {
                'Warning' {
                    if ($Strict) {
                        Write-Error -Message $Detail -Category InvalidData -TargetObject $record -ErrorId "EditorGuard$Check"
                    } else {
                        Write-Warning $Detail
                    }
                }
                'Info' { Write-Verbose "${Check}: $Detail" }
            }
            $records.Add($record)
            $record   # emit to the pipeline so it is capturable/filterable
        }

        $settingsPath = [System.IO.Path]::Combine($root, '.vscode', 'settings.json')
        $settings = $null
        if (Test-Path -LiteralPath $settingsPath -PathType Leaf) {
            try { $settings = ConvertFrom-Jsonc -Text (Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8) }
            catch {
                _emit 'AutoCreateGuard' 'Warning' ".vscode/settings.json exists but could not be parsed as JSON ($($_.Exception.Message)); cannot confirm the Dev Kit auto-create guard."
                # Without parseable settings, the remaining settings-based checks cannot run.
                $settings = $null
            }
        }

        # --- AutoCreateGuard ---
        if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) {
            _emit 'AutoCreateGuard' 'Info' "No .vscode/settings.json. If this repository is opened in VS Code with the C# Dev Kit, it will auto-create a legacy .sln next to the .slnx unless 'dotnet.automaticallyCreateSolutionInWorkspace' is set to false."
        } elseif ($null -ne $settings) {
            $autoProp = $settings.PSObject.Properties['dotnet.automaticallyCreateSolutionInWorkspace']
            if (-not $autoProp) {
                _emit 'AutoCreateGuard' 'Warning' "'dotnet.automaticallyCreateSolutionInWorkspace' is not set in .vscode/settings.json. VS Code's C# Dev Kit will regenerate a legacy .sln next to your .slnx; set it to false to keep the consolidation durable."
            } elseif ($autoProp.Value -eq $true) {
                _emit 'AutoCreateGuard' 'Warning' "'dotnet.automaticallyCreateSolutionInWorkspace' is true in .vscode/settings.json. Set it to false so the C# Dev Kit does not regenerate a legacy .sln next to your .slnx."
            } else {
                _emit 'AutoCreateGuard' 'OK' "'dotnet.automaticallyCreateSolutionInWorkspace' is false; the C# Dev Kit will not regenerate a .sln."
            }
        }

        # --- DefaultSolution ---
        if ($null -ne $settings) {
            $defProp = $settings.PSObject.Properties['dotnet.defaultSolution']
            if (-not $defProp) {
                _emit 'DefaultSolution' 'Warning' "'dotnet.defaultSolution' is not set in .vscode/settings.json. The C# Dev Kit will choose which solution loads, which may be a stray .sln. Point it at your .slnx."
            } elseif ("$($defProp.Value)" -eq 'disable') {
                _emit 'DefaultSolution' 'OK' "'dotnet.defaultSolution' is 'disable'; the C# Dev Kit will not auto-load a solution."
            } else {
                $defVal = "$($defProp.Value)"
                $defAbs = [System.IO.Path]::GetFullPath((Join-Path $root ($defVal.Replace('/', [System.IO.Path]::DirectorySeparatorChar).Replace('\', [System.IO.Path]::DirectorySeparatorChar))))
                if (-not (Test-Path -LiteralPath $defAbs -PathType Leaf)) {
                    _emit 'DefaultSolution' 'Warning' "'dotnet.defaultSolution' points at '$defVal', which does not exist. Point it at an existing .slnx."
                } elseif ([System.IO.Path]::GetExtension($defAbs) -ieq '.sln') {
                    _emit 'DefaultSolution' 'Info' "'dotnet.defaultSolution' points at a legacy .sln ('$defVal'), not your .slnx. Repoint it at the .slnx so the consolidated solution is the one that loads."
                } else {
                    _emit 'DefaultSolution' 'OK' "'dotnet.defaultSolution' points at an existing solution ('$defVal')."
                }
            }
        }

        # --- GitignoreGuard ---
        $gitignorePath = [System.IO.Path]::Combine($root, '.gitignore')
        $hasSlnGuard = $false
        if (Test-Path -LiteralPath $gitignorePath -PathType Leaf) {
            foreach ($line in (Get-Content -LiteralPath $gitignorePath -ErrorAction SilentlyContinue)) {
                $t = $line.Trim()
                if (-not $t -or $t.StartsWith('#')) { continue }
                if ($t -match '^/?(\*\*/)?\*\.sln$') { $hasSlnGuard = $true; break }
            }
        }
        if (-not $hasSlnGuard) {
            _emit 'GitignoreGuard' 'Info' "No '*.sln' rule in .gitignore. With a .slnx as the source of truth, ignore '*.sln' so a regenerated legacy solution cannot be committed."
        } else {
            _emit 'GitignoreGuard' 'OK' ".gitignore ignores '*.sln'; a regenerated legacy solution cannot be committed."
        }

        if (-not @($records | Where-Object { $_.Severity -eq 'Warning' }).Count) {
            Write-Host "Editor solution guards look good - the .slnx consolidation is durable." -ForegroundColor Green
        }
    }
}