Payload/scripts/kaden/utils/strip-bom.ps1

<#
.SYNOPSIS
    Rewrite files to UTF-8 without a BOM.

.DESCRIPTION
    Repo PowerShell is UTF-8 without a BOM (PSScriptAnalyzerSettings.psd1 excludes
    PSUseBOMForUnicodeEncodedFile for that reason; lint-powershell.ps1 fails when
    EF BB BF is present). This script strips a UTF-8 BOM, and converts UTF-16 LE
    or BE (BOM) to UTF-8 without a BOM. Files that already have no BOM are left
    unchanged.

    Pass one or more file paths. Relative paths resolve from the current
    directory, then from the repository root. Rewrites stay inside the
    repository unless -AllowOutsideRepo is set.

    -StagedOnly rewrites staged *.ps1 / *.psm1 / *.psd1 under the current git
    toplevel (pre-commit Phase 1), then re-stages any file that changed.

    pwsh -File passes each path as its own argument. Use bare paths, or -Path
    for the first path and the rest bare. A direct call inside pwsh can use
    -Path a.ps1, b.ps1.

.PARAMETER Path
    One or more file paths. Alias: Paths. Further paths may follow.

.PARAMETER AllowOutsideRepo
    Rewrite a file that resolves outside this repository.

.PARAMETER StagedOnly
    Strip BOM from staged PowerShell files in the current git toplevel and
    re-stage rewritten paths. Ignores -Path. Exit 0 when nothing is staged.

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 scripts\kaden\harness\lib\HarnessContract.psm1 tests\harness\Some.Tests.ps1

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 -Path scripts\a.ps1 tests\harness\Some.Tests.ps1

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\strip-bom.ps1 -StagedOnly

.EXAMPLE
    Get-Help .\scripts\kaden\utils\strip-bom.ps1 -Parameter Path
#>


#Requires -Version 5.1
[CmdletBinding(DefaultParameterSetName = 'Files')]
param(
    [Parameter(ParameterSetName = 'Files', Position = 0)]
    [Alias('Paths')]
    [string[]]$Path = @(),

    [Parameter(ParameterSetName = 'Files', ValueFromRemainingArguments = $true, DontShow = $true)]
    [string[]]$ExtraPath,

    [Parameter(ParameterSetName = 'Files')]
    [switch]$AllowOutsideRepo,

    [Parameter(ParameterSetName = 'Staged')]
    [switch]$StagedOnly,

    [Parameter(ParameterSetName = 'Help')]
    [Alias('h', 'help')]
    [switch]$ShowHelp
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$cliKitManifest = Join-Path $PSScriptRoot "PsCliUiKit\PsCliUiKit.psd1"
if (-not (Test-Path -LiteralPath $cliKitManifest)) {
    throw "PsCliUiKit manifest not found: $cliKitManifest"
}
Import-Module -Name $cliKitManifest -Force

function Resolve-StripBomRepoRoot {
    $current = $PSScriptRoot
    while ($true) {
        $marker = Join-Path $current "scripts\kaden\harness-invoke.ps1"
        if (Test-Path -LiteralPath $marker) {
            return $current
        }
        $parent = Split-Path $current -Parent
        if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) {
            return $null
        }
        $current = $parent
    }
}

function Resolve-StripBomInputPath {
    param(
        [Parameter(Mandatory = $true)][string]$InputPath,
        [string]$RepoRoot = ''
    )

    if ([string]::IsNullOrWhiteSpace($InputPath)) {
        throw "Path is empty."
    }
    if (Test-Path -LiteralPath $InputPath) {
        $resolved = (Resolve-Path -LiteralPath $InputPath).Path
    } elseif (-not [string]::IsNullOrWhiteSpace($RepoRoot) -and -not [System.IO.Path]::IsPathRooted($InputPath)) {
        $candidate = Join-Path $RepoRoot $InputPath
        if (-not (Test-Path -LiteralPath $candidate)) {
            throw "File not found: $InputPath"
        }
        $resolved = (Resolve-Path -LiteralPath $candidate).Path
    } else {
        throw "File not found: $InputPath"
    }

    $item = Get-Item -LiteralPath $resolved
    if ($item.PSIsContainer) {
        throw "Not a file: $resolved"
    }
    return $resolved
}

