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 } |