Payload/scripts/kaden/harness/lint-powershell.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
        Run PSScriptAnalyzer on authored PowerShell paths in this repository.

.DESCRIPTION
        Analyze (default): Error and Warning severity using PSScriptAnalyzerSettings.psd1.
        Pre-commit uses -StagedOnly (Error and Warning, including indentation).

        Format: run PSScriptAnalyzer -Fix, then Invoke-Formatter for full layout/indentation formatting.
        Some naming rules (for example, PSUseSingularNouns) are not auto-fixable.

        Scans only directories listed in PowerShellLintPaths.psd1 (default: scripts,
        hooks, .githooks, certs, .devcontainer). Skips node_modules, .venv, wip, ideas, and similar.

.PARAMETER Mode
        Analyze or Format.

.PARAMETER StagedOnly
        Restrict to staged .ps1 files under lint roots. Pre-commit uses this (Error and Warning).

.PARAMETER ErrorsOnly
        Report and fail on Error severity only. Not the pre-commit default.

.PARAMETER ReportWarnings
        Deprecated. Warnings are included by default; this switch is ignored.

.PARAMETER EnableExit
        Deprecated. Pre-commit compatibility no-op; exit behavior is always explicit.

.PARAMETER RepoRoot
        Repository root (defaults to parent of scripts/).

.EXAMPLE
        .\scripts\kaden\harness\lint-powershell.ps1

.EXAMPLE
        .\scripts\kaden\harness\lint-powershell.ps1 -StagedOnly

.EXAMPLE
        .\scripts\kaden\harness\lint-powershell.ps1 -Mode Format
#>

[CmdletBinding()]
param(
    [ValidateSet('Analyze', 'Format')]
    [string]$Mode = 'Analyze',

    [switch]$StagedOnly,

    [switch]$ErrorsOnly,

    [switch]$ReportWarnings,

    [switch]$EnableExit,

    [string]$RepoRoot = ''
)

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

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

    return $Path -like '\\*'
}

function Get-LocalPSScriptAnalyzerManifest {
    param(
        [Parameter(Mandatory = $true)]
        [object[]]$Available
    )

    $localModule = @($Available | Where-Object { -not (Test-IsUncPath -Path $_.ModuleBase) } | Select-Object -First 1)
    if ($localModule.Count -eq 0) {
        return $null
    }
    $manifestPath = Join-Path $localModule[0].ModuleBase 'PSScriptAnalyzer.psd1'
    if (Test-Path -LiteralPath $manifestPath) {
        return $manifestPath
    }
    return $null
}

function Get-CachedUncPSScriptAnalyzerManifest {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Selected
    )

    if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
        $manifestPath = Join-Path $Selected.ModuleBase 'PSScriptAnalyzer.psd1'
        if (Test-Path -LiteralPath $manifestPath) {
            return $manifestPath
        }
        throw 'PSScriptAnalyzer is only available from a network location and LOCALAPPDATA is empty; install it locally.'
    }

    $cacheRoot = Join-Path $env:LOCALAPPDATA 'provenir-linter'
    $cacheRoot = Join-Path $cacheRoot 'psmodules'
    $cacheRoot = Join-Path $cacheRoot 'PSScriptAnalyzer'
    $cacheVersionDir = Join-Path $cacheRoot $Selected.Version.ToString()
    $cacheManifest = Join-Path $cacheVersionDir 'PSScriptAnalyzer.psd1'

    if (-not (Test-Path -LiteralPath $cacheManifest)) {
        New-Item -Path $cacheVersionDir -ItemType Directory -Force | Out-Null
        Copy-Item -Path (Join-Path $Selected.ModuleBase '*') -Destination $cacheVersionDir -Recurse -Force
    }

    if (-not (Test-Path -LiteralPath $cacheManifest)) {
        throw "Failed to cache PSScriptAnalyzer manifest to $cacheManifest"
    }

    Write-Verbose "[lint-powershell] Using cached local PSScriptAnalyzer from $cacheVersionDir"
    return $cacheManifest
}

