Creating custom PSScriptAnalyzer rules

PowerShell PowerShell Universal

July 17, 2024

quote Discuss this Article

As a part of our PowerShell Universal v5 release, we are adding some custom PSScriptAnalyzer rules to the platform to make it easier to spot problems when building out scripts. In this post, we’ll walk through how to create custom PSScriptAnalyzer rules.

PSScriptAnalyzer

PSScriptAnalyzer is a static code analysis tool for PowerShell scripts. It checks the quality of the code based on a set of rules. The built in rules check all kinds of things like alias usage, spaces at the ends of lines, $null checks and more. When using editors, like VS Code, PSScriptAnalyzer runs in the background to provide real-time feedback on your scripts.

Internally, the PowerShell extension is running Invoke-ScriptAnalyzer to provide this feedback. You can run this command yourself to see the results of the analysis. Running with just the path will run all the default rules.

Invoke-ScriptAnalyzer -Path .\MyScript.ps1

You can also exclude rules using parameters on the cmdlet.

Invoke-ScriptAnalyzer -Path .\MyScript.ps1 -ExcludeRule PSAvoidUsingCmdletAliases

In order to customize PSScriptAnalyzer behavior without having to modify the command line, you can also use a configuration file. This file does everything the different parameters on Invoke-ScriptAnalyzer.

# PSScriptAnalyzerSettings.psd1
@{
    Severity=@('Error','Warning')
    ExcludeRules=@('PSAvoidUsingCmdletAliases', 'PSAvoidUsingWriteHost')
}

You can use script analyzer settings files by passing the -Settings parameter to Invoke-ScriptAnalyzer.

Invoke-ScriptAnalyzer -Path MyScript.ps1 -Settings PSScriptAnalyzerSettings.psd1

You can also use this settings file with VS Code by configuring this setting.

Custom Rules

Custom rules are defined using PowerShell modules. Custom rules can accept a list of tokens or an abstract syntax tree (AST) and return diagnostic records. For example, in PowerShell Universal, we have a rule that checks for the use of built in variables like $PSUEnvironment or $UniversalClient. Setting these items would cause undefined behaviors so we want users to know up front when they attempt to override them.

The first step is to create a new .psm1 module file and define a function that will be used to analyze the script. In this example, we’ve chosen Universal.Rules.psm1 as the file name and defined the Measure-PSUBuiltInVariables function. Comment-based help is required and there are some specific details of this function that should be noted.

First, you need to include the OutputType attribute to specify the type of object that the function will return. In this case, it will return an array of Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord objects. You also need to accept a ScriptBlockAst object as a parameter. This object represents the script block of the script that is being analyzed.

<#
.SYNOPSIS
    Locates built in PowerShell Universal variables
.DESCRIPTION
    Checks to make sure that built in PowerShell Universal variables are not being used in the script.
.EXAMPLE
    Measure-PSUBuiltInVariables -ScriptBlockAst $ScriptBlockAst
.INPUTS
    [System.Management.Automation.Language.ScriptBlockAst]
.OUTPUTS
    [Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
.NOTES
    None
#>
function Measure-PSUBuiltInVariables {
    [CmdletBinding()]
    [OutputType([Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
    Param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Language.ScriptBlockAst]
        $ScriptBlockAst
    )

The body of this function does the heavy lifting of analyzing the script block. In our example, we check the AST to try to find assignment statements. If we find assignment statements, we then check to see if the left side of the assignment is a variable. If that’s the case, we then using a static method to verify whether it’s one of the built in variables in PowerShell Universal.

If the conditions are met, we create new DiagnosticRecord objects and populate the necessary information. $_.Extent is important because it tells editors, like VS Code or Monaco, what part of the script to highlight with the warning or error.

    Process {
        $results = @()

        try {
            #region Define predicates to find ASTs.
            [ScriptBlock]$predicate1 = {
                param ([System.Management.Automation.Language.Ast]$Ast)

                if ($Ast -is [System.Management.Automation.Language.AssignmentStatementAst]) {
                    if ($Ast.Left -is [System.Management.Automation.Language.VariableExpressionAst]) {   
                        return [PowerShellUniversal.BuiltInVariables]::IsBuiltInVariable($Ast.Left.VariablePath.UserPath)
                    }
                }

                return $false
            }

            #endregion

            #region Finds ASTs that match the predicate.

            [System.Management.Automation.Language.Ast[]]$methodAst = $ScriptBlockAst.FindAll($predicate1, $true)

            $methodAst | ForEach-Object {
                $result = New-Object `
                    -Typename "Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord" `
                    -ArgumentList "Overriding built in PowerShell Universal variables can cause undefined behavior.", $_.Extent, $PSCmdlet.MyInvocation.InvocationName, Warning, $null
                $results += $result
            }

            return $results

            #endregion
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($PSItem)
        }
    }

Finally, we make sure to export the function from our module.

Export-ModuleMember -Function Measure-PSUBuiltInVariables

Inside PowerShell Universal, we use Invoke-ScriptAnalyzer to run code analysis. As you type in the editor, this function is called to provide real-time feedback. In order to use our custom rule, we pass the -CustomRulePath parameter to Invoke-ScriptAnalyzer.

Invoke-ScriptAnalyzer -ScriptDefinition $code -CustomRulePath $InstallDir\Modules\Universal.Rules\Universal.Rules.psm1 -IncludeDefaultRules

We then process the output of PSScriptAnalyzer to provide feedback to the user.

Have some ideas for custom rules for PowerShell Universal? Let us know.