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 |