Creating a New PSSA Rule

Creating a New PSSA Rule

15 min read

Issues are a great place to start when looking to contribute. They’re used to track bugs, enhancements, and new feature requests. They provide a way for the community to discuss and prioritise.

You can view the open issues on the GitHub repository.

Issue

Issue PowerShell/PSScriptAnalyzer#2099 from user iRon7 highlights that it’s possible to create functions with names that are reserved words in PowerShell. The below are all allowed:

 1function function {
 2    Write-Host "Hello from 'Function'"
 3}
 4
 5function exit {
 6    Write-Host "Hello from 'Exit'"
 7}
 8
 9function throw {
10    Write-Host "Hello from 'Throw'"
11}

You wouldn’t be able to use these like any normal function. You’d need to use the call operator (&) to invoke them:

1& function
2& exit
3& throw

PowerShell is, by design, very permissive. The language doesn’t want to get in your way.

That said, just because you can, doesn’t mean that you should; Enter PSScriptAnalyzer.

We can write a rule that analyses a script and checks the names of all defined functions. It will warn if any of them are reserved words so the user can make an informed choice; chances are it’s a mistake and they hadn’t realised.

Reserved Words

A list of reserved words can be found under help topic about_Reserved_Words.

assembly            else                type                hidden
base                elseif              until               if
begin               end                 using               in
break               enum                var (*)             inlinescript
catch               process             while               interface
class               public              workflow            module
command             return              exit                namespace
configuration       sequence            filter              parallel
continue            static              finally             param
data                switch              for                 private
define (*)          throw               foreach
do                  trap                from (*)
dynamicparam        try                 function

(*) are not currently used but are reserved for future use.

Branching

We’ll create a feature branch to work in that’s based on the current default branch.

I like to include the issue reference in the branch name where possible.

I think the rule name should be PSAvoidReservedWordsAsFunctionNames - so we’ll use that too.

Ultimately your branch name doesn’t matter - but be descriptive - future you may thank you.

1git checkout -b '#2099PSAvoidReservedWordsAsFunctionNames' origin/master

To start with a clean slate, we’ll first build and run the tests on our new branch. It’s important we know we’re starting in a known good state.

With tests all passing, we can get to work.

Scaffolding

Rules are defined in the Rules project, as classes in the Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules namespace that implement the IScriptRule interface.

Let’s create a new rule file: Rules\AvoidReservedWordsAsFunctionNames.cs

 1// Copyright (c) Microsoft Corporation. All rights reserved.
 2// Licensed under the MIT License.
 3
 4using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
 5using System;
 6using System.Collections.Generic;
 7using System.Globalization;
 8using System.Management.Automation.Language;
 9#if !CORECLR
10using System.ComponentModel.Composition;
11#endif
12
13namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
14{
15#if !CORECLR
16    [Export(typeof(IScriptRule))]
17#endif
18
19    /// <summary>
20    /// Rule that warns when reserved words are used as function names
21    /// </summary>
22    public class AvoidReservedWordsAsFunctionNames : IScriptRule
23    {
24
25        /// <summary>
26        /// Analyzes the PowerShell AST for uses of reserved words as function names.
27        /// </summary>
28        /// <param name="ast">The PowerShell Abstract Syntax Tree to analyze.</param>
29        /// <param name="fileName">The name of the file being analyzed (for diagnostic reporting).</param>
30        /// <returns>A collection of diagnostic records for each violation.</returns>
31        public IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName)
32        {
33            if (ast == null)
34            {
35                throw new ArgumentNullException(Strings.NullAstErrorMessage);
36            }
37            return new List<DiagnosticRecord>();
38        }
39
40        public string GetCommonName() => Strings.AvoidReservedWordsAsFunctionNamesCommonName;
41
42        public string GetDescription() => Strings.AvoidReservedWordsAsFunctionNamesDescription;
43
44        public string GetName() => string.Format(
45                CultureInfo.CurrentCulture,
46                Strings.NameSpaceFormat,
47                GetSourceName(),
48                Strings.AvoidReservedWordsAsFunctionNamesName);
49
50        public RuleSeverity GetSeverity() => RuleSeverity.Warning;
51
52        public string GetSourceName() => Strings.SourceName;
53
54        public SourceType GetSourceType() => SourceType.Builtin;
55    }
56}

There’s lots there, so let’s break it down.

  • At the very top we have the copyright header - this must be present for all source files committed to the repository.

  • There’s a using directive and an export attribute which are required for .NET Framework - so they’re guarded by a preprocessor directive. Since the project is targeting both .NET Framework and .NET Core, this needs to be here.

  • The rule class is declared, implementing the IScriptRule interface and its required interface members.

  • The AnalyzeScript method is what’s called when the rule is run against a PowerShell script. It’s where the logic for identifying violations and emitting diagnostic records will be implemented. Currently we’re just returning an empty list - effectively doing nothing.

  • Lastly there are several convenience methods defined to help with rule metadata. They’re used to provide additional information about the rule, such as its name, description, and severity.

