Lint PowerShell Scripts with PSScriptAnalyzer: Catch Issues Before Review
You can prevent many defects and review churn in PowerShell projects by linting early and often. PSScriptAnalyzer is the de facto linter for PowerShell that flags style problems, portability risks, and correctness issues before your code reaches a pull request. In this post you will set up fast local linting, tune rules for correctness and cross-platform portability, and wire the analyzer into pre-commit and CI so warnings stay visible while only critical problems fail the build.
Why lint PowerShell with PSScriptAnalyzer
- Fewer defects: Catch anti-patterns like Invoke-Expression, unused or undeclared variables, and global state leaks.
- Clearer reviews: Automated style and consistency checks free reviewers to focus on design and logic.
- Portability: Compatibility rules help you avoid Windows-only cmdlets or syntax in cross-platform code.
- Safer releases: Blocking errors early reduces late-cycle surprises and hotfixes.
Quick-start: run PSScriptAnalyzer locally
The following script installs PSScriptAnalyzer if needed, runs a targeted rule set, and fails only on errors (warnings remain visible in logs for later cleanup). Keep this in tools/run-pssa.ps1 and call it before committing.
$path = Join-Path -Path (Get-Location) -ChildPath 'src'
# Ensure the analyzer is available
if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -Repository PSGallery
}
$rules = @(
'PSAvoidUsingWriteHost',
'PSUseDeclaredVarsMoreThanAssignments',
'PSUseCompatibleCmdlets',
'PSAvoidGlobalVars'
)
$results = Invoke-ScriptAnalyzer -Path $path -Recurse -Severity Error, Warning -IncludeRule $rules
if ($results) {
$results | Sort-Object Severity, RuleName, ScriptName |
Select-Object Severity, RuleName, ScriptName, Line, Message | Format-Table -AutoSize
if ($results.Severity -contains 'Error') { exit 1 }
} else {
Write-Host 'No issues found.'
}
Tips:
- Keep your rule set small at first. Focus on correctness and portability rules (examples below) and add more once your team is comfortable.
- Run on your working directory or a
srcsubtree to avoid scanning vendored code. - Prefer table output for local runs and export a machine-readable artifact (CSV or JSON) in CI for easy triage.
Tune your rules for correctness and portability
PSScriptAnalyzer ships with dozens of rules. For most teams, you get the highest ROI by emphasizing correctness, safety, and cross-platform behavior.
Suggested rule buckets
- Critical (fail the build):
- PSAvoidUsingInvokeExpression
- PSUseDeclaredVarsMoreThanAssignments
- PSUseCompatibleCmdlets
- PSUseCompatibleSyntax
- Warnings (log, fix in batches):
- PSAvoidGlobalVars
- PSAvoidUsingWriteHost
- PSUseApprovedVerbs
- PSAvoidUsingCmdletAliases
One practical way to “escalate” critical issues is to run the analyzer twice—once for the critical rules and fail on any finding, and again for your warning rules without failing:
$path = Resolve-Path './src'
$criticalRules = @(
'PSAvoidUsingInvokeExpression',
'PSUseDeclaredVarsMoreThanAssignments',
'PSUseCompatibleCmdlets',
'PSUseCompatibleSyntax'
)
$warningRules = @(
'PSAvoidGlobalVars',
'PSAvoidUsingWriteHost',
'PSUseApprovedVerbs',
'PSAvoidUsingCmdletAliases'
)
$crit = Invoke-ScriptAnalyzer -Path $path -Recurse -IncludeRule $criticalRules
$warn = Invoke-ScriptAnalyzer -Path $path -Recurse -IncludeRule $warningRules -Severity Warning
if ($crit) {
$crit | Sort-Object RuleName | Select-Object Severity, RuleName, ScriptName, Line, Message | Format-Table -AutoSize
Write-Error "Critical analyzer findings detected."
exit 1
}
if ($warn) {
$warn | Sort-Object RuleName | Select-Object Severity, RuleName, ScriptName, Line, Message | Format-Table -AutoSize
}
Write-Host "PSScriptAnalyzer completed."
Use a shared settings file
Codify your decisions in a repository-scoped settings file so editors and CI run the same rules. Save this as .config/PSScriptAnalyzerSettings.psd1 and point to it with -Settings.
@{
# Keep defaults, then tune
IncludeDefaultRules = $true
# Prefer explicit include list so your intent is clear
IncludeRules = @(
'PSAvoidUsingInvokeExpression',
'PSUseDeclaredVarsMoreThanAssignments',
'PSUseCompatibleCmdlets',
'PSUseCompatibleSyntax',
'PSAvoidGlobalVars',
'PSUseApprovedVerbs',
'PSAvoidUsingCmdletAliases'
)
Rules = @{
PSUseCompatibleCmdlets = @{
Enable = $true
TargetProfiles = @(
'core-7.4-windows',
'core-7.4-linux',
'core-7.4-macos'
)
}
PSUseCompatibleSyntax = @{
Enable = $true
TargetVersions = @('7.4')
}
PSUseApprovedVerbs = @{
Enable = $true
# Add exceptions only when necessary
AllowUnapprovedVerb = @()
}
}
}
Run with settings:
Invoke-ScriptAnalyzer -Path ./src -Settings ./.config/PSScriptAnalyzerSettings.psd1 -Recurse
Document suppressions with justifications
Sometimes a rule is correct in general but wrong in a specific context (for example, using Write-Host in an interactive demo). Suppress the rule locally and include a justification to preserve readability and intent.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', 'Interactive output', Justification='Console-only demo script')]
param()
Write-Host 'Showing progress...'
Best practices for suppressions:
- Prefer narrow scope: apply the attribute to a function or file rather than globally.
- Always include a clear, actionable justification.
- Track suppressions in code review; stale suppressions should be removed.
Automation: pre-commit and CI/CD
Pre-commit hook
Block commits that introduce critical issues while still printing warnings. On cross-platform repos, use a small wrapper that CI also calls.
# .git/hooks/pre-commit (make executable)
#!/usr/bin/env pwsh
$ErrorActionPreference = 'Stop'
# Analyze only changed PowerShell files for speed
$changed = git diff --cached --name-only | Where-Object { $_ -match '\.(ps1|psm1|psd1)$' }
if (-not $changed) { exit 0 }
if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -Repository PSGallery
}
$criticalRules = 'PSAvoidUsingInvokeExpression','PSUseDeclaredVarsMoreThanAssignments','PSUseCompatibleCmdlets','PSUseCompatibleSyntax'
$crit = Invoke-ScriptAnalyzer -Path $changed -IncludeRule $criticalRules
if ($crit) {
$crit | Select-Object Severity, RuleName, ScriptName, Line, Message | Format-Table -AutoSize
Write-Error 'Commit blocked by critical analyzer findings.'
exit 1
}
Write-Host 'PSScriptAnalyzer: no critical issues.'
exit 0
GitHub Actions workflow
Run the same script in CI and upload a CSV artifact so the team can triage warnings.
name: lint-powershell
on: [push, pull_request]
jobs:
pssa:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/powershell:7.4-alpine
steps:
- uses: actions/checkout@v4
- name: Install PSScriptAnalyzer
run: pwsh -NoProfile -Command "Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -Repository PSGallery"
- name: Run analyzer
run: pwsh -NoProfile -File tools/run-pssa.ps1
- name: Export warnings
run: |
pwsh -NoProfile -Command "`$r = Invoke-ScriptAnalyzer -Path ./src -Recurse -Severity Warning; `$r | Select Severity,RuleName,ScriptName,Line,Message | Export-Csv -Path pssa-warnings.csv -NoTypeInformation"
- uses: actions/upload-artifact@v4
with:
name: pssa-warnings
path: pssa-warnings.csv
Practical tips and patterns
Make fixes fast
- Auto-format: Use
Invoke-Formatter(from PSScriptAnalyzer) to normalize style before linting. - Batch warnings: Fix warnings periodically. Keep them visible in CI logs so they do not regress.
- Short feedback loops: Analyze only changed files locally and the full tree in CI.
# Format all scripts in place
Get-ChildItem -Path ./src -Recurse -Include *.ps1, *.psm1 | ForEach-Object {
$code = Get-Content $_ -Raw
$formatted = Invoke-Formatter -ScriptDefinition $code
if ($formatted -ne $code) { Set-Content -Path $_ -Value $formatted }
}
Rules that pay off
- PSAvoidUsingInvokeExpression: Reduces code injection risk and improves readability.
- PSUseDeclaredVarsMoreThanAssignments: Catches typos and shadowed variables.
- PSUseCompatibleCmdlets / PSUseCompatibleSyntax: Keeps modules working on Windows, Linux, and macOS.
- PSAvoidGlobalVars: Prevents hidden state coupling between modules and tests.
- PSUseApprovedVerbs: Makes functions predictable and discoverable with
Get-Command.
Performance and reliability
- Scope the path: Avoid scanning
bin,obj, or vendored modules. Use.psd1settings or-ExcludeRuleand path filters. - Pin versions: Pin PSScriptAnalyzer in CI for reproducibility across agents.
- Containerize: Use a Powershell container in CI so your results are consistent across runners.
Putting it all together
- Install and run PSScriptAnalyzer locally with a focused rule set.
- Codify rules and portability targets in a shared
PSScriptAnalyzerSettings.psd1. - Split critical vs. warning rules; fail only on critical findings.
- Automate with a pre-commit hook and a CI job that uploads warning reports.
- Document any suppressions with clear justifications and revisit them regularly.
Adopting this workflow yields fewer defects, clearer reviews, consistent style, and safer releases—without slowing you down.
Further reading
- PSScriptAnalyzer documentation: https://github.com/PowerShell/PSScriptAnalyzer
- PowerShell Gallery (module): https://www.powershellgallery.com/packages/PSScriptAnalyzer
- PowerShell Advanced Cookbook: Raise your PowerShell quality. Explore the book → Amazon
#PowerShell #PSScriptAnalyzer #CodeQuality #Scripting #BestPractices #PowerShellCookbook #Automation