PSScriptAnalyzer version 1.18 was released recently, and ships with powerful new rules that can check PowerShell scripts for incompatibilities with other PowerShell versions and environments.
In this blog post, the first in a series, we’ll see how to use these new rules to check a script for problems running on PowerShell 3, 5.1 and 6.
Wait, what’s PSScriptAnalyzer?
PSScriptAnalzyer is a module providing static analysis, or linting, and some dynamic analysis (based on the state of your environment) for PowerShell. It’s able to find problems and fix bad habits in PowerShell scripts as you create them, similar to the way the C# compiler will give you warnings and find errors in C# code before it’s executed.
If you use the VSCode PowerShell extension, you might have seen the “green squigglies” and problem reports that PSScriptAnalyzer generates for scripts you author:
You can install PSScriptAnalyzer to use on your own scripts with:
Install-Module PSScriptAnalyzer -Scope CurrentUser
PSScriptAnalyzer works by running a series of rules on your scripts, each of which independently
assesses some issue. For example AvoidUsingCmdletAliases
checks that aliases aren’t used in
scripts, and MisleadingBackticks
checks that backticks at the ends of lines aren’t followed by
whitespace.
For more information, see the PSScriptAnalyzer deep dive blog series.
Introducing the compatibility check rules
The new compatibility checking functionality is provided by three new rules:
- PSUseCompatibleSyntax, which checks whether a syntax used in a script will work in other PowerShell versions.
- PSUseCompatibleCommands, which checks whether commands used in a script are available in other PowerShell environments.
- PSUseCompatibleTypes, which checks whether .NET types and static methods/properties are available in other PowerShell environments.
The syntax check rule simply requires a list of PowerShell versions you want to target, and will tell you if a syntax used in your script won’t work in any of those versions.
The command and type checking rules are more sophisticated and rely on profiles (catalogs of commands and types available) from commonly used PowerShell platforms. They require configuration to use these profiles, which we’ll go over below.
For this post, we’ll look at configuring and using PSUseCompatibleSyntax and PSUseCompatibleCommands to check that a script works with different versions of PowerShell. We’ll look at PSUseCompatibleTypes in a later post, although it’s configured very similarly to PSUseCompatibleCommands.
Working example: A small PowerShell script
Imagine we have a small (and contrived) archival script saved to .\archiveScript.ps1
:
# Import helper module to get folders to archive
Import-Module -FullyQualifiedName @{ ModuleName = ArchiveHelper; ModuleVersion = '1.1' }
$paths = Get-FoldersToArchive -RootPath 'C:\Documents\DocumentsToArchive\'
$archiveBasePath = '\\ArchiveServer\DocumentArchive\'
# Dictionary to collect hashes
$hashes = [System.Collections.Generic.Dictionary[string, string]]::new()
foreach ($path in $paths)
{
# Get the hash of the file and turn it into a base64 string
$hash = (Get-FileHash -LiteralPath $path).Hash
# Add this file to the hash catalog
$hashes[$hash] = $path
# Now give the archive a unique name and zip it up
$name = Split-Path -LeafBase $path
Compress-Archive -LiteralPath $path -DestinationPath (Join-Path $archiveBasePath "$name-$hash.zip")
}
# Add the hash catalog to the archive directory
ConvertTo-Json $hashes | Out-File -LiteralPath (Join-Path $archiveBasePath "catalog.json") -NoNewline
This script was written in PowerShell 6.2, and we’ve tested that it works there. But we also want to run it on other machines, some of which run PowerShell 5.1 and some of which run PowerShell 3.0.
Ideally we will test it on those other platforms, but it would be nice if we could try to iron out as many bugs as possible ahead of time.
Checking syntax with PSUseCompatibleSyntax
The first and easiest rule to apply is PSUseCompatibleSyntax. We’re going to create some settings for PSScriptAnalyzer to enable the rule, and then run analysis on our script to get back any diagnostics about compatibility.
Running PSScriptAnalyzer is straightforward. It comes as a PowerShell module, so once it’s installed
on your module path you just invoke it on your file with Invoke-ScriptAnalyzer
, like this:
Invoke-ScriptAnalyzer -Path '.\archiveScript.ps1`
A very simple invocation like this one will run PSScriptAnalyzer using its default rules and configurations on the script you point it to.
However, because they require more targeted configuration, the compatibility rules are not enabled by default. Instead, we need to supply some settings to run the syntax check rule. In particular, PSUseCompatibleSyntax requires a list of the PowerShell versions you are targeting with your script.
$settings = @{
Rules = @{
PSUseCompatibleSyntax = @{
# This turns the rule on (setting it to false will turn it off)
Enable = $true
# List the targeted versions of PowerShell here
TargetVersions = @(
'3.0',
'5.1',
'6.2'
)
}
}
}
Invoke-ScriptAnalyzer -Path .\archiveScript.ps1 -Settings $settings
Running this will present us with the following output:
RuleName Severity ScriptName Line Message
-------- -------- ---------- ---- -------
PSUseCompatibleSyntax Warning archiveScr 8 The constructor syntax
ipt.ps1 '[System.Collections.Generic.Dictionary[string,
string]]::new()' is not available by default in
PowerShell versions 3,4
This is telling us that the [dictionary[string, string]]::new()
syntax we used won’t work in
PowerShell 3. Better than that, in this case the rule has actually proposed a fix:
$diagnostics = Invoke-ScriptAnalyzer -Path .\archiveScript.ps1 -Settings $settings
$diagnostics[0].SuggestedCorrections
File : C:\Users\roholt\Documents\Dev\sandbox\VersionedScript\archiveScript.ps1
Description : Use the 'New-Object @($arg1, $arg2, ...)' syntax instead for compatibility with PowerShell versions 3,4
StartLineNumber : 8
StartColumnNumber : 11
EndLineNumber : 8
EndColumnNumber : 73
Text : New-Object 'System.Collections.Generic.Dictionary[string,string]'
Lines : {New-Object 'System.Collections.Generic.Dictionary[string,string]'}
Start : Microsoft.Windows.PowerShell.ScriptAnalyzer.Position
End : Microsoft.Windows.PowerShell.ScriptAnalyzer.Position
The suggested correction is to use New-Object
instead. The way this is suggested might seem
slightly unhelpful here with all the position information, but we’ll see later why this is useful.
This dictionary example is a bit artificial of course (since a hashtable would come more naturally),
but having a spanner thrown into the works in PowerShell 3 or 4 because of a ::new()
is not
uncommon. The PSUseCompatibleSyntax rule will also warn you about classes, workflows and using
statements depending on the versions of PowerShell you’re authoring for.
We’re not going to make the suggested change just yet, since there’s more to show you first.
Checking command usage with PSUseCompatibleCommands
We now want to check the commands. Because command compatibility is a bit more complicated than syntax (since the availability of commands depends on more than what version of PowerShell is being run), we have to target profiles instead.
Profiles are catalogs of information taken from stock machines running common PowerShell environments. The ones shipped in PSScriptAnalyzer can’t always match your working environment perfectly, but they come pretty close (there’s also a way to generate your own profile, detailed in a later blog post). In our case, we’re trying to target PowerShell 3.0, PowerShell 5.1 and PowerShell 6.2 on Windows. We have the first two profiles, but in the last case we’ll need to target 6.1 instead. These targets are very close, so warnings will still be pertinent to using PowerShell 6.2. Later when a 6.2 profile is made available, we’ll be able to switch over to that.
We need to look under the PSUseCompatibleCommands documentation for a list of profiles available by default. For our desired targets we pick:
- PowerShell 6.1 on Windows Server 2019 (
win-8_x64_10.0.14393.0_6.1.3_x64_4.0.30319.42000_core
) - PowerShell 5.1 on Windows Server 2019 (
win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework
) - PowerShell 3.0 on Windows Server 2012 (
win-8_x64_6.2.9200.0_3.0_x64_4.0.30319.42000_framework
)
The long names on the right are canonical profile identifiers, which we use in the settings:
$settings = @{
Rules = @{
PSUseCompatibleCommands = @{
# Turns the rule on
Enable = $true
# Lists the PowerShell platforms we want to check compatibility with
TargetProfiles = @(
'win-8_x64_10.0.14393.0_6.1.3_x64_4.0.30319.42000_core',
'win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework',
'win-8_x64_6.2.9200.0_3.0_x64_4.0.30319.42000_framework'
)
}
}
}
Invoke-ScriptAnalyzer -Path ./archiveScript.ps1 -Settings $settings
There might be a delay the first time you execute this because the rules have to load the catalogs into a cache. Each catalog of a PowerShell platform contains details of all the modules and .NET assemblies available to PowerShell on that platform, which can be as many as 1700 commands with 15,000 parameters and 100 assemblies with 10,000 types. But once it’s loaded, further compatibility analysis will be fast. We get output like this:
RuleName Severity ScriptName Line Message
-------- -------- ---------- ---- -------
PSUseCompatibleCommands Warning archiveScr 2 The parameter 'FullyQualifiedName' is not available for
ipt.ps1 command 'Import-Module' by default in PowerShell version
'3.0' on platform 'Microsoft Windows Server 2012
Datacenter'
PSUseCompatibleCommands Warning archiveScr 12 The command 'Get-FileHash' is not available by default in
ipt.ps1 PowerShell version '3.0' on platform 'Microsoft Windows
Server 2012 Datacenter'
PSUseCompatibleCommands Warning archiveScr 18 The parameter 'LeafBase' is not available for command
ipt.ps1 'Split-Path' by default in PowerShell version
'5.1.17763.316' on platform 'Microsoft Windows Server
2019 Datacenter'
PSUseCompatibleCommands Warning archiveScr 18 The parameter 'LeafBase' is not available for command
ipt.ps1 'Split-Path' by default in PowerShell version '3.0' on
platform 'Microsoft Windows Server 2012 Datacenter'
PSUseCompatibleCommands Warning archiveScr 19 The command 'Compress-Archive' is not available by
ipt.ps1 default in PowerShell version '3.0' on platform
'Microsoft Windows Server 2012 Datacenter'
PSUseCompatibleCommands Warning archiveScr 23 The parameter 'NoNewline' is not available for command
ipt.ps1 'Out-File' by default in PowerShell version '3.0' on
platform 'Microsoft Windows Server 2012 Datacenter'
This is telling us that:
Import-Module
doesn’t support-FullyQualifiedName
in PowerShell 3.0;Get-FileHash
doesn’t exist in PowerShell 3.0;Split-Path
doesn’t have-LeafBase
in PowerShell 5.1 or PowerShell 3.0;Compress-Archive
isn’t available in PowerShell 3.0, and;Out-File
doesn’t support-NoNewline
in PowerShell 3.0
One thing you’ll notice is that the Get-FoldersToArchive
function is not being warned about. This
is because the compatibility rules are designed to ignore user-provided commands; a command will
only be marked as incompatible if it’s present in some profile and not in one of your targets.
Again, we can change the script to fix these warnings, but before we do, I want to show you how to make this a more continuous experience; as you change your script, you want to know if the changes you make break compatibility, and that’s easy to do with the steps below.
Using a settings file for repeated invocation
The first thing we want is to make the PSScriptAnalyzer invocation more automated and reproducible. A nice step toward this is taking the settings hashtable we made and turning it into a declarative data file, separating out the “what” from the “how”.
PSScriptAnalyzer will accept a path to a PSD1 in the -Settings
parameter, so all we need to do is
turn our hashtable into a PSD1 file, which we’ll make ./PSScriptAnalyzerSettings.psd1
. Notice we
can merge the settings for both PSUseCompatibleSyntax and PSUseCompatibleCommands:
# PSScriptAnalyzerSettings.psd1
# Settings for PSScriptAnalyzer invocation.
@{
Rules = @{
PSUseCompatibleCommands = @{
# Turns the rule on
Enable = $true
# Lists the PowerShell platforms we want to check compatibility with
TargetProfiles = @(
'win-8_x64_10.0.14393.0_6.1.3_x64_4.0.30319.42000_core',
'win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework',
'win-8_x64_6.2.9200.0_3.0_x64_4.0.30319.42000_framework'
)
}
PSUseCompatibleSyntax = @{
# This turns the rule on (setting it to false will turn it off)
Enable = $true
# Simply list the targeted versions of PowerShell here
TargetVersions = @(
'3.0',
'5.1',
'6.2'
)
}
}
}
Now we can run the PSScriptAnalyzer again on the script using the settings file:
Invoke-ScriptAnalyzer -Path ./archiveScript.ps1 -Settings ./PSScriptAnalyzerSettings.psd1
This gives the output:
RuleName Severity ScriptName Line Message
-------- -------- ---------- ---- -------
PSUseCompatibleCommands Warning archiveScr 1 The parameter 'FullyQualifiedName' is not available for
ipt.ps1 command 'Import-Module' by default in PowerShell version
'3.0' on platform 'Microsoft Windows Server 2012
Datacenter'
PSUseCompatibleCommands Warning archiveScr 9 The command 'Get-FileHash' is not available by default in
ipt.ps1 PowerShell version '3.0' on platform 'Microsoft Windows
Server 2012 Datacenter'
PSUseCompatibleCommands Warning archiveScr 12 The parameter 'LeafBase' is not available for command
ipt.ps1 'Split-Path' by default in PowerShell version '3.0' on
platform 'Microsoft Windows Server 2012 Datacenter'
PSUseCompatibleCommands Warning archiveScr 12 The parameter 'LeafBase' is not available for command
ipt.ps1 'Split-Path' by default in PowerShell version
'5.1.17763.316' on platform 'Microsoft Windows Server
2019 Datacenter'
PSUseCompatibleCommands Warning archiveScr 13 The command 'Compress-Archive' is not available by
ipt.ps1 default in PowerShell version '3.0' on platform
'Microsoft Windows Server 2012 Datacenter'
PSUseCompatibleCommands Warning archiveScr 16 The parameter 'NoNewline' is not available for command
ipt.ps1 'Out-File' by default in PowerShell version '3.0' on
platform 'Microsoft Windows Server 2012 Datacenter'
PSUseCompatibleSyntax Warning archiveScr 6 The constructor syntax
ipt.ps1 '[System.Collections.Generic.Dictionary[string,
string]]::new()' is not available by default in
PowerShell versions 3,4
Now we don’t depend on any variables anymore, and have a separate spefication of the analysis you want. Using this, you could put this into continuous integration environments for example to check that changes in scripts don’t break compatibility.
But what we really want is to know that PowerShell scripts stay compatible as you edit them. That’s what the settings file is building to, and also where it’s easiest to make the changes you need to make your script compatible. For that, we want to integrate with the VSCode PowerShell extension.
Integrating with VSCode for on-the-fly compatibility checking
As explained at the start of this post, VSCode PowerShell extension has builtin support for PSScriptAnalyzer. In fact, as of version 1.12.0, the PowerShell extension ships with PSScriptAnalyzer 1.18, meaning you don’t need to do anything other than create a settings file to do compatibility analysis.
We already have our settings file ready to go from the last step, so all we have to do is point the PowerShell extension to the file in the VSCode settings.
You can open the settings with Ctrl+, (use Cmd+, on
macOS). In the Settings view, we want PowerShell > Script Analysis: Settings Path
. In the
settings.json
view this is "powershell.scriptAnalysis.settingsPath"
. Entering a relative path
here will find a settings file in our workspace, so we just put ./PSScriptAnalyzerSettings.psd1
:
In the settings.json
view this will look like:
"powershell.scriptAnalysis.settingsPath": "./PSScriptAnalyzerSettings.psd1"
Now, opening the script in VSCode we see “green squigglies” for compatibility warnings:
In the problems pane, you’ll get a full desrciption of all the incompatibilities:
Let’s fix the syntax problem first. If you remember, PSScriptAnalyzer supplies a suggested correction to this problem. VSCode integrates with PSScriptAnalyzer’s suggested corrections and can apply them if you click on the lightbulb or with Ctrl+Space when the region is under the cursor:
Applying this change, the script is now:
Import-Module -FullyQualifiedName @{ ModuleName = ArchiveHelper; ModuleVersion = '1.1' }
$paths = Get-FoldersToArchive -RootPath 'C:\Documents\DocumentsToArchive\'
$archivePath = '\\ArchiveServer\DocumentArchive\'
$hashes = New-Object 'System.Collections.Generic.Dictionary[string,string]'
foreach ($path in $paths)
{
$hash = (Get-FileHash -LiteralPath $path).Hash
$hashes[$hash] = $path
$name = Split-Path -LeafBase $path
Compress-Archive -LiteralPath $path -DestinationPath (Join-Path $archivePath "$name-$hash.zip")
}
ConvertTo-Json $hashes | Out-File -LiteralPath (Join-Path $archivePath "catalog.json") -NoNewline
The other incompatibilities don’t have corrections; for now PSUseCompatibleCommands knows what commands are available on each platform, but not what to substitute with when a command isn’t available. So we just need to apply some PowerShell knowledge:
- Instead of
Import-Module -FullyQualifiedName @{...}
we useImport-Module -Name ... -Version ...
- Instead of
Get-FileHash
, we’re going to need to use .NET directly and write a function - Instead of
Split-Path -LeafBase
, we can use[System.IO.Path]::GetFileNameWithoutExtension()
- Instead of
Compress-Archive
we’ll need to use more .NET methods in a function, and - Instead of
Out-File -NoNewline
we can useNew-Item -Value
We end up with something like this (the specific implementation is unimportant, but we have something that will work in all versions):
Import-Module -Name ArchiveHelper -Version '1.1'
function CompatibleGetFileHash
{
param(
[string]
$LiteralPath
)
try
{
$hashAlg = [System.Security.Cryptography.SHA256]::Create()
$file = [System.IO.File]::Open($LiteralPath, 'Open', 'Read')
$file.Position = 0
$hashBytes = $hashAlg.ComputeHash($file)
return [System.BitConverter]::ToString($hashBytes).Replace('-', '')
}
finally
{
$file.Dispose()
$hashAlg.Dispose()
}
}
function CompatibleCompressArchive
{
param(
[string]
$LiteralPath,
[string]
$DestinationPath
)
if ($PSVersion.Major -le 3)
{
# PSUseCompatibleTypes identifies that [System.IO.Compression.ZipFile]
# isn't available by default in PowerShell 3 and we have to do this.
# We'll cover that rule in the next blog post.
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Ignore
}
[System.IO.Compression.ZipFile]::Create(
$LiteralPath,
$DestinationPath,
'Optimal',
<# includeBaseDirectory #> $true)
}
$paths = Get-FoldersToArchive -RootPath 'C:\Documents\DocumentsToArchive\'
$archivePath = '\\ArchiveServer\DocumentArchive\'
$hashes = New-Object 'System.Collections.Generic.Dictionary[string,string]'
foreach ($path in $paths)
{
$hash = CompatibleGetFileHash -LiteralPath $path
$hashes[$hash] = $path
$name = [System.IO.Path]::GetFileNameWithoutExtension($path)
CompatibleCompressArchive -LiteralPath $path -DestinationPath (Join-Path $archivePath "$name-$hash.zip")
}
$jsonStr = ConvertTo-Json $hashes
New-Item -Path (Join-Path $archivePath "catalog.json") -Value $jsonStr
You should notice that as you type, VSCode displays new analysis of what you’re writing and the green squigglies drop away. When we’re done we get a clean bill of health for script compatibility:
This means you’ll now be able to use this script across all the PowerShell versions you need to target. Better, you now have a configuration in your workspace so as you write more scripts, there is continual checking for compatibility. And if your compatibility targets change, all you need to do is change your configuration file in one place to point to your desired targets, at which point you’ll get analysis for your updated target platforms.
Summary
Hopefully in this blog post you got some idea of the new compatibility rules that come with PSScriptAnalyzer 1.18.
We’ve covered how to set up and use the syntax compatibility checking rule, PSUseCompatibleSyntax, and the command checking rule, PSUseCompatibleCommands, both using a hashtable configuration and a settings PSD1 file.
We’ve also looked at using the compatibility rules in with the PowerShell extension for VSCode, where they come by default from version 1.12.0.
If you’ve got the latest release of the PowerShell extension for VSCode (1.12.1), you’ll be able to set your configuration file and instantly get compatibility checking.
In the next blog post, we’ll look at how to use these rules and PSUseCompatibleTypes (which checks if .NET types and static methods are available on target platforms) can be used to help you write scripts that work cross platform across Windows and Linux using both Windows PowerShell and PowerShell Core.
Rob Holt
Software Engineer
PowerShell Team
Great stuff! Exactly what I was looking for to implement in my CI-CD Powershell repository 🙂
PS: I got noticed, that if you checck “Select-Object -exp name” (exp instead of expandProperty), PSUseCompatibleCommands rule incorrectly highlight this as error with message: The parameter ‘exp’ is not available for command ‘Select-Object’ by default in PowerShell version…
Is it possible to bypass this somehow?
⚠ Typo alert:
win-8_x64_10.0.14393
is Windows Server 2016, not 2019. Also,win-8_x64_10.0.17763
is Windows Server 2019, not 2016. This typographic error is visible in the GitHub repo page too.Thanks for letting me know. I’ve updated the blog post and opened a PR on GitHub.
Really good write up and very well explained! Didn’t know it was so configurable as I always used the default in VS Code. Thank you for this amazing and detailed explenation. Let’s start tweaking some things now 🙂