function Test-StripBomPathInsideRepo {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][string]$RepoRoot
    )

    $rootFull = [System.IO.Path]::GetFullPath($RepoRoot)
    $separator = [System.IO.Path]::DirectorySeparatorChar
    if (-not $rootFull.EndsWith($separator)) {
        $rootFull += $separator
    }
    $full = [System.IO.Path]::GetFullPath($Path)
    return $full.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)
}

function Get-StripBomGitCommand {
    $gitCmd = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
    if (-not $gitCmd) {
        $gitCmd = Get-Command git.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
    }
    if (-not $gitCmd) {
        throw 'git not found on PATH (tried git, then git.exe)'
    }
    return $gitCmd.Source
}

function Get-StripBomStagedPowerShellFile {
    <#
    .SYNOPSIS
        Resolve staged PowerShell paths under a git toplevel (zero or more files).
    #>

    param([Parameter(Mandatory = $true)][string]$RepoRoot)

    $git = Get-StripBomGitCommand
    Push-Location $RepoRoot
    try {
        $staged = @(& $git diff --cached --name-only --diff-filter=ACMR -- '*.ps1' '*.psm1' '*.psd1')
        if ($LASTEXITCODE -ne 0) {
            throw "git diff --cached failed with exit $LASTEXITCODE"
        }
        return @(
            $staged |
                ForEach-Object { ($_ -replace '\\', '/').Trim() } |
                Where-Object { $_ } |
                ForEach-Object { Join-Path $RepoRoot ($_ -replace '/', [System.IO.Path]::DirectorySeparatorChar) } |
                Where-Object { Test-Path -LiteralPath $_ }
        )
    } finally {
        Pop-Location
    }
}

function ConvertTo-StripBomUtf8 {
    param([Parameter(Mandatory = $true)][string]$LiteralPath)

    $bytes = [System.IO.File]::ReadAllBytes($LiteralPath)
    $kind = "none"
    $text = $null
    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        $kind = "utf8-bom"
        $utf8Strict = New-Object System.Text.UTF8Encoding $false, $true
        $text = $utf8Strict.GetString($bytes, 3, $bytes.Length - 3)
    } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        if (($bytes.Length % 2) -ne 0) {
            throw "UTF-16 LE byte length is odd: $LiteralPath"
        }
        $kind = "utf16-le-bom"
        $utf16 = New-Object System.Text.UnicodeEncoding $false, $true, $true
        $text = $utf16.GetString($bytes, 2, $bytes.Length - 2)
    } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
        if (($bytes.Length % 2) -ne 0) {
            throw "UTF-16 BE byte length is odd: $LiteralPath"
        }
        $kind = "utf16-be-bom"
        $utf16be = New-Object System.Text.UnicodeEncoding $true, $true, $true
        $text = $utf16be.GetString($bytes, 2, $bytes.Length - 2)
    }

    if ($kind -eq "none") {
        return [pscustomobject]@{
            Path   = $LiteralPath
            Result = "unchanged"
            Detail = ""
        }
    }

    $utf8 = New-Object System.Text.UTF8Encoding $false
    $tmp = "$LiteralPath.nobom"
    try {
        [System.IO.File]::WriteAllText($tmp, $text, $utf8)
        Move-Item -LiteralPath $tmp -Destination $LiteralPath -Force
    } finally {
        if (Test-Path -LiteralPath $tmp) {
            Remove-Item -LiteralPath $tmp -Force
        }
    }

    $after = [System.IO.File]::ReadAllBytes($LiteralPath)
    $stillBom = ($after.Length -ge 3 -and $after[0] -eq 0xEF -and $after[1] -eq 0xBB -and $after[2] -eq 0xBF)
    if ($stillBom) {
        throw "Rewrite left a UTF-8 BOM on $LiteralPath"
    }
    $detail = ""
    $result = "removed"
    if ($kind -eq "utf16-le-bom") {
        $result = "converted"
        $detail = "utf-16 le"
    } elseif ($kind -eq "utf16-be-bom") {
        $result = "converted"
        $detail = "utf-16 be"
    }
    return [pscustomobject]@{
        Path   = $LiteralPath
        Result = $result
        Detail = $detail
    }
}