Currently we have an error, preventing us from building the project. We’ve used several string resources that we’ve not yet defined.

They need to be added to the Rules/Strings.resx file.

 1<data name="AvoidReservedWordsAsFunctionNamesCommonName" xml:space="preserve">
 2    <value>Avoid Reserved Words as function names</value>
 3</data>
 4<data name="AvoidReservedWordsAsFunctionNamesDescription" xml:space="preserve">
 5    <value>Avoid using reserved words as function names. Using reserved words as function names can cause errors or unexpected behavior in scripts.</value>
 6</data>
 7<data name="AvoidReservedWordsAsFunctionNamesName" xml:space="preserve">
 8    <value>AvoidReservedWordsAsFunctionNames</value>
 9</data>
10<data name="AvoidReservedWordsAsFunctionNamesError" xml:space="preserve">
11    <value>The reserved word '{0}' was used as a function name. This should be avoided.</value>
12</data>

Adding them in, you’ll notice we’ve also added another, AvoidReservedWordsAsFunctionNamesError. We’re going to need this shortly. It will be the text of the warning that gets reported when a reserved word is used as a function name. The {0} is a placeholder for the offending function name.

Building now will succeed, but running the tests shows some other issues:

Let’s address these issues before we move on.

Firstly, we update the number of tests in that test file to 71; nice and simple!

Secondly, we add an entry for our new rule in the docs/Rules/README.md file, in the PSScriptAnalyzer Rules table. We list it as enabled by default and of Warning severity.

Lastly we create a markdown documentation file for our new rule. This file goes in the docs/Rules folder.

AvoidReservedWordsAsFunctionNames.md:

 1---
 2description: Avoid reserved words as function names
 3ms.date: 08/31/2025
 4ms.topic: reference
 5title: AvoidReservedWordsAsFunctionNames
 6---
 7# AvoidReservedWordsAsFunctionNames
 8
 9**Severity Level: Warning**
10
11## Description
12
13Avoid using reserved words as function names. Using reserved words as function
14names can cause errors or unexpected behavior in scripts.
15
16## How to Fix
17
18## Example
19
20### Wrong
21
22```powershell
23# function is a reserved word
24function function {
25    Write-Host "Hello, World!"
26}
27```
28
29### Correct
30
31```powershell
32# myFunction is not a reserved word
33function myFunction {
34    Write-Host "Hello, World!"
35}
36```

Running the tests now - we get green across the board!

AST

Our rule currently does absolutely nothing. We still need to implement the logic to analyse the script and report any violations.

In the AnalyzeScript method, we’re passed the AST representation of the script being analysed.

AST is the Abstract Syntax Tree; a representation of the structure of the code.

Let’s look at the AST of a simple PowerShell script using the PowerShell AST Inspector VS Code extension. Running it on the below simple script:

1function functionName1 {}
2
3function functionName2 {}

AST Representation of code block

We can see that there are two function definitions in the AST. These are of type FunctionDefinitionAst.

Looking at the properties of one of the FunctionDefinitionAst instances, we can see we have access to the Name property of the function. Helpfully the documentation tells us that this property is never null or empty. Any function without a name is a parser error.

FunctionDefinitionAst Properties

Analyse and Diagnose

We can now implement the core logic of the rule.

Let’s start by defining a list of all the reserved words in PowerShell. We need this to check function names against.

As we just want to quickly check if the function name is in the list, ignoring case, we can use a HashSet<string> for fast lookups.

With an OrdinalIgnoreCase comparer, we ensure that our checks are case-insensitive and fast.

 1static readonly HashSet<string> reservedWords = new HashSet<string>(
 2    new[] {
 3        "assembly", "base", "begin", "break",
 4        "catch", "class", "command", "configuration",
 5        "continue", "data", "define", "do",
 6        "dynamicparam", "else", "elseif", "end",
 7        "enum", "exit", "filter", "finally",
 8        "for", "foreach", "from", "function",
 9        "hidden", "if", "in", "inlinescript",
10        "interface", "module", "namespace", "parallel",
11        "param", "private", "process", "public",
12        "return", "sequence", "static", "switch",
13        "throw", "trap", "try", "type",
14        "until", "using","var", "while", "workflow"
15    },
16    StringComparer.OrdinalIgnoreCase
17);

Next we find all of the FunctionDefinitionAst nodes in the AST.

1var functionDefinitions = ast.FindAll(
2    astNode => astNode is FunctionDefinitionAst,
3    true
4).Cast<FunctionDefinitionAst>();

