Table of Contents

Creating a New PSSA Rule
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:
GetScriptAnalyzerRule.tests.ps1
expected 70 built-in rules but found 71.RuleDocumentation.tests.ps1
reports that our rule doesn’t have an entry in the main README.md rules file.RuleDocumentation.tests.ps1
reports that our rule doesn’t have a documentation file.
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 {}
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.
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.
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!
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}
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, butmyFunction
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.
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.
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.
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
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
orcloses #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.
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!