function Resolve-PSScriptAnalyzerImportPath {
    $available = @(
        Get-Module -ListAvailable -Name PSScriptAnalyzer | Sort-Object -Property Version -Descending
    )

    if ($available.Count -eq 0) {
        throw @(
            'PSScriptAnalyzer is not installed.'
            'Install-Module PSScriptAnalyzer -Scope CurrentUser -Force'
        ) -join ' '
    }

    $localManifest = Get-LocalPSScriptAnalyzerManifest -Available $available
    if ($localManifest) {
        return $localManifest
    }

    $selected = $available[0]
    if (-not (Test-IsUncPath -Path $selected.ModuleBase)) {
        $manifestPath = Join-Path $selected.ModuleBase 'PSScriptAnalyzer.psd1'
        if (Test-Path -LiteralPath $manifestPath) {
            return $manifestPath
        }
        throw "PSScriptAnalyzer manifest not found at $manifestPath"
    }

    return Get-CachedUncPSScriptAnalyzerManifest -Selected $selected
}

$psScriptAnalyzerImportPath = Resolve-PSScriptAnalyzerImportPath
Import-Module -Name $psScriptAnalyzerImportPath -ErrorAction Stop

if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    $scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
    # Historical layout lived at scripts/harness (two levels under the repo).
    # scripts/kaden/harness is one level deeper, so keep walking until the
    # lint-path marker is found. A short walk that misses the marker used to
    # scan zero files and exit 0.
    $markerName = 'PowerShellLintPaths.psd1'
    $candidate = (Resolve-Path (Join-Path $scriptDir (Join-Path '..' '..'))).Path
    if (-not (Test-Path -LiteralPath (Join-Path $candidate $markerName))) {
        $walk = $scriptDir
        for ($depth = 0; $depth -lt 8; $depth++) {
            $parent = Split-Path -Parent $walk
            if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $walk) { break }
            $walk = $parent
            if (Test-Path -LiteralPath (Join-Path $walk $markerName)) {
                $candidate = (Resolve-Path -LiteralPath $walk).Path
                break
            }
        }
    }
    if (-not (Test-Path -LiteralPath (Join-Path $candidate $markerName))) {
        throw "lint-powershell could not find $markerName above $scriptDir. Pass -RepoRoot."
    }
    $RepoRoot = $candidate
}

$lintRoots = @('scripts', 'hooks', '.githooks', 'certs', '.devcontainer')
$excludeDirNames = @('node_modules', '.venv', 'wip', 'ideas', 'output', '__pycache__', '.pytest_cache')

$pathsFile = Join-Path $RepoRoot 'PowerShellLintPaths.psd1'
if (Test-Path $pathsFile) {
    $pathsData = Import-LocalizedData -BaseDirectory $RepoRoot -FileName 'PowerShellLintPaths.psd1'
    if ($pathsData.LintRoots) {
        $lintRoots = @($pathsData.LintRoots)
    }
    if ($pathsData.ExcludeDirectoryNames) {
        $excludeDirNames = @($pathsData.ExcludeDirectoryNames)
    }
}

function Test-ExcludedPath {
    param(
        [Parameter(Mandatory = $true)][string]$RelativePath,
        [Parameter(Mandatory = $true)][string[]]$ExcludedNames
    )

    $parts = ($RelativePath -replace '\\', '/') -split '/'
    foreach ($part in $parts) {
        if ($ExcludedNames -contains $part) {
            return $true
        }
    }
    return $false
}