For each function definition we can ask if the function’s name is in our list of reserved words, and if it is, emit a warning.

 1foreach (var function in functionDefinitions)
 2{
 3    if (reservedWords.Contains(function.Name))
 4    {
 5        yield return new DiagnosticRecord(
 6            string.Format(
 7                CultureInfo.CurrentCulture,
 8                Strings.AvoidReservedWordsAsFunctionNamesError,
 9                function.Name),
10            function.Extent,
11            GetName(),
12            DiagnosticSeverity.Warning,
13            fileName
14        );
15    }
16}

We’ll go over the DiagnosticRecord class in more detail another time, but for now, just know that it takes a message to display, an extent (the start and stop locations) of the issue, the name of the rule, the severity of the issue, and the file name. It can optionally take other things, but for now that’s it.

Our rule now works! 🥳

Building the module and hacking it into VS Code (outside the scope of this article) shows it in action.

Rule in Action in VS Code

We see yellow squigglies covering the functions that use reserved words as their name.

Seeing it in action I think we need to tweak the yellow squigglies. They’re currently highlighting the entire function definition - which is a bit visually noisy. It would be better if it just highlighted the offending function’s name.

This squiggly line comes from the extent we use when we create the DiagnosticRecord. We’re using the function.Extent currently - which is the entire function definition. Instead we need to get an extent for just the function’s name.

To achieve that, we can use a helper function from the Engine\Helper.cs class. Modifying our code to use:

 1if (reservedWords.Contains(function.Name))
 2{
 3    yield return new DiagnosticRecord(
 4        string.Format(
 5            CultureInfo.CurrentCulture,
 6            Strings.AvoidReservedWordsAsFunctionNamesError,
 7            function.Name),
 8        Helper.Instance.GetScriptExtentForFunctionName(function) ?? function.Extent,
 9        GetName(),
10        DiagnosticSeverity.Warning,
11        fileName
12    );
13}

Building that and bringing it into VS Code, things are looking much better!

Rule in Action in VS Code

While playing about in VS Code, I noticed that we don’t handle the case when functions are defined with a scope.

1function global:else {}

Inspecting the AST, the function’s name is reported as the whole name, including the scope - global:else.

This is already a solved problem in the codebase and there’s a helper function to strip the scope from the function name; FunctionNameWithoutScope.

We can use this helper function in our rule and it now handles this situation as you’d expect.

 1if (reservedWords.Contains(
 2    Helper.Instance.FunctionNameWithoutScope(function.Name)
 3))
 4{
 5    yield return new DiagnosticRecord(
 6        string.Format(
 7            CultureInfo.CurrentCulture,
 8            Strings.AvoidReservedWordsAsFunctionNamesError,
 9            function.Name),
10        Helper.Instance.GetScriptExtentForFunctionName(function) ?? function.Extent,
11        GetName(),
12        DiagnosticSeverity.Warning,
13        fileName
14    );
15}

Rule in Action in VS Code

So we’re done right - 🚢 Ship it?

Almost but not quite! We still need to write a comprehensive set of unit tests.

Testing

Tests in the project are written in Pester, the de facto standard testing framework for PowerShell.

We create a new file in the Tests/Rules/ folder for our new rule.

I won’t go through the whole file, but in general we’re trying to cover off the positive (when our rule should alert) and negative (when our rule should not alert) cases for our rule.

To write tests, I like to think through the Should and Should not statements that apply to the rule. For example:

  • The rule should alert when a function is defined with a reserved word as its name.
  • The casing of the defined function name should not matter.
  • The rule should flag the name of the function as the issue location/Extent.
  • The warning message should be coming from our rule and should include the offending function’s name.
  • The rule should not alert when a function is defined with a non-reserved word as its name.
  • The rule should not alert when a function name contains a reserved word as a substring. (i.e. a name of function should alert, but myFunction should not).

We can translate these Should and Should not statements into Pester tests.

We have a string array called $reservedWords that contains the names of all reserved words in PowerShell. We can use this array to drive our tests.

 1Describe 'AvoidReservedWordsAsFunctionNames' {
 2	Context 'When function names are reserved words' {
 3		It 'flags reserved word "<_>" as a violation' -TestCases $reservedWords {
 4
 5			$scriptDefinition = "function $_ { 'test' }"
 6			$violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
 7
 8			$violations.Count | Should -Be 1
 9			$violations.Severity | Should -Be 'Warning'
10			$violations.RuleName | Should -Be $ruleName
11			$violations.Message | Should -Be "The reserved word '$_' was used as a function name. This should be avoided."
12			$violations.Extent.Text | Should -Be $_
13		}
14    }
15}

