Payload/scripts/kaden/utils/lint-target.ps1

<#
.SYNOPSIS
    Run targeted lint checks for PowerShell and JSON paths.

.DESCRIPTION
    Accepts one or more files and/or folders. Folder targets are expanded to files.
    When -Type Auto, this script lints PowerShell and JSON files it finds.

    If no target is provided and an interactive host is available, PromptForChoice
    can guide the input shape (file list or folder).

    PowerShell lint uses PSScriptAnalyzerSettings.psd1 and also checks UTF-8 BOM.
    JSON lint uses the repository ESLint config through npx eslint --no-ignore.

.PARAMETER Target
    File and/or folder paths. Relative paths resolve from current directory first,
    then repository root.

.PARAMETER Type
    Auto, PowerShell, or Json.

.PARAMETER Recurse
    When a Target is a folder, include nested files.

.PARAMETER PromptIfMissing
    When no Target is provided, prompt for target shape and path values.

.PARAMETER Fix
    Apply tool-supported auto-fixes before reporting findings.

.PARAMETER AllowOutsideRepo
    Allow -Fix to rewrite a file that resolves outside this repository.

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\lint-target.ps1 -Target scripts\kaden\harness\lint-powershell.ps1

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\lint-target.ps1 -Target scripts -Type Json -Recurse

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\lint-target.ps1 -Target scripts,tests\harness -Type PowerShell -Recurse

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\lint-target.ps1 -Target scripts\kaden\utils -Type Auto -Recurse -Fix

.EXAMPLE
    Get-Help .\scripts\kaden\utils\lint-target.ps1 -Parameter Type
#>


#Requires -Version 5.1
[CmdletBinding(DefaultParameterSetName = "Run")]
param(
    [Parameter(ParameterSetName = "Run", Position = 0)]
    [string[]]$Target = @(),

    [Parameter(ParameterSetName = "Run")]
    [string]$Type = "Auto",

    [Parameter(ParameterSetName = "Run")]
    [switch]$Recurse,

    [Parameter(ParameterSetName = "Run")]
    [switch]$PromptIfMissing,

    [Parameter(ParameterSetName = "Run")]
    [switch]$Fix,

    [Parameter(ParameterSetName = "Run")]
    [switch]$AllowOutsideRepo,

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

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

function Resolve-LintTargetRepoRoot {
    $current = $PSScriptRoot
    while ($true) {
        $marker = Join-Path $current "package.json"
        if (Test-Path -LiteralPath $marker) {
            return $current
        }
        $parent = Split-Path $current -Parent
        if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) {
            throw "Could not locate repository root (package.json) from this script."
        }
        $current = $parent
    }
}

function Resolve-LintTargetPath {
    param(
        [Parameter(Mandatory = $true)][string]$InputPath,
        [Parameter(Mandatory = $true)][string]$RepoRoot
    )
    if ([string]::IsNullOrWhiteSpace($InputPath)) {
        throw "Target path is empty."
    }
    if (Test-Path -LiteralPath $InputPath) {
        return (Resolve-Path -LiteralPath $InputPath).Path
    }
    if (-not [System.IO.Path]::IsPathRooted($InputPath)) {
        $candidate = Join-Path $RepoRoot $InputPath
        if (Test-Path -LiteralPath $candidate) {
            return (Resolve-Path -LiteralPath $candidate).Path
        }
    }
    throw "Target not found: $InputPath"
}

function Expand-LintTargetFileSet {
    param(
        [Parameter(Mandatory = $true)][string[]]$InputTargets,
        [Parameter(Mandatory = $true)][string]$RepoRoot,
        [switch]$Recurse
    )

    $files = New-Object System.Collections.Generic.List[string]
    foreach ($targetPath in $InputTargets) {
        $resolved = Resolve-LintTargetPath -InputPath $targetPath -RepoRoot $RepoRoot
        $item = Get-Item -LiteralPath $resolved
        if (-not $item.PSIsContainer) {
            [void]$files.Add($resolved)
            continue
        }
        $children = if ($Recurse) {
            Get-ChildItem -LiteralPath $resolved -File -Recurse
        } else {
            Get-ChildItem -LiteralPath $resolved -File
        }
        foreach ($child in $children) {
            [void]$files.Add($child.FullName)
        }
    }
    return @($files | Sort-Object -Unique)
}