function Get-ScopedPs1File {
    param([string[]]$Roots)

    $results = [System.Collections.Generic.List[string]]::new()
    foreach ($root in $Roots) {
        $fullRoot = Join-Path $RepoRoot $root
        if (-not (Test-Path $fullRoot)) {
            continue
        }

        Get-ChildItem -Path $fullRoot -Include '*.ps1','*.psm1' -Recurse -File -Force | ForEach-Object {
            $relative = $_.FullName.Substring($RepoRoot.Length).TrimStart('\', '/')
            if (Test-ExcludedPath -RelativePath $relative -ExcludedNames $excludeDirNames) {
                return
            }
            $results.Add($_.FullName) | Out-Null
        }
    }

    return @($results | Select-Object -Unique)
}

function Get-StagedScopedPs1 {
    param([string[]]$ScopedFiles)

    $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)'
    }
    $git = $gitCmd.Source
    Push-Location $RepoRoot
    try {
        $staged = @(& $git diff --cached --name-only --diff-filter=ACMR -- '*.ps1' '*.psm1')
        if ($LASTEXITCODE -ne 0) {
            return @()
        }

        $scopedRel = @(
            $ScopedFiles | ForEach-Object {
                $_.Substring($RepoRoot.Length).TrimStart('\', '/').Replace('\', '/')
            }
        )

        return @(
            $staged |
                ForEach-Object { ($_ -replace '\\', '/').Trim() } |
                Where-Object { $_ -and ($scopedRel -contains $_) }
        )
    }
    finally {
        Pop-Location
    }
}