try {
    if ($ShowHelp) {
        (Get-Help -Name $PSCommandPath -Full | Out-String -Width 100) | Out-Host
        return
    }

    $resolvedPaths = New-Object System.Collections.Generic.List[string]
    $repoRoot = ''
    $restageRewritten = $false

    if ($StagedOnly) {
        $git = Get-StripBomGitCommand
        $repoRoot = (& $git rev-parse --show-toplevel 2>$null)
        if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) {
            throw 'git rev-parse --show-toplevel failed. Run -StagedOnly from inside a git work tree.'
        }
        $repoRoot = $repoRoot.Trim()
        foreach ($full in @(Get-StripBomStagedPowerShellFile -RepoRoot $repoRoot)) {
            [void]$resolvedPaths.Add($full)
        }
        $restageRewritten = $true
        if ($resolvedPaths.Count -eq 0) {
            Write-CliSection -Title "Strip BOM"
            Write-CliStatus -State "Info" -Message "No staged .ps1/.psm1/.psd1 files; skipping."
            return
        }
    } else {
        $requested = New-Object System.Collections.Generic.List[string]
        foreach ($entry in @($Path)) {
            if (-not [string]::IsNullOrWhiteSpace($entry)) {
                [void]$requested.Add($entry)
            }
        }

        if ($null -ne $ExtraPath) {
            foreach ($entry in @($ExtraPath)) {
                if (-not [string]::IsNullOrWhiteSpace($entry)) {
                    [void]$requested.Add($entry)
                }
            }
        }

        if ($requested.Count -eq 0) {
            throw "No files given. Get-Help `"$PSCommandPath`" -Parameter Path"
        }

        $repoRoot = Resolve-StripBomRepoRoot
        foreach ($inputPath in $requested) {
            $full = Resolve-StripBomInputPath -InputPath $inputPath -RepoRoot $repoRoot
            [void]$resolvedPaths.Add($full)
        }
        if (-not $AllowOutsideRepo) {
            if ([string]::IsNullOrWhiteSpace($repoRoot)) {
                throw "Could not locate the repository root. Get-Help `"$PSCommandPath`" -Parameter AllowOutsideRepo"
            }
            foreach ($full in $resolvedPaths) {
                if (-not (Test-StripBomPathInsideRepo -Path $full -RepoRoot $repoRoot)) {
                    throw "Refusing to rewrite a file outside the repository: $full. Get-Help `"$PSCommandPath`" -Parameter AllowOutsideRepo"
                }
            }
        }
    }

    Write-CliSection -Title "Strip BOM"

    $rewritten = 0
    $unchanged = 0
    $rewrittenPaths = New-Object System.Collections.Generic.List[string]
    foreach ($full in $resolvedPaths) {
        $result = ConvertTo-StripBomUtf8 -LiteralPath $full
        if ($result.Result -eq "unchanged") {
            $unchanged++
            Write-CliKeyValue -Label "unchanged" -LabelWidth 10 -Value "[muted]$($result.Path)[-]"
        } elseif ($result.Result -eq "converted") {
            $rewritten++
            [void]$rewrittenPaths.Add($full)
            Write-CliKeyValue -Label "converted" -LabelWidth 10 -Value "[success]$($result.Path)[-] [muted]($($result.Detail))[-]"
        } else {
            $rewritten++
            [void]$rewrittenPaths.Add($full)
            Write-CliKeyValue -Label "removed" -LabelWidth 10 -Value "[success]$($result.Path)[-]"
        }
    }

    if ($restageRewritten -and $rewrittenPaths.Count -gt 0) {
        $git = Get-StripBomGitCommand
        Push-Location $repoRoot
        try {
            & $git add -- @($rewrittenPaths)
            if ($LASTEXITCODE -ne 0) {
                throw "git add failed with exit $LASTEXITCODE after BOM rewrite"
            }
            Write-CliKeyValue -Label "restaged" -LabelWidth 10 -Value ("[success]{0} file(s)[-]" -f $rewrittenPaths.Count)
        } finally {
            Pop-Location
        }
    }

    $summaryState = if ($rewritten -gt 0) { "Success" } else { "Info" }
    Write-CliStatus -State $summaryState -Message "$rewritten rewritten, $unchanged unchanged"
} catch {
    Write-CliSection -Title "Strip BOM failed" -Style "error"
    Write-CliStatus -State "Error" -Message $_.Exception.Message
    exit 2
} finally {
    Write-CliEnd
}