function Test-LintTargetPathInsideRepo {
    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 Read-LintTargetInteractiveInput {
    $caption = "Lint target input"
    $message = "No -Target supplied. Select input shape:"
    $choices = [System.Collections.ObjectModel.Collection[System.Management.Automation.Host.ChoiceDescription]]::new()
    [void]$choices.Add((New-Object System.Management.Automation.Host.ChoiceDescription "&File list", "One or more file paths"))
    [void]$choices.Add((New-Object System.Management.Automation.Host.ChoiceDescription "&Folder", "One folder path"))
    [void]$choices.Add((New-Object System.Management.Automation.Host.ChoiceDescription "&Cancel", "Stop without running lint"))

    $selection = $host.UI.PromptForChoice($caption, $message, $choices, 0)
    if ($selection -eq 2) {
        throw "Lint canceled by operator."
    }
    if ($selection -eq 0) {
        $raw = Read-Host "Enter one or more file paths (comma-separated)"
        $parts = @($raw -split "," | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        if ($parts.Count -eq 0) {
            throw "No file paths provided."
        }
        return $parts
    }
    $folder = Read-Host "Enter folder path"
    if ([string]::IsNullOrWhiteSpace($folder)) {
        throw "No folder path provided."
    }
    return @($folder.Trim())
}

function Test-LintTargetIsPowerShellFile {
    param([Parameter(Mandatory = $true)][string]$Path)
    $ext = [System.IO.Path]::GetExtension($Path)
    return $ext -in @(".ps1", ".psm1", ".psd1")
}

function Test-LintTargetIsJsonFile {
    param([Parameter(Mandatory = $true)][string]$Path)
    return ([System.IO.Path]::GetExtension($Path) -eq ".json")
}

function Invoke-LintTargetPowerShell {
    param(
        [Parameter(Mandatory = $true)]$Files,
        [Parameter(Mandatory = $true)][string]$RepoRoot,
        [switch]$Fix
    )
    $Files = @($Files | ForEach-Object { [string]$_ })

    Write-CliSection -Title "PowerShell lint" -Style info
    $settings = Join-Path $RepoRoot "PSScriptAnalyzerSettings.psd1"
    if (-not (Test-Path -LiteralPath $settings)) {
        throw "PSScriptAnalyzer settings not found: $settings"
    }
    $settingsData = Import-PowerShellDataFile -LiteralPath $settings

    if ($Fix) {
        Write-CliStatus -State "Info" -Message "Applying PowerShell auto-fix where supported."
        foreach ($path in $Files) {
            $null = Invoke-ScriptAnalyzer -Path $path -Settings $settingsData -Fix
        }
    }

    $allFindings = New-Object System.Collections.Generic.List[object]
    foreach ($path in $Files) {
        $fileFindings = @(Invoke-ScriptAnalyzer -Path $path -Settings $settingsData)
        foreach ($finding in $fileFindings) {
            if ($finding.Severity -in @("Error", "Warning")) {
                [void]$allFindings.Add($finding)
            }
        }
    }
    $findings = @($allFindings.ToArray())

    $bomIssues = New-Object System.Collections.Generic.List[string]
    foreach ($path in $Files) {
        $bytes = [System.IO.File]::ReadAllBytes($path)
        $hasUtf8Bom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
        if ($hasUtf8Bom -and $Fix) {
            $utf8 = New-Object System.Text.UTF8Encoding $false
            $text = $utf8.GetString($bytes, 3, $bytes.Length - 3)
            [System.IO.File]::WriteAllText($path, $text, $utf8)
            $bytes = [System.IO.File]::ReadAllBytes($path)
            $hasUtf8Bom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
        }
        if ($hasUtf8Bom) {
            [void]$bomIssues.Add("$path : UTF-8 BOM present")
        }
    }

    Write-CliKeyValue -Label "Files" -Value ([string]$Files.Count)
    Write-CliKeyValue -Label "Findings" -Value ([string]$findings.Count)
    Write-CliKeyValue -Label "BOM issues" -Value ([string]$bomIssues.Count)

    if ($findings.Count -eq 0 -and $bomIssues.Count -eq 0) {
        Write-CliStatus -State "Success" -Message "PowerShell lint passed."
        return $true
    }

    Write-CliStatus -State "Error" -Message "PowerShell lint found issues."
    foreach ($finding in $findings) {
        $text = ("{0}:{1} {2} {3}" -f $finding.ScriptName, $finding.Line, $finding.RuleName, $finding.Message)
        Write-CliList -Item $text -Bullet "!"
    }
    foreach ($issue in $bomIssues) {
        Write-CliList -Item $issue -Bullet "!"
    }
    return $false
}

function Invoke-LintTargetJson {
    param(
        [Parameter(Mandatory = $true)]$Files,
        [Parameter(Mandatory = $true)][string]$RepoRoot,
        [switch]$Fix
    )
    $Files = @($Files | ForEach-Object { [string]$_ })

    Write-CliSection -Title "JSON lint" -Style info
    Write-CliKeyValue -Label "Files" -Value ([string]$Files.Count)

    $eslintConfig = Join-Path $RepoRoot "eslint.config.mjs"
    if (-not (Test-Path -LiteralPath $eslintConfig)) {
        throw "ESLint config not found: $eslintConfig"
    }

    $eslintArgs = @(
        "eslint",
        "--no-ignore",
        "--config",
        $eslintConfig,
        "--"
    ) + $Files
    if ($Fix) {
        $eslintArgs = @(
            "eslint",
            "--no-ignore",
            "--fix",
            "--config",
            $eslintConfig,
            "--"
        ) + $Files
    }

    $output = & npx @eslintArgs 2>&1
    $exitCode = $LASTEXITCODE
    if ($output) {
        foreach ($line in @($output)) {
            Write-CliList -Item ([string]$line) -Bullet "|"
        }
    }

    if ($exitCode -eq 0) {
        Write-CliStatus -State "Success" -Message "JSON lint passed."
        return $true
    }

    Write-CliStatus -State "Error" -Message "JSON lint failed (exit=$exitCode)."
    return $false
}

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

$exitCode = 0
try {
    if ($ShowHelp) {
        (Get-Help -Name $PSCommandPath -Full | Out-String -Width 100) | Out-Host
    } else {
        $allowedTypes = @("Auto", "PowerShell", "Json")
        if ($allowedTypes -notcontains $Type) {
            throw "Unknown lint type '$Type'. Get-Help `"$PSCommandPath`" -Parameter Type"
        } else {
            $requestedTargets = @($Target | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
            if ($requestedTargets.Count -eq 0 -and $PromptIfMissing) {
                $requestedTargets = @(Read-LintTargetInteractiveInput)
            }
            if ($requestedTargets.Count -eq 0) {
                throw "No targets provided. Get-Help `"$PSCommandPath`" -Parameter Target"
            }

            $files = @(Expand-LintTargetFileSet -InputTargets $requestedTargets -RepoRoot $repoRoot -Recurse:$Recurse)
            if ($files.Count -eq 0) {
                throw "No files found under target input."
            }
            if ($Fix -and -not $AllowOutsideRepo) {
                foreach ($oneFile in $files) {
                    if (-not (Test-LintTargetPathInsideRepo -Path $oneFile -RepoRoot $repoRoot)) {
                        throw "Refusing to rewrite a file outside the repository: $oneFile. Get-Help `"$PSCommandPath`" -Parameter AllowOutsideRepo"
                    }
                }
            }

            Write-CliSection -Title "Lint target summary" -Style info
            Write-CliKeyValue -Label "Type" -Value $Type
            Write-CliKeyValue -Label "Targets" -Value ([string]$requestedTargets.Count)
            Write-CliKeyValue -Label "Files found" -Value ([string]$files.Count)
            Write-CliKeyValue -Label "Fix" -Value ($(if ($Fix) { "true" } else { "false" }))

            $psFiles = @($files | Where-Object { Test-LintTargetIsPowerShellFile -Path $_ })
            $jsonFiles = @($files | Where-Object { Test-LintTargetIsJsonFile -Path $_ })

            $runPowerShell = $Type -in @("Auto", "PowerShell")
            $runJson = $Type -in @("Auto", "Json")
            $psOk = $true
            $jsonOk = $true

            if ($runPowerShell) {
                if ($psFiles.Count -gt 0) {
                    $psOk = Invoke-LintTargetPowerShell -Files $psFiles -RepoRoot $repoRoot -Fix:$Fix
                } else {
                    Write-CliStatus -State "Info" -Message "No PowerShell files found for the selected target."
                }
            }
            if ($runJson) {
                if ($jsonFiles.Count -gt 0) {
                    $jsonOk = Invoke-LintTargetJson -Files $jsonFiles -RepoRoot $repoRoot -Fix:$Fix
                } else {
                    Write-CliStatus -State "Info" -Message "No JSON files found for the selected target."
                }
            }

            if ($psOk -and $jsonOk) {
                Write-CliSection -Title "Lint complete" -Style success
                Write-CliStatus -State "Success" -Message "All selected lint checks passed."
                $exitCode = 0
            } else {
                Write-CliSection -Title "Lint complete" -Style error
                Write-CliStatus -State "Error" -Message "One or more lint checks failed."
                $exitCode = 1
            }
        }
    }
} catch {
    Write-CliSection -Title "Lint failed" -Style "error"
    Write-CliStatus -State "Error" -Message $_.Exception.Message
    $exitCode = 2
} finally {
    Write-CliEnd
}

if ($exitCode -ne 0) {
    exit $exitCode
}