$scopedFiles = @(Get-ScopedPs1File -Roots $lintRoots)
Write-Output "[lint-powershell] Lint roots: $($lintRoots -join ', ') ($($scopedFiles.Count) file(s) in scope)"
foreach ($file in $scopedFiles) {
    $relative = $file.Substring($RepoRoot.Length).TrimStart('\', '/')
    Write-Output " - $relative"
}
if ($scopedFiles.Count -eq 0) {
    Write-Output '[lint-powershell] No .ps1 files under lint roots; nothing to do.'
    exit 0
}

if ($StagedOnly) {
    $stagedRel = @(Get-StagedScopedPs1 -ScopedFiles $scopedFiles)
    if ($stagedRel.Count -eq 0) {
        Write-Output '[lint-powershell] No staged .ps1/.psm1 files in lint scope; skipping.'
        exit 0
    }
    $targetPaths = @($stagedRel | ForEach-Object { Join-Path $RepoRoot $_ })
}
elseif ($Mode -eq 'Format') {
    $targetPaths = @(
        foreach ($root in $lintRoots) {
            $full = Join-Path $RepoRoot $root
            if (Test-Path $full) { $full }
        }
    )
}
else {
    $targetPaths = @($scopedFiles)
}

if ($Mode -eq 'Format') {
    $roots = @($targetPaths | Where-Object { $_ })
    if ($roots.Count -eq 0) {
        Write-Output '[lint-powershell] No lint roots exist on disk; nothing to format.'
        exit 0
    }

    Write-Output "[lint-powershell] Format (-Fix + Invoke-Formatter via PSScriptAnalyzerSettings.psd1) on $($roots.Count) root(s)..."
    $settingsFile = Join-Path $RepoRoot 'PSScriptAnalyzerSettings.psd1'
    $formatterCommand = Get-Command -Name Invoke-Formatter -ErrorAction SilentlyContinue
    $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
    if (-not $formatterCommand) {
        Write-Warning '[lint-powershell] Invoke-Formatter command not found; applying ScriptAnalyzer -Fix only.'
    }
    foreach ($rootPath in $roots) {
        Write-Output " $rootPath"
        Get-ChildItem -Path $rootPath -Include '*.ps1','*.psm1' -Recurse -File -Force | ForEach-Object {
            $relative = $_.FullName.Substring($RepoRoot.Length).TrimStart('\', '/')
            if (Test-ExcludedPath -RelativePath $relative -ExcludedNames $excludeDirNames) {
                return
            }
            $null = Invoke-ScriptAnalyzer -Path $_.FullName -Settings $settingsFile -Fix
            if ($formatterCommand) {
                $originalScript = [System.IO.File]::ReadAllText($_.FullName)
                $formattedScript = Invoke-Formatter -ScriptDefinition $originalScript -Settings $settingsFile
                if (($null -ne $formattedScript) -and ($formattedScript -ne $originalScript)) {
                    [System.IO.File]::WriteAllText($_.FullName, $formattedScript, $utf8NoBom)
                }
            }
        }
    }
    Write-Output '[lint-powershell] Format complete.'
    exit 0
}

$settingsFile = Join-Path $RepoRoot 'PSScriptAnalyzerSettings.psd1'
$settings = if (Test-Path $settingsFile) { $settingsFile } else { @{} }

$severityFilter = if ($ErrorsOnly) { @('Error') } else { @('Error', 'Warning') }
$label = if ($ErrorsOnly) { 'Error' } else { 'Error+Warning' }
if ($ReportWarnings.IsPresent) {
    Write-Warning '[lint-powershell] -ReportWarnings is deprecated; warnings are included by default. Use -ErrorsOnly for Error-only checks.'
}
if ($EnableExit.IsPresent) {
    Write-Verbose '[lint-powershell] -EnableExit is deprecated; exit codes are always explicit.'
}
Write-Output "[lint-powershell] Analyze ($label) on $($targetPaths.Count) file(s)..."

# PS 5.1 parses UTF-8-no-BOM as system ANSI. Any codepoint > 127 (em/en dash,
# curly quotes, arrows, box-drawing, NBSP) can become a string terminator and
# cascade into fake parse/indent errors in the IDE and under powershell.exe.
# Also keep PSScriptAnalyzerSettings.psd1 ASCII: if Editor Services fails to load
# Kind=tab, it analyzes with spaces and floods every tabbed line.
$unicodeHits = [System.Collections.Generic.List[string]]::new()
$encodingTargets = @($targetPaths)
$settingsForEncoding = Join-Path $RepoRoot 'PSScriptAnalyzerSettings.psd1'
if ((Test-Path -LiteralPath $settingsForEncoding) -and ($encodingTargets -notcontains $settingsForEncoding)) {
    $encodingTargets += $settingsForEncoding
}
foreach ($path in @($encodingTargets)) {
    if (-not (Test-Path -LiteralPath $path)) { continue }
    $rel = $path.Substring($RepoRoot.Length).TrimStart('\', '/')
    $raw = [System.IO.File]::ReadAllBytes($path)
    if ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF) {
        [void]$unicodeHits.Add(("{0}: UTF-8 BOM present (use UTF-8 without BOM)" -f $rel))
    }
    $lineNo = 0
    Get-Content -LiteralPath $path -Encoding UTF8 | ForEach-Object {
        $lineNo++
        foreach ($ch in $_.ToCharArray()) {
            $code = [int][char]$ch
            if ($code -gt 127) {
                [void]$unicodeHits.Add(("{0}:{1} contains U+{2:X4} (use ASCII - / -- / -> / ' / `" / ...)" -f $rel, $lineNo, $code))
                break
            }
        }
    }
}
if ($unicodeHits.Count -gt 0) {
    Write-Output '[lint-powershell] Non-ASCII bytes in .ps1/.psm1/.psd1 break Windows PowerShell 5.1 / PSES analysis:'
    $unicodeHits | ForEach-Object { Write-Output (" {0}" -f $_) }
    exit 1
}

$allFindings = [System.Collections.Generic.List[object]]::new()
foreach ($path in @($targetPaths)) {
    $findings = @(
        Invoke-ScriptAnalyzer -Path $path -Settings $settings -Recurse:$false |
            Where-Object { $_.Severity -in $severityFilter }
    )
    foreach ($finding in $findings) {
        $allFindings.Add($finding) | Out-Null
    }
}

if ($allFindings.Count -gt 0) {
    $allFindings | Format-Table -AutoSize ScriptName, Line, RuleName, Severity, Message
    exit 1
}

Write-Output "[lint-powershell] No $label findings."
exit 0