Pester’s data-driven tests are great for covering a wide range of scenarios without duplicating code. We can easily run the same test for each reserved word. Above we’re defining a function with the current test case as its name, i.e. function function { ... }. We can then run our rule on that definition. We make checks about the resulting violation(s) to ensure they match our expectations. For instance:

  • $violations.Count | Should -Be 1 - we get exactly 1 violation.
  • $violations.Severity | Should -Be 'Warning' - the violation we got was a warning.
  • $violations.RuleName | Should -Be $ruleName - the violation came from our rule.
  • $violations.Message | Should -Be "The reserved word '$_' wa... - The message matched what we expected it to.
  • $violations.Extent.Text | Should -Be $_ - the extent of the violation is just the name of the function - not the whole function definition.

The test statements should be easy to read and fairly close to an English statement. e.g. $violations.Count | Should -Be 1 reads as "Violations count should be one". Zero ambiguity as to what that test means!

Running our tests we get all passes.

screenshot showing all tests passing

Pull Request

Now that we’re done writing, documenting, and testing our rule - we can commit it to our local branch and push that local branch to our remote repository on GitHub.

1# Make sure you only add files you've changed that directly relate to the
2# implementation of the new rule. Avoid any random formatting changes that you
3# or your IDE made to unrelated files! Consider git add -p to make tactical
4# additions.
5git add .
6git commit -m "Add new rule to avoid reserved words as function names"
7git push origin '#2099PSAvoidReservedWordsAsFunctionNames'

Now that GitHub has our branch and changes, we can open our Pull Request.

Going to our fork on GitHub, it’s helpfully suggesting that we may want to open a Pull Request.

screenshot of GitHub UI prompting us to create a PR

GitHub is helpful that way - I’d recommend clicking it. If you happen to dismiss or wait long enough that the prompt goes away, you can go to the main repo page and create a Pull Request from there - click the New button on the Pull Requests tab.

screenshot of GitHub UI showing the option to create a PR

You’ll then need to tell it you want to Compare across forks for your forked repo to show up and be able to select your branch

screenshot of GitHub UI creating a PR across forks

Once you’ve selected your fork and branch, you can click Create pull request.

When creating a new PR, there’s a template that you should populate (it will come up automatically, don’t delete it):

 1## PR Summary
 2
 3<!-- summarize your PR between here and the checklist -->
 4
 5## PR Checklist
 6
 7- [ ] [PR has a meaningful title](https://github.com/PowerShell/PowerShell/blob/master/.github/CONTRIBUTING.md#pull-request---submission)
 8    - Use the present tense and imperative mood when describing your changes
 9- [ ] [Summarized changes](https://github.com/PowerShell/PowerShell/blob/master/.github/CONTRIBUTING.md#pull-request---submission)
10- [ ] [Change is not breaking](https://github.com/PowerShell/PowerShell/blob/master/.github/CONTRIBUTING.md#making-breaking-changes)
11- [ ] [Make sure all `.cs`, `.ps1` and `.psm1` files have the correct copyright header](https://github.com/PowerShell/PowerShell/blob/master/.github/CONTRIBUTING.md#pull-request---submission)
12- [ ] Make sure you've added a new test if existing tests do not effectively test the code changed and/or updated documentation
13- [ ] This PR is ready to merge and is not [Work in Progress](https://github.com/PowerShell/PowerShell/blob/master/.github/CONTRIBUTING.md#pull-request---work-in-progress).
14    - If the PR is work in progress, please add the prefix `WIP:` to the beginning of the title and remove the prefix when the PR is ready.

So we need to write a description of our Pull Request and check off each item in the list.

Once we’ve done that, we’re ready to submit.

Tip: If your PR resolves an open issue (do a search of the issues) then you can link it by using a keyword followed by the issue number. e.g. fixes #1234 or closes #1234. This links the PR to the issue - which lets the issue author know that a fix is in the works.

Tip: If your PR still needs work, you can prefix the title with 'WIP:' and you can also open the PR as draft. screenshot showing option to create PR as draft

Submission

Now we’re done; our Pull Request submitted. The maintainers are busy people with plenty else on their plates. A little patience helps when it comes to the review process. It will get looked at in time!

The project maintainers will review the changes and provide feedback. The community can also provide you feedback should they think it would be helpful.

It’s important to take feedback in the way it’s intended - constructively. It’s not a personal criticism of your work, but rather a way to improve the overall quality of the project. Be open to suggestions and willing to make changes based on the feedback you receive. That’s not to say you have to accept every piece of feedback you get. It’s okay to have a discussion about the feedback and come to a mutual understanding.

Our rule seems pretty niche, but imagine someone starting out with PowerShell - wondering why their function (called ‘function’) isn’t working! Having some guard rails to keep them on track and warning them when they may be doing something inadvisable can be really helpful.

I find it really rewarding to work through these issues - pick them apart and get to the root of the issues and fix them.

That’s it! I’ll update this post in the future to let you know how my little PR gets on!

Share Post