From 520d2aa0a371d0d4986d801221c19de8f43defd5 Mon Sep 17 00:00:00 2001 From: Denis Melnikov <87656893+Haimasker@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:52:40 +0300 Subject: [PATCH 01/21] Add UseCorrectParametersKind rule (#2149) * Consistent function parameters definition * Rule is disabled by default * Possible types of preferred function parameters are: "Inline", "ParamBlock" Co-authored-by: Christoph Bergmeister --- Rules/Strings.resx | 15 + Rules/UseConsistentParametersKind.cs | 171 +++++++ Tests/Engine/GetScriptAnalyzerRule.tests.ps1 | 4 +- .../UseConsistentParametersKind.Tests.ps1 | 428 ++++++++++++++++++ docs/Rules/README.md | 1 + docs/Rules/UseConsistentParametersKind.md | 57 +++ 6 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 Rules/UseConsistentParametersKind.cs create mode 100644 Tests/Rules/UseConsistentParametersKind.Tests.ps1 create mode 100644 docs/Rules/UseConsistentParametersKind.md diff --git a/Rules/Strings.resx b/Rules/Strings.resx index c7645c9cf..f94c9cceb 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1236,4 +1236,19 @@ The reserved word '{0}' was used as a function name. This should be avoided. + + Use correct function parameters definition kind. + + + Use consistent parameters definition kind to prevent potential unexpected behavior with inline functions parameters or param() block. + + + UseConsistentParametersKind + + + Use param() block in function body instead of inline parameters. + + + Use inline parameters definition instead of param() block in function body. + \ No newline at end of file diff --git a/Rules/UseConsistentParametersKind.cs b/Rules/UseConsistentParametersKind.cs new file mode 100644 index 000000000..fd2dfa732 --- /dev/null +++ b/Rules/UseConsistentParametersKind.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +#if !CORECLR +using System.ComponentModel.Composition; +#endif +using System.Globalization; +using System.Linq; +using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// UseConsistentParametersKind: Checks if function parameters definition kind is same as preferred. + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + public class UseConsistentParametersKind : ConfigurableRule + { + private enum ParametersDefinitionKind + { + Inline, + ParamBlock + } + + private ParametersDefinitionKind parametersKind; + + /// + /// Construct an object of UseConsistentParametersKind type. + /// + public UseConsistentParametersKind() : base() + { + Enable = false; // Disable rule by default + } + + /// + /// The type of preferred parameters definition for functions. + /// + /// Default value is "ParamBlock". + /// + [ConfigurableRuleProperty(defaultValue: "ParamBlock")] + public string ParametersKind + { + get + { + return parametersKind.ToString(); + } + set + { + if (String.IsNullOrWhiteSpace(value) || + !Enum.TryParse(value, true, out parametersKind)) + { + parametersKind = ParametersDefinitionKind.ParamBlock; + } + } + } + + /// + /// AnalyzeScript: Analyze the script to check if any function is using not preferred parameters kind. + /// + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) { throw new ArgumentNullException(Strings.NullAstErrorMessage); } + + IEnumerable functionAsts = ast.FindAll(testAst => testAst is FunctionDefinitionAst, true); + if (parametersKind == ParametersDefinitionKind.ParamBlock) + { + return checkInlineParameters(functionAsts, fileName); + } + else + { + return checkParamBlockParameters(functionAsts, fileName); + } + } + + private IEnumerable checkInlineParameters(IEnumerable functionAsts, string fileName) + { + foreach (FunctionDefinitionAst functionAst in functionAsts) + { + if (functionAst.Parameters != null) + { + yield return new DiagnosticRecord( + string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindInlineError, functionAst.Name), + functionAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + ); + } + } + } + + private IEnumerable checkParamBlockParameters(IEnumerable functionAsts, string fileName) + { + foreach (FunctionDefinitionAst functionAst in functionAsts) + { + if (functionAst.Body.ParamBlock != null) + { + yield return new DiagnosticRecord( + string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindParamBlockError, functionAst.Name), + functionAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + ); + } + } + } + + /// + /// Retrieves the common name of this rule. + /// + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindCommonName); + } + + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindDescription); + } + + /// + /// Retrieves the name of this rule. + /// + public override string GetName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.NameSpaceFormat, GetSourceName(), Strings.UseConsistentParametersKindName); + } + + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// Gets the severity of the returned diagnostic record: error, warning, or information. + /// + /// + public DiagnosticSeverity GetDiagnosticSeverity() + { + return DiagnosticSeverity.Warning; + } + + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + } +} diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index 8dca8dcdc..8e433d071 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -63,8 +63,8 @@ Describe "Test Name parameters" { It "get Rules with no parameters supplied" { $defaultRules = Get-ScriptAnalyzerRule - $expectedNumRules = 71 - $defaultRules.Count | Should -Be $expectedNumRules + $expectedNumRules = 72 + $defaultRules.Count | Should -Be $expectedNumRules } It "is a positional parameter" { diff --git a/Tests/Rules/UseConsistentParametersKind.Tests.ps1 b/Tests/Rules/UseConsistentParametersKind.Tests.ps1 new file mode 100644 index 000000000..1dfae19f2 --- /dev/null +++ b/Tests/Rules/UseConsistentParametersKind.Tests.ps1 @@ -0,0 +1,428 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +Describe 'UseConsistentParametersKind' { + Context 'When preferred parameters kind is set to "ParamBlock" explicitly' { + + BeforeAll { + $ruleConfiguration = @{ + Enable = $true + ParametersKind = "ParamBlock" + } + $settings = @{ + IncludeRules = @("PSUseConsistentParametersKind") + Rules = @{ + PSUseConsistentParametersKind = $ruleConfiguration + } + } + } + + It "Returns no violations for parameters outside function" { + $scriptDefinition = @' +[Parameter()]$FirstParam +[Parameter()]$SecondParam + +$FirstParam | Out-Null +$SecondParam | Out-Null +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for param() block outside function" { + $scriptDefinition = @' +param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) + +$FirstParam | Out-Null +$SecondParam | Out-Null +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function without parameters" { + $scriptDefinition = @' +function Test-Function { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with empty param() block" { + $scriptDefinition = @' +function Test-Function { + param() + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with non-empty param() block" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam + ) + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with empty inline parameters" { + $scriptDefinition = @' +function Test-Function() { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with empty inline parameters and non-empty param() block" { + $scriptDefinition = @' +function Test-Function() { + param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam + ) + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns violations for function with non-empty inline parameters" { + $scriptDefinition = @' +function Test-Function( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + } + } + + Context 'When preferred parameters kind is set to "ParamBlock" via default value' { + + BeforeAll { + $ruleConfiguration = @{ + Enable = $true + } + $settings = @{ + IncludeRules = @("PSUseConsistentParametersKind") + Rules = @{ + PSUseConsistentParametersKind = $ruleConfiguration + } + } + } + + It "Returns no violations for parameters outside function" { + $scriptDefinition = @' +[Parameter()]$FirstParam +[Parameter()]$SecondParam + +$FirstParam | Out-Null +$SecondParam | Out-Null +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for param() block outside function" { + $scriptDefinition = @' +param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) + +$FirstParam | Out-Null +$SecondParam | Out-Null +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function without parameters" { + $scriptDefinition = @' +function Test-Function { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with empty param() block" { + $scriptDefinition = @' +function Test-Function { + param() + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with non-empty param() block" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam + ) + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with empty inline parameters" { + $scriptDefinition = @' +function Test-Function() { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function with empty inline parameters and non-empty param() block" { + $scriptDefinition = @' +function Test-Function() { + param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam + ) + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns violations for function with non-empty inline parameters" { + $scriptDefinition = @' +function Test-Function( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + } + } + + Context 'When preferred parameters kind is set to "Inline" explicitly' { + + BeforeAll { + $ruleConfiguration = @{ + Enable = $true + ParametersKind = "Inline" + } + + $settings = @{ + IncludeRules = @("PSUseConsistentParametersKind") + Rules = @{ + PSUseConsistentParametersKind = $ruleConfiguration + } + } + } + + It "Returns no violations for parameters outside function" { + $scriptDefinition = @' +[Parameter()]$FirstParam +[Parameter()]$SecondParam + +$FirstParam | Out-Null +$SecondParam | Out-Null +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for param() block outside function" { + $scriptDefinition = @' +param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) + +$FirstParam | Out-Null +$SecondParam | Out-Null +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns no violations for function without parameters" { + $scriptDefinition = @' +function Test-Function { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns violations for function with empty param() block" { + $scriptDefinition = @' +function Test-Function { + param() + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + } + + It "Returns violations for function with non-empty param() block" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam + ) + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + } + + It "Returns no violations for function with empty inline parameters" { + $scriptDefinition = @' +function Test-Function() { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + + It "Returns violations for function with empty inline parameters and non-empty param() block" { + $scriptDefinition = @' +function Test-Function() { + param( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam + ) + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + } + + It "Returns no violations for function with non-empty inline parameters" { + $scriptDefinition = @' +function Test-Function( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) { + return +} +'@ + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + } + + Context 'When rule is disabled explicitly' { + + BeforeAll { + $ruleConfiguration = @{ + Enable = $false + ParametersKind = "ParamBlock" + } + $settings = @{ + IncludeRules = @("PSUseConsistentParametersKind") + Rules = @{ + PSUseConsistentParametersKind = $ruleConfiguration + } + } + } + + It "Returns no violations for function with non-empty inline parameters" { + $scriptDefinition = @' +function Test-Function( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) { + return +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + } + + Context 'When rule is disabled via default "Enable" value' { + + BeforeAll { + $ruleConfiguration = @{ + ParametersKind = "ParamBlock" + } + $settings = @{ + IncludeRules = @("PSUseConsistentParametersKind") + Rules = @{ + PSUseConsistentParametersKind = $ruleConfiguration + } + } + } + + It "Returns no violations for function with non-empty inline parameters" { + $scriptDefinition = @' +function Test-Function( + [Parameter()]$FirstParam, + [Parameter()]$SecondParam +) { + return +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations | Should -BeNullOrEmpty + } + } +} \ No newline at end of file diff --git a/docs/Rules/README.md b/docs/Rules/README.md index da1058bc2..51858d0de 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -68,6 +68,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [UseCompatibleSyntax](./UseCompatibleSyntax.md) | Warning | No | Yes | | [UseCompatibleTypes](./UseCompatibleTypes.md) | Warning | No | Yes | | [UseConsistentIndentation](./UseConsistentIndentation.md) | Warning | No | Yes | +| [UseConsistentParametersKind](./UseConsistentParametersKind.md) | Warning | No | Yes | | [UseConsistentWhitespace](./UseConsistentWhitespace.md) | Warning | No | Yes | | [UseCorrectCasing](./UseCorrectCasing.md) | Information | No | Yes | | [UseDeclaredVarsMoreThanAssignments](./UseDeclaredVarsMoreThanAssignments.md) | Warning | Yes | | diff --git a/docs/Rules/UseConsistentParametersKind.md b/docs/Rules/UseConsistentParametersKind.md new file mode 100644 index 000000000..04a323b3d --- /dev/null +++ b/docs/Rules/UseConsistentParametersKind.md @@ -0,0 +1,57 @@ +# UseConsistentParametersKind + +**Severity Level: Warning** + +## Description + +All functions should have same parameters definition kind specified in the rule. +Possible kinds are: +1. `Inline`, i.e.: +```PowerShell +function f([Parameter()]$FirstParam) { + return +} +``` +2. `ParamBlock`, i.e.: +```PowerShell +function f { + param([Parameter()]$FirstParam) + return +} +``` + +* For information: in simple scenarios both function definitions above may be considered as equal. Using this rule as-is is more for consistent code-style than functional, but it can be useful in combination with other rules. + +## How to Fix + +Rewrite function so it defines parameters as specified in the rule + +## Example + +### When the rule sets parameters definition kind to 'Inline': +```PowerShell +# Correct +function f([Parameter()]$FirstParam) { + return +} + +# Incorrect +function g { + param([Parameter()]$FirstParam) + return +} +``` + +### When the rule sets parameters definition kind to 'ParamBlock': +```PowerShell +# Inorrect +function f([Parameter()]$FirstParam) { + return +} + +# Correct +function g { + param([Parameter()]$FirstParam) + return +} +``` \ No newline at end of file From b3653a9496f7ee5865a01ebc19ca204775c3eff3 Mon Sep 17 00:00:00 2001 From: Henrique Azevedo Date: Tue, 17 Mar 2026 22:27:59 -0300 Subject: [PATCH 02/21] Fix typos in resource strings and associated C# references (#2163) * Fix typos and grammatical errors in resource strings - Correct misspellings in Strings.resx (e.g., 'equaltiy', 'whitepsace') - Rename misspelled resource keys for consistency - Update C# rule files to reflect new resource key names * Strings: fix grammar and casing in rule descriptions - Combine sentence fragment in AvoidUsingEmptyCatchBlockDescription for better flow - Update 'Whatif' to 'WhatIf' in UseSupportsShouldProcess strings to match PowerShell naming conventions * Strings: improve AvoidUsingEmptyCatchBlockDescription phrasing --- Rules/PossibleIncorrectComparisonWithNull.cs | 2 +- Rules/Strings.resx | 42 +++++++++---------- Rules/UseLiteralInitializerForHashtable.cs | 10 ++--- ...eShouldProcessForStateChangingFunctions.cs | 2 +- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Rules/PossibleIncorrectComparisonWithNull.cs b/Rules/PossibleIncorrectComparisonWithNull.cs index 3302583ae..692f49f13 100644 --- a/Rules/PossibleIncorrectComparisonWithNull.cs +++ b/Rules/PossibleIncorrectComparisonWithNull.cs @@ -104,7 +104,7 @@ private IEnumerable GetCorrectionExtent(BinaryExpressionAst bi binaryExpressionAst.Extent.EndColumnNumber, $"{binaryExpressionAst.Right.Extent.Text} {binaryExpressionAst.ErrorPosition.Text} {binaryExpressionAst.Left.Extent.Text}", binaryExpressionAst.Extent.File, - Strings.PossibleIncorrectComparisonWithNullSuggesteCorrectionDescription + Strings.PossibleIncorrectComparisonWithNullSuggestedCorrectionDescription ); yield return correction; diff --git a/Rules/Strings.resx b/Rules/Strings.resx index f94c9cceb..67f925ede 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -124,7 +124,7 @@ Avoid Using Cmdlet Aliases or omitting the 'Get-' prefix. - Empty catch blocks are considered poor design decisions because if an error occurs in the try block, this error is simply swallowed and not acted upon. While this does not inherently lead to bad things. It can and this should be avoided if possible. To fix a violation of this rule, using Write-Error or throw statements in catch blocks. + Empty catch blocks are considered poor design decisions because if an error occurs in the try block, this error is simply swallowed and not acted upon. While this does not inherently cause problems, it can, so it should be avoided where possible. To fix a violation of this rule, use Write-Error or throw statements in catch blocks. Avoid Using Empty Catch Block @@ -136,7 +136,7 @@ Avoid Using Invoke-Expression - Readability and clarity should be the goal of any script we expect to maintain over time. When calling a command that takes parameters, where possible consider using name parameters as opposed to positional parameters. To fix a violation of this rule, please use named parameters instead of positional parameters when calling a command. + Readability and clarity should be the goal of any script we expect to maintain over time. When calling a command that takes parameters, where possible consider using named parameters as opposed to positional parameters. To fix a violation of this rule, please use named parameters instead of positional parameters when calling a command. Avoid Using Positional Parameters @@ -178,7 +178,7 @@ No Global Variables - Checks that $null is on the left side of any equaltiy comparisons (eq, ne, ceq, cne, ieq, ine). When there is an array on the left side of a null equality comparison, PowerShell will check for a $null IN the array rather than if the array is null. If the two sides of the comaprision are switched this is fixed. Therefore, $null should always be on the left side of equality comparisons just in case. + Checks that $null is on the left side of any equality comparisons (eq, ne, ceq, cne, ieq, ine). When there is an array on the left side of a null equality comparison, PowerShell will check for a $null IN the array rather than if the array is null. If the two sides of the comparison are switched this is fixed. Therefore, $null should always be on the left side of equality comparisons just in case. $null should be on the left side of equality comparisons. @@ -618,7 +618,7 @@ Use ShouldProcess For State Changing Functions - + Functions that have verbs like New, Start, Stop, Set, Reset, Restart that change system state should support 'ShouldProcess'. @@ -703,7 +703,7 @@ PowerShell help file needs to use UTF8 Encoding. - File {0} has to use UTF8 instead of {1} encoding because it is a powershell help file. + File {0} has to use UTF8 instead of {1} encoding because it is a PowerShell help file. UseUTF8EncodingForHelpFile @@ -742,7 +742,7 @@ Misleading Backtick - Ending a line with an escaped whitepsace character is misleading. A trailing backtick is usually used for line continuation. Users typically don't intend to end a line with escaped whitespace. + Ending a line with an escaped whitespace character is misleading. A trailing backtick is usually used for line continuation. Users typically don't intend to end a line with escaped whitespace. MisleadingBacktick @@ -786,16 +786,16 @@ Replace {0} with {1} - + Create hashtables with literal initializers - + Use literal initializer, @{{}}, for creating a hashtable as they are case-insensitive by default - - Create hashtables with literal initliazers + + Create hashtables with literal initializers - + UseLiteralInitializerForHashtable @@ -874,7 +874,7 @@ The type accelerator '{0}' is not available by default in PowerShell version '{1}' on platform '{2}' - Avoid global functiosn and aliases + Avoid global functions and aliases Checks that global functions and aliases are not used. Global functions are strongly discouraged as they can cause errors across different systems. @@ -979,7 +979,7 @@ Use consistent indentation - Each statement block should have a consistent indenation. + Each statement block should have a consistent indentation. Indentation not consistent @@ -991,7 +991,7 @@ Use whitespaces - Check for whitespace between keyword and open paren/curly, around assigment operator ('='), around arithmetic operators and after separators (',' and ';') + Check for whitespace between keyword and open paren/curly, around assignment operator ('='), around arithmetic operators and after separators (',' and ';') Use space before open brace. @@ -1015,10 +1015,10 @@ Use SupportsShouldProcess - Commands typically provide Confirm and Whatif parameters to give more control on its execution in an interactive environment. In PowerShell, a command can use a SupportsShouldProcess attribute to provide this capability. Hence, manual addition of these parameters to a command is discouraged. If a commands need Confirm and Whatif parameters, then it should support ShouldProcess. + Commands typically provide Confirm and WhatIf parameters to give more control on its execution in an interactive environment. In PowerShell, a command can use a SupportsShouldProcess attribute to provide this capability. Hence, manual addition of these parameters to a command is discouraged. If a command needs Confirm and WhatIf parameters, then it should support ShouldProcess. - Whatif and/or Confirm manually defined in function {0}. Instead, please use SupportsShouldProcess attribute. + WhatIf and/or Confirm manually defined in function {0}. Instead, please use SupportsShouldProcess attribute. AlignAssignmentStatement @@ -1042,10 +1042,10 @@ Use a different variable name - Changing automtic variables might have undesired side effects + Changing automatic variables might have undesired side effects - This automatic variables is built into PowerShell and readonly. + This automatic variable is built into PowerShell and readonly. The Variable '{0}' cannot be assigned since it is a readonly automatic variable that is built into PowerShell, please use a different name. @@ -1077,7 +1077,7 @@ PossibleIncorrectUsageOfRedirectionOperator - + Use $null on the left hand side for safe comparison with $null. @@ -1177,7 +1177,7 @@ Avoid multiple type specifiers on parameters - Prameter should not have more than one type specifier. + Parameter should not have more than one type specifier. Parameter '{0}' has more than one type specifier. @@ -1219,7 +1219,7 @@ Avoid sending credentials and secrets over unencrypted connections. - The insecure AllowUsingUnencryptedAuthentication switch was used. This should be avoided except for compatability with legacy systems. + The insecure AllowUnencryptedAuthentication switch was used. This should be avoided except for compatibility with legacy systems. AvoidUsingAllowUnencryptedAuthentication diff --git a/Rules/UseLiteralInitializerForHashtable.cs b/Rules/UseLiteralInitializerForHashtable.cs index 8f59d8332..56a31508f 100644 --- a/Rules/UseLiteralInitializerForHashtable.cs +++ b/Rules/UseLiteralInitializerForHashtable.cs @@ -62,7 +62,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) /// public string GetCommonName() { - return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitilializerForHashtableCommonName); + return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitializerForHashtableCommonName); } /// @@ -70,7 +70,7 @@ public string GetCommonName() /// public string GetDescription() { - return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitilializerForHashtableDescription); + return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitializerForHashtableDescription); } /// @@ -82,7 +82,7 @@ public string GetName() CultureInfo.CurrentCulture, Strings.NameSpaceFormat, GetSourceName(), - Strings.UseLiteralInitilializerForHashtableName); + Strings.UseLiteralInitializerForHashtableName); } /// @@ -170,7 +170,7 @@ public override AstVisitAction VisitInvokeMemberExpression(InvokeMemberExpressio || !HasIgnoreCaseComparerArg(methodCallAst.Arguments)) { var dr = new DiagnosticRecord( - Strings.UseLiteralInitilializerForHashtableDescription, + Strings.UseLiteralInitializerForHashtableDescription, methodCallAst.Extent, GetName(), GetDiagnosticSeverity(), @@ -205,7 +205,7 @@ private void AnalyzeNewObjectCommand(CommandAst commandAst) } var dr = new DiagnosticRecord( - Strings.UseLiteralInitilializerForHashtableDescription, + Strings.UseLiteralInitializerForHashtableDescription, commandAst.Extent, GetName(), GetDiagnosticSeverity(), diff --git a/Rules/UseShouldProcessForStateChangingFunctions.cs b/Rules/UseShouldProcessForStateChangingFunctions.cs index 4448e6693..0d526042f 100644 --- a/Rules/UseShouldProcessForStateChangingFunctions.cs +++ b/Rules/UseShouldProcessForStateChangingFunctions.cs @@ -102,7 +102,7 @@ public string GetCommonName() /// The description of this rule public string GetDescription() { - return string.Format(CultureInfo.CurrentCulture, Strings.UseShouldProcessForStateChangingFunctionsDescrption); + return string.Format(CultureInfo.CurrentCulture, Strings.UseShouldProcessForStateChangingFunctionsDescription); } /// From 474e28bcf9ca93c4480fd3ef54c408e60ec646b3 Mon Sep 17 00:00:00 2001 From: Christoph Bergmeister Date: Wed, 18 Mar 2026 01:29:19 +0000 Subject: [PATCH 03/21] Optimise LINQ queries (#2160) * Initial plan * Optimize LINQ operations for better performance Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> * Update Rules/AvoidMultipleTypeAttributes.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Rules/AvoidMultipleTypeAttributes.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Engine/Generic/RuleSuppression.cs | 2 +- Engine/ScriptAnalyzer.cs | 8 ++++---- Engine/Settings.cs | 2 +- Rules/AvoidMultipleTypeAttributes.cs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Engine/Generic/RuleSuppression.cs b/Engine/Generic/RuleSuppression.cs index 279eec58f..d16b356fb 100644 --- a/Engine/Generic/RuleSuppression.cs +++ b/Engine/Generic/RuleSuppression.cs @@ -340,7 +340,7 @@ public static List GetSuppressions(IEnumerable at if (targetAsts != null) { - if (targetAsts.Count() == 0) + if (!targetAsts.Any()) { if (String.IsNullOrWhiteSpace(scopeAst.Extent.File)) { diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index c564dc8fa..46e267fc6 100644 --- a/Engine/ScriptAnalyzer.cs +++ b/Engine/ScriptAnalyzer.cs @@ -267,9 +267,9 @@ internal bool ParseProfile(object profileObject, PathIntrinsics path, IOutputWri return false; } - this.severity = (severityList.Count() == 0) ? null : severityList.ToArray(); - this.includeRule = (includeRuleList.Count() == 0) ? null : includeRuleList.ToArray(); - this.excludeRule = (excludeRuleList.Count() == 0) ? null : excludeRuleList.ToArray(); + this.severity = (severityList.Count == 0) ? null : severityList.ToArray(); + this.includeRule = (includeRuleList.Count == 0) ? null : includeRuleList.ToArray(); + this.excludeRule = (excludeRuleList.Count == 0) ? null : excludeRuleList.ToArray(); if (settings != null && settings.ContainsKey("Rules")) { @@ -609,7 +609,7 @@ private bool ParseProfileString(string profile, PathIntrinsics path, IOutputWrit IEnumerable hashTableAsts = profileAst.FindAll(item => item is HashtableAst, false); // no hashtable, raise warning - if (hashTableAsts.Count() == 0) + if (!hashTableAsts.Any()) { writer.WriteError(new ErrorRecord(new ArgumentException(string.Format(CultureInfo.CurrentCulture, Strings.InvalidProfile, profile)), Strings.ConfigurationFileHasNoHashTable, ErrorCategory.ResourceUnavailable, profile)); diff --git a/Engine/Settings.cs b/Engine/Settings.cs index 124e5488a..0d37a3a78 100644 --- a/Engine/Settings.cs +++ b/Engine/Settings.cs @@ -453,7 +453,7 @@ private void parseSettingsFile(string settingsFilePath) IEnumerable hashTableAsts = profileAst.FindAll(item => item is HashtableAst, false); // no hashtable, raise warning - if (hashTableAsts.Count() == 0) + if (!hashTableAsts.Any()) { throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Strings.InvalidProfile, settingsFilePath)); } diff --git a/Rules/AvoidMultipleTypeAttributes.cs b/Rules/AvoidMultipleTypeAttributes.cs index 77f63de21..590a058d9 100644 --- a/Rules/AvoidMultipleTypeAttributes.cs +++ b/Rules/AvoidMultipleTypeAttributes.cs @@ -37,7 +37,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) // Iterates all ParamAsts and check the number of its types. foreach (ParameterAst paramAst in paramAsts) { - if (paramAst.Attributes.Where(typeAst => typeAst is TypeConstraintAst).Count() > 1) + if (paramAst.Attributes.OfType().Skip(1).Any()) { yield return new DiagnosticRecord( String.Format(CultureInfo.CurrentCulture, Strings.AvoidMultipleTypeAttributesError, paramAst.Name), From 9d8a1fea42b4b8026d11ffe68c34363cf5527479 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Wed, 18 Mar 2026 01:31:18 +0000 Subject: [PATCH 04/21] AlignAssignmentStatement overhaul to fix issues and include handing of Enums. (#2132) * Wholesale rewrite of AlignAssignmentStatement to fix issues * Change setting defaults to all enabled * Wholesale rewrite of AlignAssignmentStatement to fix issues * Change setting defaults to all enabled --------- Co-authored-by: Christoph Bergmeister Co-authored-by: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> --- Rules/AlignAssignmentStatement.cs | 800 ++++++++++---- .../Rules/AlignAssignmentStatement.tests.ps1 | 974 ++++++++++++++++-- docs/Rules/AlignAssignmentStatement.md | 160 ++- 3 files changed, 1617 insertions(+), 317 deletions(-) diff --git a/Rules/AlignAssignmentStatement.cs b/Rules/AlignAssignmentStatement.cs index 14cce7d37..5b941924a 100644 --- a/Rules/AlignAssignmentStatement.cs +++ b/Rules/AlignAssignmentStatement.cs @@ -21,34 +21,45 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules #endif public class AlignAssignmentStatement : ConfigurableRule { - // We keep this switch even though the rule has only one switch (this) as of now, because we want - // to let the rule be expandable in the future to allow formatting assignments even - // in variable assignments. But for now we will stick to only one option. + /// - /// Check if key value pairs in a hashtable are aligned or not. + /// Check the key value pairs of a hashtable, including DSC configurations. /// - /// [ConfigurableRuleProperty(defaultValue: true)] public bool CheckHashtable { get; set; } - private readonly char whitespaceChar = ' '; + /// + /// Whether to include hashtable key-value pairs where there is a comment + /// between the key and the equals sign in alignment. + /// + [ConfigurableRuleProperty(defaultValue: true)] + public bool AlignHashtableKvpWithInterveningComment { get; set; } - private List>> violationFinders - = new List>>(); + /// + /// Check the members of an enum. + /// + [ConfigurableRuleProperty(defaultValue: true)] + public bool CheckEnums { get; set; } /// - /// Sets the configurable properties of this rule. + /// Include enum members without explicit values in the width calculation. /// - /// A dictionary that maps parameter name to it value. Must be non-null - public override void ConfigureRule(IDictionary paramValueMap) - { - base.ConfigureRule(paramValueMap); - if (CheckHashtable) - { - violationFinders.Add(FindHashtableViolations); - } - } + [ConfigurableRuleProperty(defaultValue: true)] + public bool IncludeValuelessEnumMembers { get; set; } + + /// + /// Whether to include enum members where there is a comment + /// between the name and the equals sign in alignment. + /// + [ConfigurableRuleProperty(defaultValue: true)] + public bool AlignEnumMemberWithInterveningComment { get; set; } + /// + /// A mapping of line numbers to the indices of assignment operator + /// tokens on those lines. + /// + private readonly Dictionary> assignmentOperatorIndicesByLine = + new Dictionary>(); /// /// Analyzes the given ast to find if consecutive assignment statements are aligned. @@ -60,277 +71,640 @@ public override IEnumerable AnalyzeScript(Ast ast, string file { if (ast == null) { - throw new ArgumentNullException("ast"); + throw new ArgumentNullException(nameof(ast)); } - // only handles one line assignments - // if the rule encounters assignment statements that are multi-line, the rule will ignore that block - var tokenOps = new TokenOperations(Helper.Instance.Tokens, ast); - foreach (var violationFinder in violationFinders) + // The high-level approach of the rule is to find all of the + // Key-Value pairs in a hashtable, or the members of an enum. + // For all of these assignments, we want to locate where both the + // left-hand-side (LHS) ends and where the equals sign is. + // Looking at all of these assignments for a particular structure, + // we can then decide where the equals sign _should_ be. It should + // be in the column after the longest LHS. + // + // Looking at where it _is_ vs where it _should_ be, we can then + // generate diagnostics and corrections. + + // As an optimisation, we first build a dictionary of all of the + // assignment operators in the script, keyed by line number. We do + // this by doing a single scan of the tokens. This makes it trvially + // fast to find the `Equals` token for a given assignment. + + // Note: In instances where there is a parse error, we do not have + // access to the tokens, so we can't build this dictionary. + // This is relevant for the DSC configuration parsing. + LocateAssignmentOperators(); + + if (CheckHashtable) { - foreach (var diagnosticRecord in violationFinder(tokenOps)) + // Find all hashtables + var hashtableAsts = ast.FindAll( + a => a is HashtableAst, true + ).Cast(); + foreach (var hashtableAst in hashtableAsts) { - yield return diagnosticRecord; + // For each hashtable find all assignment sites that meet + // our criteria for alignment checking + var hashtableAssignmentSites = ParseHashtable(hashtableAst); + + // Check alignment of the assignment sites and emit a + // diagnostic for each misalignment found. + foreach (var diag in CheckAlignment(hashtableAssignmentSites)) + { + yield return diag; + } + } + + // DSC does design time checking of available resource nodes. + // If a resource is not available at design time, the parser + // will error. A DSC Resource definition for a resource which is + // not found will not successfully be parsed and appear in the + // AST as a hashtable. The below is a best-effort attempt to + // find these assignment statements and consistently align them. + + // Find all ConfigurationDefinitionAsts + var dscConfigDefAsts = ast.FindAll( + a => a is ConfigurationDefinitionAst, true + ).Cast(); + foreach (var dscConfigDefAst in dscConfigDefAsts) + { + // Within each ConfigurationDefinitionAst, there can be many + // nested NamedBlocks, each of which can contain many nested + // CommandAsts. The CommandAsts which have 3 command + // elements, with the middle one being an equals sign, are + // the ones we're interested in. `ParseDscConfigDef` will + // emit parsed lists of these CommandAsts that share the + // same parent (and so should be aligned with one another). + foreach (var group in ParseDscConfigDef(dscConfigDefAst, ast)) + { + // Check alignment of the assignment sites and emit a + // diagnostic for each misalignment found. + foreach (var diag in CheckAlignment(group)) + { + yield return diag; + } + } } } - } - /// - /// Retrieves the common name of this rule. - /// - public override string GetCommonName() - { - return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementCommonName); - } + if (CheckEnums) + { + // Find all enum TypeDefinitionAsts + var EnumTypeDefAsts = ast.FindAll( + a => a is TypeDefinitionAst t && t.IsEnum, true + ).Cast(); + foreach (var enumTypeDefAst in EnumTypeDefAsts) + { + // For each enum TypeDef find all assignment sites that meet + // our criteria for alignment checking + var enumAssignmentSites = ParseEnums(enumTypeDefAst); - /// - /// Retrieves the description of this rule. - /// - public override string GetDescription() - { - return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementDescription); + // Check alignment of the assignment sites and emit a + // diagnostic for each misalignment found. + foreach (var diag in CheckAlignment(enumAssignmentSites)) + { + yield return diag; + } + } + } } /// - /// Retrieves the name of this rule. + /// Locate all the assignment tokens in the script and store their + /// indices in the assignmentOperatorIndicesByLine dictionary. /// - public override string GetName() + private void LocateAssignmentOperators() { - return string.Format( - CultureInfo.CurrentCulture, - Strings.NameSpaceFormat, - GetSourceName(), - Strings.AlignAssignmentStatementName); - } + // Clear any existing entries + assignmentOperatorIndicesByLine.Clear(); - /// - /// Retrieves the severity of the rule: error, warning or information. - /// - public override RuleSeverity GetSeverity() - { - return RuleSeverity.Warning; + var tokens = Helper.Instance.Tokens; + // Iterate through all tokens, looking for Equals tokens + for (int i = 0; i < tokens.Length; i++) + { + if (tokens[i].Kind == TokenKind.Equals) + { + // When an equals token is found, check if the dictionary + // has an entry for this line number, and if not create one. + int lineNumber = tokens[i].Extent.StartLineNumber; + if (!assignmentOperatorIndicesByLine.ContainsKey(lineNumber)) + { + assignmentOperatorIndicesByLine[lineNumber] = new List(); + } + // Add the index of this token to the list for this line + assignmentOperatorIndicesByLine[lineNumber].Add(i); + } + } } /// - /// Gets the severity of the returned diagnostic record: error, warning, or information. + /// Parse a hashtable's key-value pairs into a list of tuples which are + /// later used to verify and correct alignment of assignment operators. /// - /// - public DiagnosticSeverity GetDiagnosticSeverity() + /// The hashtable AST to parse. + /// + /// A list of tuples, where each tuple is a (lhsTokenExtent, equalsExtent) + /// pair representing the extent of the token immediately before the '=' + /// (effectively the key/rightmost key token) and the extent of the '=' itself. + /// Only includes pairs where an '=' token is found on the same line as the key. + /// Implicitly skips line continuations. + /// + private List> ParseHashtable(HashtableAst hashtableAst) { - return DiagnosticSeverity.Warning; - } + var assignmentSites = new List>(); - /// - /// Retrieves the name of the module/assembly the rule is from. - /// - public override string GetSourceName() - { - return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + if (hashtableAst == null) { return assignmentSites; } + + // Enumerate the KeyValuePairs of this hashtable + // Each KVP is a Tuple + foreach (var kvp in hashtableAst.KeyValuePairs) + { + // If the assignmentOperator dictionary has no entry for the + // line that the key ends on, skip this KVP + if (!assignmentOperatorIndicesByLine.ContainsKey(kvp.Item1.Extent.EndLineNumber)) + { + continue; + } + + // Next we need to find the location of the equals sign for this + // Key-Value pair. We know the line it should be on. We can + // search all of the equals signs on that line for the one that + // lives between the end of the key and the start of the value. + + int equalsTokenIndex = -1; + foreach (var index in assignmentOperatorIndicesByLine[kvp.Item1.Extent.EndLineNumber]) + { + if (Helper.Instance.Tokens[index].Extent.StartOffset >= kvp.Item1.Extent.EndOffset && + Helper.Instance.Tokens[index].Extent.EndOffset <= kvp.Item2.Extent.StartOffset + ) + { + equalsTokenIndex = index; + break; + } + } + + // If we didn't find the equals sign - skip this KVP + if (equalsTokenIndex == -1) + { + continue; + } + + // Normally a Key-Value pair looks like: + // + // Key = Value + // + // But the below is also valid: + // + // Key <#Inline Comment#> = Value + // + // We can still use this KVP for alignment - we simply treat + // the end of the token before the equals sign as the Left-Hand + // Side (LHS) of the assignment. We expose a user setting for + // this. + // If the user has not chosen to align such KVPs and the token + // before the equals sign does not end at the same offset as + // the key, we skip this KVP. + if (!AlignHashtableKvpWithInterveningComment && + Helper.Instance.Tokens[equalsTokenIndex - 1].Extent.EndOffset != kvp.Item1.Extent.EndOffset + ) + { + continue; + } + + assignmentSites.Add(new Tuple( + Helper.Instance.Tokens[equalsTokenIndex - 1].Extent, + Helper.Instance.Tokens[equalsTokenIndex].Extent + )); + } + + return assignmentSites; } /// - /// Retrieves the type of the rule, Builtin, Managed or Module. + /// Parse a DSC configuration definition's resource/property blocks into + /// a list of tuples which are later used to verify and correct alignment of + /// assignment operators. /// - public override SourceType GetSourceType() + /// The ConfigurationDefinitionAst to parse. + /// + /// An enumeration of lists of tuples, where each tuple is a (lhsTokenExtent, equalsExtent) + /// pair representing the extent of the token immediately before the '=' + /// (effectively the key/rightmost key token) and the extent of the '=' itself. + /// Only includes pairs where an '=' token is found on the same line as the key. + /// Implicitly skips line continuations. + /// + private IEnumerable>> ParseDscConfigDef( + ConfigurationDefinitionAst configDefAst, + Ast ast + ) { - return SourceType.Builtin; - } - private IEnumerable FindHashtableViolations(TokenOperations tokenOps) - { - var hashtableAsts = tokenOps.Ast.FindAll(ast => ast is HashtableAst, true); - var groups = new List>>(); - if (hashtableAsts != null) + + if (configDefAst == null) { yield break; } + + // Find command asts shaped like: = + var commandAsts = configDefAst.FindAll( + a => + a is CommandAst c && + c.CommandElements.Count == 3 && + c.CommandElements[1].Extent?.Text == "=", + true + ).Cast(); + + // Group by grandparent NamedBlock (commandAst.Parent is PipelineAst) + var grouped = commandAsts.GroupBy( + c => c.Parent?.Parent + ); + + foreach (var group in grouped) { - foreach (var astItem in hashtableAsts) + var assignmentSites = new List>(); + + foreach (var cmd in group) { - groups.Add(GetExtents(tokenOps, (HashtableAst)astItem)); - } - } + var lhs = cmd.CommandElements[0].Extent; + var eq = cmd.CommandElements[1].Extent; - var configAsts = tokenOps.Ast.FindAll(ast => ast is ConfigurationDefinitionAst, true); - if (configAsts != null) - { - // There are probably parse errors caused by an "Undefined DSC resource" - // which prevents the parser from detecting the property value pairs as - // hashtable. Hence, this is a workaround to format configurations which - // have "Undefined DSC resource" parse errors. - - // find all commandAsts of the form "prop" "=" "val" that have the same parent - // and format those pairs. - foreach (var configAst in configAsts) + if (lhs.EndLineNumber != eq.StartLineNumber) + { + // Skip if the key and equals sign are not on the same + // line + continue; + } + + // Note: We can't use the token dictionary here like we do + // for hashtables/enums, as we get here typically + // because there's a parse error. i.e. + // ModuleNotFoundDuringParse and ResourceNotDefined + // Helper.Instance.Tokens is unavailable when there's + // a parse error so we can only use the ast. + + // In lieu of being able to check tokens, we check the + // source text between the end of the lhs and the start of + // the equals sign for non-whitespace characters. + // + // key <#comment#> = value + // ^ ^ + // | | + // ------------- + // | + // We check for non-whitespace characters here + // + // If there are any, we extend the lhs extent to include + // them, so that the alignment is to the end of the + // rightmost non-whitespace characters. + + // We get the text between between lhs and eq, trim it from + // the end (so we keep the right-most non-whitespace + // characters). It's length is how much we need to extend + // the lhs extent by. + var nonWhitespaceLength = + ast.Extent.Text.Substring( + lhs.EndOffset, + eq.StartOffset - lhs.EndOffset + ).TrimEnd().Length; + + // If there's any non-whitespace characters between the + // key and the equals sign, and the user has chosen to + // ignore such cases, skip this KVP. + if (nonWhitespaceLength > 0 && !AlignHashtableKvpWithInterveningComment) + { + continue; + } + + IScriptExtent leftExtent = null; + if (nonWhitespaceLength == 0) + { + // When there is no intervening comment, we use the + // key's extent as the LHS extent. + leftExtent = lhs; + } + else + { + // When there is an intervening comment, we extend + // the key's extent to include it. + leftExtent = new ScriptExtent( + new ScriptPosition( + lhs.File, + lhs.StartLineNumber, + lhs.StartColumnNumber, + null + ), + new ScriptPosition( + lhs.File, + lhs.EndLineNumber, + lhs.EndColumnNumber + nonWhitespaceLength, + null + ) + ); + } + + assignmentSites.Add(new Tuple( + leftExtent, + eq + )); + } + if (assignmentSites.Count > 0) { - groups.AddRange(GetCommandElementExtentGroups(configAst)); + yield return assignmentSites; } } + } - // it is probably much easier have a hashtable writer that formats the hashtable and writes it - // but it makes handling comments hard. So we need to use this approach. - - // This is how the algorithm actually works: - // if each key value pair are on a separate line - // find all the assignment operators - // if all the assignment operators are aligned (check the column number of each assignment operator) - // skip - // else - // find the distance between the assignment operators and their corresponding LHS - // find the longest left expression - // make sure all the assignment operators are in the same column as that of the longest left hand. - foreach (var extentTuples in groups) + /// + /// Parse an enum's members into a list of tuples which are later used to + /// verify and correct alignment of assignment operators. + /// + /// The enum TypeDefinitionAst to parse. + /// + /// A list of tuples, where each tuple is a (lhsTokenExtent, equalsExtent) + /// pair representing the extent of the token immediately before the '=' + /// (effectively the member name) and the extent of the '=' itself. + /// Implicitly skips line continuations. + /// + private List> ParseEnums( + TypeDefinitionAst enumTypeDefAst + ) + { + var assignmentSites = new List>(); + if (enumTypeDefAst == null) { return assignmentSites; } + + // Ensure we're only processing enums + if (!enumTypeDefAst.IsEnum) { return assignmentSites; } + + // Enumerate Enum Members that are PropertyMemberAst + foreach ( + var member in enumTypeDefAst.Members.Where( + m => m is PropertyMemberAst + ).Cast() + ) { - if (!HasPropertiesOnSeparateLines(extentTuples)) + + // Enums can have members with or without explicit values. + + // If InitialValue is null, this member has no explicit + // value and so should have no equals sign. + if (member.InitialValue == null) { + if (!IncludeValuelessEnumMembers) + { + continue; + } + + if (member.Extent.StartLineNumber != member.Extent.EndLineNumber) + { + // This member spans multiple lines - skip it + continue; + } + + // We include this member in the alignment check, but + // with a null equalsExtent. This will be ignored in + // CheckAlignment, but will ensure that this member + // is included in the calculation of the target column. + assignmentSites.Add(new Tuple( + member.Extent, + null + )); continue; } - if (extentTuples == null - || extentTuples.Count == 0 - || !extentTuples.All(t => t.Item1.StartLineNumber == t.Item2.EndLineNumber)) + // If the assignmentOperator dictionary has no entry for the + // line of the member name - skip this member; it should + // have an explicit value, so must have an equals sign. + // It's possible that the equals sign is on a different + // line thanks to line continuations (`). We skip such + // members. + if (!assignmentOperatorIndicesByLine.ContainsKey(member.Extent.StartLineNumber)) { continue; } - var expectedStartColumnNumber = extentTuples.Max(x => x.Item1.EndColumnNumber) + 1; - foreach (var extentTuple in extentTuples) + // Next we need to find the location of the equals sign for this + // member. We know the line it should be on. We can + // search all of the equals signs on that line. + // + // Unlike hashtables, we don't have an extent for the LHS and + // RHS of the member. We have the extent of the entire + // member, the name of the member, and the extent of the + // InitialValue (RHS). We can use these to find the equals + // sign. We know the equals sign must be after the + // member name, and before the InitialValue. + + int equalsTokenIndex = -1; + foreach (var index in assignmentOperatorIndicesByLine[member.Extent.StartLineNumber]) { - if (extentTuple.Item2.StartColumnNumber != expectedStartColumnNumber) + if (Helper.Instance.Tokens[index].Extent.StartOffset >= (member.Extent.StartColumnNumber + member.Name.Length) && + Helper.Instance.Tokens[index].Extent.EndOffset < member.InitialValue.Extent.StartOffset + ) { - yield return new DiagnosticRecord( - GetError(), - extentTuple.Item2, - GetName(), - GetDiagnosticSeverity(), - extentTuple.Item1.File, - null, - GetHashtableCorrections(extentTuple, expectedStartColumnNumber).ToList()); + equalsTokenIndex = index; + break; } } - } - } - private List>> GetCommandElementExtentGroups(Ast configAst) - { - var result = new List>>(); - var commandAstGroups = GetCommandElementGroups(configAst); - foreach (var commandAstGroup in commandAstGroups) - { - var list = new List>(); - foreach (var commandAst in commandAstGroup) + // If we didn't find the equals sign - skip, it's likely on a + // different line due to line continuations. + if (equalsTokenIndex == -1) { - var elems = commandAst.CommandElements; - list.Add(new Tuple(elems[0].Extent, elems[1].Extent)); + continue; } - result.Add(list); - } + // Normally a member with a value looks like: + // + // Name = Value + // + // But the below is also valid: + // + // Name <#Inline Comment#> = Value + // + // We can still use this member for alignment - we simply treat + // the end of the token before the equals sign as the Left-Hand + // Side (LHS) of the assignment. We expose a user setting for + // this. + // If the user has not chosen to align such members and the + // token before the equals sign is a comment, we skip this + // member. + if (!AlignEnumMemberWithInterveningComment && + Helper.Instance.Tokens[equalsTokenIndex - 1].Kind == TokenKind.Comment + ) + { + continue; + } - return result; + assignmentSites.Add(new Tuple( + Helper.Instance.Tokens[equalsTokenIndex - 1].Extent, + Helper.Instance.Tokens[equalsTokenIndex].Extent + )); + } + return assignmentSites; } - private List> GetCommandElementGroups(Ast configAst) + /// + /// Check alignment of assignment operators in the provided list of + /// (lhsTokenExtent, equalsExtent) tuples, and return diagnostics for + /// any misalignments found. + /// + /// From the lhsTokenExtent, we can determine the target column for + /// alignment (the column after the longest key). We then compare the + /// equalsExtent's start column to the target column, and if they + /// differ, we have a misalignment and return a diagnostic. + /// + /// + /// A list of tuples, where each tuple is a (lhsTokenExtent, equalsExtent) + /// pair representing the extent of the token immediately before the '=' + /// and the extent of the '=' itself. + /// Only includes pairs where an '=' token is found on the same line as + /// the key. + /// + /// + /// An enumerable of DiagnosticRecords, one for each misaligned + /// assignment operator found. + /// + private IEnumerable CheckAlignment( + List> assignmentSites + ) { - var result = new List>(); - var astsFound = configAst.FindAll(ast => IsPropertyValueCommandAst(ast), true); - if (astsFound == null) + if (assignmentSites == null || assignmentSites.Count == 0) { - return result; + yield break; } - var parentChildrenGroup = from ast in astsFound - select (CommandAst)ast into commandAst - group commandAst by commandAst.Parent.Parent; // parent is pipeline and pipeline's parent is namedblockast - foreach (var group in parentChildrenGroup) + // Filter out everything from assignmentSites that is not on + // it's own line. Do this by grouping by the start line number + // of the lhsTokenExtent, and only keeping groups with a count + // of 1. + assignmentSites = assignmentSites + .GroupBy(t => t.Item1.StartLineNumber) + .Where(g => g.Count() == 1) + .Select(g => g.First()) + .ToList(); + + // If, after filtering, we have no assignment sites, exit + if (assignmentSites == null || assignmentSites.Count == 0) { - result.Add(group.ToList()); + yield break; } - return result; + // The target column for this hashtable is longest key plus one + // space. + var targetColumn = assignmentSites + .Max(t => t.Item1.EndColumnNumber) + 1; + + // Check each element of the hashtable to see if it's aligned + foreach (var site in assignmentSites) + { + // If the equalsExtent is null, this is a member without + // an explicit value. We include such members in the + // calculation of the target column, but we don't + // generate diagnostics for them. + if (site.Item2 == null) + { + continue; + } + + // If the equals sign is already at the target column, + // no diagnostic is needed. + if (site.Item2.StartColumnNumber == targetColumn) + { + continue; + } + + yield return new DiagnosticRecord( + string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementError), + site.Item2, + GetName(), + DiagnosticSeverity.Warning, + site.Item1.File, + null, + GetCorrectionExtent( + site.Item1, + site.Item2, + targetColumn + ) + ); + } } - private bool IsPropertyValueCommandAst(Ast ast) + /// + /// Generate the correction extent to align the assignment operator + /// to the target column. + /// + /// The extent of the token immediately before the '=' + /// The extent of the '=' token + /// The target column to align to + /// An enumerable of CorrectionExtents, one for each correction + private List GetCorrectionExtent( + IScriptExtent lhsExtent, + IScriptExtent equalsExtent, + int targetColumn + ) { - var commandAst = ast as CommandAst; - return commandAst != null - && commandAst.CommandElements.Count() == 3 - && commandAst.CommandElements[1].Extent.Text.Equals("="); + // We generate a correction extent which replaces the text between + // the end of the lhs and the start of the equals sign with the + // appropriate number of spaces to align the equals sign to the + // target column. + return new List + { + new CorrectionExtent( + lhsExtent.EndLineNumber, + equalsExtent.StartLineNumber, + lhsExtent.EndColumnNumber, + equalsExtent.StartColumnNumber, + new string(' ', targetColumn - lhsExtent.EndColumnNumber), + string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementError) + ) + }; } - private IEnumerable GetHashtableCorrections( - Tuple extentTuple, - int expectedStartColumnNumber) + /// + /// Retrieves the common name of this rule. + /// + public override string GetCommonName() { - var equalExtent = extentTuple.Item2; - var lhsExtent = extentTuple.Item1; - var columnDiff = expectedStartColumnNumber - equalExtent.StartColumnNumber; - yield return new CorrectionExtent( - lhsExtent.EndLineNumber, - equalExtent.StartLineNumber, - lhsExtent.EndColumnNumber, - equalExtent.StartColumnNumber, - new String(whitespaceChar, expectedStartColumnNumber - lhsExtent.EndColumnNumber), - GetError()); + return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementCommonName); } - private string GetError() + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() { - return String.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementError); + return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementDescription); } - private static List> GetExtents( - TokenOperations tokenOps, - HashtableAst hashtableAst) + /// + /// Retrieves the name of this rule. + /// + public override string GetName() { - var nodeTuples = new List>(); - foreach (var kvp in hashtableAst.KeyValuePairs) - { - var keyStartOffset = kvp.Item1.Extent.StartOffset; - bool keyStartOffSetReached = false; - var keyTokenNode = tokenOps.GetTokenNodes( - token => - { - if (keyStartOffSetReached) - { - return token.Kind == TokenKind.Equals; - } - if (token.Extent.StartOffset == keyStartOffset) - { - keyStartOffSetReached = true; - } - return false; - }).FirstOrDefault(); - if (keyTokenNode == null || keyTokenNode.Value == null) - { - continue; - } - var assignmentToken = keyTokenNode.Value.Extent; - - nodeTuples.Add(new Tuple( - kvp.Item1.Extent, assignmentToken)); - } + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.AlignAssignmentStatementName); + } - return nodeTuples; + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; } - private bool HasPropertiesOnSeparateLines(IEnumerable> tuples) + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() { - if (tuples.Count() == 1) - { - // If the hashtable has just a single key-value pair, it does not have properties on separate lines - return false; - } - var lines = new HashSet(); - foreach (var kvp in tuples) - { - if (lines.Contains(kvp.Item1.StartLineNumber)) - { - return false; - } - else - { - lines.Add(kvp.Item1.StartLineNumber); - } - } + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } - return true; + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; } } } diff --git a/Tests/Rules/AlignAssignmentStatement.tests.ps1 b/Tests/Rules/AlignAssignmentStatement.tests.ps1 index f252cccb5..1262acdf8 100644 --- a/Tests/Rules/AlignAssignmentStatement.tests.ps1 +++ b/Tests/Rules/AlignAssignmentStatement.tests.ps1 @@ -2,162 +2,956 @@ # Licensed under the MIT License. BeforeAll { - $testRootDirectory = Split-Path -Parent $PSScriptRoot - Import-Module (Join-Path $testRootDirectory "PSScriptAnalyzerTestHelper.psm1") + function New-AlignAssignmentSettings { + [OutputType([hashtable])] + [CmdletBinding()] + param( + [Parameter()] + [bool] + $CheckHashtable = $false, + [Parameter()] + [bool] + $AlignHashtableKvpWithInterveningComment = $false, + [Parameter()] + [bool] + $CheckEnums = $false, + [Parameter()] + [bool] + $IncludeValuelessEnumMembers = $false, + [Parameter()] + [bool] + $AlignEnumMemberWithInterveningComment = $false + ) + return @{ + IncludeRules = @('PSAlignAssignmentStatement') + Rules = @{ + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $CheckHashtable + AlignHashtableKvpWithInterveningComment = $AlignHashtableKvpWithInterveningComment + CheckEnums = $CheckEnums + IncludeValuelessEnumMembers = $IncludeValuelessEnumMembers + AlignEnumMemberWithInterveningComment = $AlignEnumMemberWithInterveningComment + } + } + } + } - $ruleConfiguration = @{ - Enable = $true - CheckHashtable = $true + function Get-NonParseDiagnostics { + [OutputType([object[]])] + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [object[]] + $Diagnostics + ) + process { + $Diagnostics | Where-Object { + $_.RuleName -eq 'PSAlignAssignmentStatement' + } + } } - $settings = @{ - IncludeRules = @("PSAlignAssignmentStatement") - Rules = @{ - PSAlignAssignmentStatement = $ruleConfiguration + function Apply-Corrections { + [OutputType([string])] + [CmdletBinding()] + param( + [string] + $Original, + [object[]] + $Diagnostics + ) + # Note: This only works to apply the correction extents because all of + # our corrections are simple, single line operations. + $lines = $Original -split "`n" + foreach ($Diagnostic in $Diagnostics) { + if (-not $Diagnostic.SuggestedCorrections) { + continue + } + foreach ($extent in $Diagnostic.SuggestedCorrections) { + $lineIndex = $extent.StartLineNumber - 1 + $prefix = $lines[$lineIndex].Substring( + 0, $extent.StartColumnNumber - 1 + ) + $suffix = $lines[$lineIndex].Substring( + $extent.EndColumnNumber - 1 + ) + $lines[$lineIndex] = $prefix + $extent.Text + $suffix + + } } + return ($lines -join "`n") + } +} + +Describe 'AlignAssignmentStatement' { + + Context 'When checking Hashtables is disabled' { + + It 'Should not find violations in mis-aligned hashtables' { + $def = @' +@{ + 'Key' = 'Value' + 'LongerKey' = 'Value' +} +'@ + $settings = New-AlignAssignmentSettings + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + + } + + It 'Should not find violations in DSC configuration blocks' { + $def = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name = '"RSAT"' + } + } +} +'@ + $settings = New-AlignAssignmentSettings + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + + } -Skip:($IsLinux -or $IsMacOS) + } + + Context 'When Hashtable checking is enabled' { + + It 'Should not find violations in empty single-line hashtable' { + $def = '@{}' + + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + } + + It 'Should not find violations in empty multi-line hashtable' { + $def = @' +@{ + } +'@ + + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + } + + It 'Should not find violation in aligned, single-line, single-kvp hashtable' { + $def = '@{"Key" = "Value"}' + + $settings = New-AlignAssignmentSettings -CheckHashtable $true -Describe "AlignAssignmentStatement" { - Context "When assignment statements are in hashtable" { - It "Should find violation when assignment statements are not aligned (whitespace needs to be added)" { + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -BeNullOrEmpty + } + + It 'Should find violation in mis-aligned, single-line, single-kvp hashtable' { + $def = '@{"Key" = "Value"}' + $expected = '@{"Key" = "Value"}' + + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + + $corrected | Should -BeExactly $expected + } + + It 'Should not find violations in mis-aligned hashtable with multiple kvp on a single line' { + $def = '@{"Key1" = "Value1";"Key2"="Value2"}' + + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -BeNullOrEmpty + } + + It 'Should not find violations in well aligned, multi-line, multi-kvp hashtable' { $def = @' -$hashtable = @{ - property1 = "value" - anotherProperty = "another value" +@{ + 'Key1' = 'Value1' + 'Key2' = 'Value2' + 'Key3' = 'Value3' } '@ - # Expected output after correction should be the following - # $hashtable = @{ - # property1 = "value" - # anotherProperty = "another value" - # } + $settings = New-AlignAssignmentSettings -CheckHashtable $true - $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings - $violations.Count | Should -Be 1 - Test-CorrectionExtentFromContent $def $violations 1 ' ' ' ' + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty } - It "Should find violation when assignment statements are not aligned (whitespace needs to be removed)" { + It 'Should find violations in mis-aligned, multi-line, multi-kvp hashtable' { $def = @' -$hashtable = @{ - property1 = "value" - anotherProperty = "another value" +@{ + 'Key1'= 'Value1' + 'Key12' = 'Value2' + 'Key123' = 'Value3' +} +'@ + + $expected = @' +@{ + 'Key1' = 'Value1' + 'Key12' = 'Value2' + 'Key123' = 'Value3' } '@ - # Expected output should be the following - # $hashtable = @{ - # property1 = "value" - # anotherProperty = "another value" - # } + $settings = New-AlignAssignmentSettings -CheckHashtable $true - $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings - $violations.Count | Should -Be 1 - Test-CorrectionExtentFromContent $def $violations 1 ' ' ' ' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 2 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected } - It "Should not crash if property name reaches further to the right than the longest property name (regression test for issue 1067)" { + It 'Should ignore lines with intervening comments when AlignHashtableKvpWithInterveningComment is false' { $def = @' -$hashtable = @{ property1 = "value" - anotherProperty = "another value" +@{ + 'Key1' <#comment#>= 'Value1' + 'Key12' = 'Value2' + 'Key123' = 'Value3' +} +'@ + + $expected = @' +@{ + 'Key1' <#comment#>= 'Value1' + 'Key12' = 'Value2' + 'Key123' = 'Value3' } '@ - $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings -ErrorAction Stop | Get-Count | Should -Be 0 + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected } - It "Should ignore if a hashtable is empty" { + It 'Should align lines with intervening comments when AlignHashtableKvpWithInterveningComment is true' { $def = @' -$x = @{ } +@{ + 'Key1' <#comment#>= 'Value1' + 'Key12' = 'Value2' + 'Key123' = 'Value3' +} +'@ + + $expected = @' +@{ + 'Key1' <#comment#> = 'Value1' + 'Key12' = 'Value2' + 'Key123' = 'Value3' +} '@ - Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Get-Count | Should -Be 0 + $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 3 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected } - It "Should ignore if a hashtable has a single key-value pair on a single line" { + It 'Should not find violations when intervening comment is already aligned and AlignHashtableKvpWithInterveningComment is true' { $def = @' -$x = @{ 'key'="value" } +@{ + 'Key1' <#comment#> = 'Value1' + 'Key2' = 'Value2' + 'Key3' = 'Value3' +} '@ - Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Get-Count | Should -Be 0 + $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty } - It "Should ignore if a hashtable has a single key-value pair across multiple lines" { + It 'Should not find violations when intervening comment is on right of equals sign and AlignHashtableKvpWithInterveningComment is true' { $def = @' -$x = @{ - 'key'="value" +@{ + 'Key1' = <#comment#> 'Value1' + 'Key2' = 'Value2' + 'Key3' = 'Value3' } '@ - Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Get-Count | Should -Be 0 + $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty } - It "Should ignore if a hashtable has multiple key-value pairs on a single line" { + It 'Should ignore kvp with a line continuation between key and equals sign' { $def = @' -$x = @{ 'key'="value"; 'key2'="value2"; 'key3WithLongerName'="value3" } +@{ + 'LongerKey' ` + = <#comment#> 'Value1' + 'Key1' = 'Value2' + 'Key12' = 'Value3' +} +'@ + + $expected = @' +@{ + 'LongerKey' ` + = <#comment#> 'Value1' + 'Key1' = 'Value2' + 'Key12' = 'Value3' +} '@ - Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Get-Count | Should -Be 0 + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected } + It 'Should correctly align kvp when key is a string containing an equals sign' { + $def = @' +@{ + 'key1=5' = 'Value1' + 'Key1' = 'Value2' + 'Key12' = 'Value3' +} +'@ + + $expected = @' +@{ + 'key1=5' = 'Value1' + 'Key1' = 'Value2' + 'Key12' = 'Value3' +} +'@ + + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 3 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should correctly align kvp when key is an expression containing an assignment' { + # Note: `($key='key1')` defines the variable `$key` and sets it's + # value to 'key1'. The entire expression evaluates to 'key1' + # which is then used as the hashtable key. So the first key + # at runtime is equal to the string 'key1'. + $def = @' +@{ + ($key='key1') = 'Value1' + 'Key2' = 'Value2' + 'Key3' = 'Value3' +} +'@ + + $expected = @' +@{ + ($key='key1') = 'Value1' + 'Key2' = 'Value2' + 'Key3' = 'Value3' +} +'@ + + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 3 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should correctly align hashtables independantly when nested' { + $def = @' +@{ + 'key1' = 5 + 'key12' = @{ + 'nestedKey1' = 'Value1' + 'nestedKey12'= 'Value2' + 'nestedKey123'= @{ + 'superNestedKey1' = 'Value1' + 'superNestedKey12'='Value2' + } + } + 'key123' = 'Value3' +} +'@ + + $expected = @' +@{ + 'key1' = 5 + 'key12' = @{ + 'nestedKey1' = 'Value1' + 'nestedKey12' = 'Value2' + 'nestedKey123' = @{ + 'superNestedKey1' = 'Value1' + 'superNestedKey12' ='Value2' + } } + 'key123' = 'Value3' +} +'@ - Context "When assignment statements are in DSC Configuration" { - It "Should find violations when assignment statements are not aligned" -skip:($IsLinux -or $IsMacOS) { + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 8 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should not find violations in aligned DSC configuration blocks' { $def = @' -Configuration MyDscConfiguration { +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name = '"RSAT"' + } + } +} +'@ + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty - param( - [string[]]$ComputerName="localhost" - ) - Node $ComputerName { - WindowsFeature MyFeatureInstance { - Ensure = "Present" - Name = "RSAT" + } -Skip:($IsLinux -or $IsMacOS) + + It 'Should find violations in mis-aligned DSC configuration blocks' { + $def = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name = '"RSAT"' } - WindowsFeature My2ndFeatureInstance { - Ensure = "Present" - Name = "Bitlocker" + } +} +'@ + + $expected = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name = '"RSAT"' } } } '@ - Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Get-Count | Should -Be 2 + + $settings = New-AlignAssignmentSettings -CheckHashtable $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + + } -Skip:($IsLinux -or $IsMacOS) + + It 'Should ignore lines in DSC configuration blocks with intervening comments when AlignHashtableKvpWithInterveningComment is false' { + $def = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name <#asdasd#>= '"RSAT"' + Other = 'Value' } } +} +'@ + + $expected = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name <#asdasd#>= '"RSAT"' + Other = 'Value' + } + } +} +'@ - Context "When assignment statements are in DSC Configuration that has parse errors" { - It "Should find violations when assignment statements are not aligned" -skip:($IsLinux -or $IsMacOS) { + $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $false + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } -Skip:($IsLinux -or $IsMacOS) + + It 'Should align lines in DSC configuration blocks with intervening comments when AlignHashtableKvpWithInterveningComment is true' { $def = @' -Configuration Sample_ChangeDescriptionAndPermissions -{ - Import-DscResource -Module NonExistentModule - # A Configuration block can have zero or more Node blocks - Node $NodeName - { - # Next, specify one or more resource blocks +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name <#asdasd#>= '"RSAT"' + Other = 'Value' + } + } +} +'@ + + $expected = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name <#asdasd#> = '"RSAT"' + Other = 'Value' + } + } +} +'@ + + $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 3 - NonExistentModule MySMBShare - { - Ensure = "Present" - Name = "MyShare" - Path = "C:\Demo\Temp" - ReadAccess = "author" - FullAccess = "some other author" - Description = "This is an updated description for this share" + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } -Skip:($IsLinux -or $IsMacOS) + + It 'Should ignore lines with a line continuation in DSC configuration blocks' { + $def = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name ` + = '"RSAT"' + Other = 'Value' } } } '@ - # This invocation will throw parse error caused by "Undefined DSC resource" because - # NonExistentModule is not really avaiable to load. Therefore we set erroraction to - # SilentlyContinue - Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings -ErrorAction SilentlyContinue | - Where-Object { $_.Severity -ne "ParseError" } | - Get-Count | - Should -Be 4 + + $expected = @' +Configuration C1 { + Node localhost { + NonExistentResource X { + Ensure = '"Present"' + Name ` + = '"RSAT"' + Other = 'Value' } } } +'@ + + $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $false + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } -Skip:($IsLinux -or $IsMacOS) + + } + + Context 'When Enum checking is disabled' { + + It 'Should not find violations in mis-aligned enums' { + $def = @' +enum E1 { + Short = 1 + Longer = 2 + Longest = 3 +} +'@ + $settings = New-AlignAssignmentSettings + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + + } + + } + + Context 'When Enum checking is enabled' { + + It 'Should not find violations in empty single-line enum' { + $def = 'enum E1 {}' + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + } + + It 'Should not find violations in empty multi-line enum' { + $def = @' +enum E1 { + +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + } + + It 'Should not find violations in single-member, valueless, single-line enum' { + $def = 'enum E1 { Member }' + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + } + + It 'Should not find violations in aligned single-member, explicitly valued, single-line enum' { + $def = 'enum E1 { Member = 1 }' + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics | + Should -BeNullOrEmpty + } + + It 'Should find violations in mis-aligned single-member, explicitly valued, single-line enum' { + $def = 'enum E1 { Member = 1 }' + + $expected = 'enum E1 { Member = 1 }' + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should find violations in mis-aligned single-member, explicitly valued, multi-line enum' { + $def = @' +enum E1 { + Member = 1 +} +'@ + + $expected = @' +enum E1 { + Member = 1 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should not find violations in aligned, multi-member enum' { + $def = @' +enum E1 { + Member1 = 1 + Member2 = 2 + Member3 = 3 + Member4 = 4 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -BeNullOrEmpty + } + + It 'Should find violations in mis-aligned, multi-member enum' { + $def = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 = 3 + Member1234 = 4 +} +'@ + + $expected = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 = 3 + Member1234 = 4 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 3 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should find violations in mis-aligned, multi-member, mixed-valued enum' { + $def = @' +enum E1 { + Member1 = 1 + Member12 + Member123 = 3 + Member1234 +} +'@ + + $expected = @' +enum E1 { + Member1 = 1 + Member12 + Member123 = 3 + Member1234 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 1 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should ignore lines with intervening comments when AlignEnumMemberWithInterveningComment is false' { + $def = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 <#Comment#>= 3 + Member1234 = 4 +} +'@ + + $expected = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 <#Comment#>= 3 + Member1234 = 4 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $false + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 2 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should align lines with intervening comments when AlignHashtableKvpWithInterveningComment is true' { + $def = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 <#Comment#>= 3 + Member1234 = 4 +} +'@ + + $expected = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 <#Comment#> = 3 + Member1234 = 4 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 4 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should not find violations when intervening comment is already aligned and AlignEnumMemberWithInterveningComment is true' { + $def = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 <#Comment#> = 3 + Member1234 = 4 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -BeNullOrEmpty + } + + It 'Should not find violations when intervening comment is on right of equals sign and AlignEnumMemberWithInterveningComment is true' { + $def = @' +enum E1 { + Member1 = 1 + Member12 = 2 + Member123 = <#Comment#> 3 + Member1234 = 4 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -BeNullOrEmpty + } + + It 'Should ignore member with a line continuation between name and equals sign' { + $def = @' +enum E1 { + Member1 = 1 + Member12 ` + = 2 + Member123 = 3 + Member1234 = 4 +} +'@ + + $expected = @' +enum E1 { + Member1 = 1 + Member12 ` + = 2 + Member123 = 3 + Member1234 = 4 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 2 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should use valueless members for alignment when IncludeValuelessEnumMembers is true' { + $def = @' +enum E1 { + Member1 = 1 + Member12 + Member123 = 3 + Member1234 +} +'@ + + $expected = @' +enum E1 { + Member1 = 1 + Member12 + Member123 = 3 + Member1234 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true -IncludeValuelessEnumMembers $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -HaveCount 2 + + $corrected = Apply-Corrections -Original $def -Diagnostics $violations + $corrected | Should -BeExactly $expected + } + + It 'Should not find violations where all members are valueless and IncludeValuelessEnumMembers is true' { + $def = @' +enum E1 { + Member1 + Member12 + Member123 + Member1234 +} +'@ + + $settings = New-AlignAssignmentSettings -CheckEnums $true -IncludeValuelessEnumMembers $true + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | + Get-NonParseDiagnostics + $violations | Should -BeNullOrEmpty + } + + } + +} \ No newline at end of file diff --git a/docs/Rules/AlignAssignmentStatement.md b/docs/Rules/AlignAssignmentStatement.md index c2709ac1a..28ca9f47f 100644 --- a/docs/Rules/AlignAssignmentStatement.md +++ b/docs/Rules/AlignAssignmentStatement.md @@ -10,38 +10,55 @@ title: AlignAssignmentStatement ## Description -Consecutive assignment statements are more readable if they are aligned. By aligned, we imply that -the `equal` sign for all the assignment statements should be in the same column. +Consecutive assignment statements are more readable when they're aligned. +Assignments are considered aligned when their `equals` signs line up vertically. -The rule looks for key (property) value pairs in a hashtable (DSC configuration) to check if they -are aligned or not. Consider the following example in which the key value pairs are not aligned. +This rule looks at the key-value pairs in hashtables (including DSC +configurations) as well as enum definitions. + +Consider the following example which has a hashtable and enum which are not +aligned. ```powershell $hashtable = @{ - property1 = 'value' + property = 'value' anotherProperty = 'another value' } + +enum Enum { + member = 1 + anotherMember = 2 +} ``` Alignment in this case would look like the following. ```powershell $hashtable = @{ - property1 = 'value' + property = 'value' anotherProperty = 'another value' } + +enum Enum { + member = 1 + anotherMember = 2 +} ``` -The rule ignores hashtables in which the assignment statements are on the same line. For example, -the rule ignores `$h = {a = 1; b = 2}`. +The rule ignores any assignments within hashtables and enums which are on the +same line as others. For example, the rule ignores `$h = @{a = 1; b = 2}`. ## Configuration ```powershell Rules = @{ PSAlignAssignmentStatement = @{ - Enable = $true - CheckHashtable = $true + Enable = $true + CheckHashtable = $true + AlignHashtableKvpWithInterveningComment = $true + CheckEnum = $true + AlignEnumMemberWithInterveningComment = $true + IncludeValuelessEnumMembers = $true } } ``` @@ -52,8 +69,123 @@ Rules = @{ Enable or disable the rule during ScriptAnalyzer invocation. -#### CheckHashtable: bool (Default value is `$false`) +#### CheckHashtable: bool (Default value is `$true`) + +Enforce alignment of assignment statements in a hashtable and in a DSC +Configuration. There is only one setting for hashtable and DSC configuration +because the property value pairs in a DSC configuration are parsed as key-value +pairs of a hashtable. + +#### AlignHashtableKvpWithInterveningComment: bool (Default value is `$true`) + +Include key-value pairs in the alignment that have an intervening comment - that +is to say a comment between the key name and the equals sign. + +Consider the following: + +```powershell +$hashtable = @{ + property = 'value' + anotherProperty <#A Comment#> = 'another value' + anotherDifferentProperty = 'yet another value' +} +``` + +With this setting disabled, the line with the comment is ignored, and it would +be aligned like so: + +```powershell +$hashtable = @{ + property = 'value' + anotherProperty <#A Comment#> = 'another value' + anotherDifferentProperty = 'yet another value' +} +``` + +With it enabled, the comment line is included in alignment: + +```powershell +$hashtable = @{ + property = 'value' + anotherProperty <#A Comment#> = 'another value' + anotherDifferentProperty = 'yet another value' +} +``` + +#### CheckEnum: bool (Default value is `$true`) + +Enforce alignment of assignment statements of an Enum definition. + +#### AlignEnumMemberWithInterveningComment: bool (Default value is `$true`) + +Include enum members in the alignment that have an intervening comment - that +is to say a comment between the member name and the equals sign. -Enforce alignment of assignment statements in a hashtable and in a DSC Configuration. There is only -one switch for hasthable and DSC configuration because the property value pairs in a DSC -configuration are parsed as key-value pairs of a hashtable. +Consider the following: + +```powershell +enum Enum { + member = 1 + anotherMember <#A Comment#> = 2 + anotherDifferentMember = 3 +} +``` + +With this setting disabled, the line with the comment is ignored, and it would +be aligned like so: + +```powershell +enum Enum { + member = 1 + anotherMember <#A Comment#> = 2 + anotherDifferentMember = 3 +} +``` + +With it enabled, the comment line is included in alignment: + +```powershell +enum Enum { + member = 1 + anotherMember <#A Comment#> = 2 + anotherDifferentMember = 3 +} +``` + +#### IncludeValuelessEnumMembers: bool (Default value is `$true`) + +Include enum members in the alignment that don't have an initial value - that +is to say they don't have an equals sign. Enum's don't need to be given a value +when they're defined. + +Consider the following: + +```powershell +enum Enum { + member = 1 + anotherMember = 2 + anotherDifferentMember +} +``` + +With this setting disabled the third line which has no value is not considered +when choosing where to align assignments. It would be aligned like so: + +```powershell +enum Enum { + member = 1 + anotherMember = 2 + anotherDifferentMember +} +``` + +With it enabled, the valueless member is included in alignment as if it had a +value: + +```powershell +enum Enum { + member = 1 + anotherMember = 2 + anotherDifferentMember +} +``` \ No newline at end of file From f3e91348de260141dd927dc37a836ab6ef6482e3 Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:04:09 -0700 Subject: [PATCH 05/21] Dynamically count expected rules in GetScriptAnalyzerRule test (#2167) Replace hardcoded rule count with dynamic counting of [Export(typeof(I...Rule))] attributes in C# source files. This prevents the test from breaking every time a new rule is added. --- Tests/Engine/GetScriptAnalyzerRule.tests.ps1 | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index 8e433d071..422b585bf 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -63,8 +63,13 @@ Describe "Test Name parameters" { It "get Rules with no parameters supplied" { $defaultRules = Get-ScriptAnalyzerRule - $expectedNumRules = 72 - $defaultRules.Count | Should -Be $expectedNumRules + # Dynamically count the expected number of rules from source files + # by finding all C# files with [Export(typeof(I...Rule))] attributes + $rulesRoot = Resolve-Path "$PSScriptRoot/../../Rules" + $expectedNumRules = (Get-ChildItem -Path $rulesRoot -Filter '*.cs' -Recurse | + Select-String -Pattern 'Export\(typeof\s*\(I\w+Rule\)\)' | + Select-Object -ExpandProperty Path -Unique).Count + $defaultRules.Count | Should -Be $expectedNumRules } It "is a positional parameter" { From ed50b9d8b5443e365de7543395f3034192686fbf Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Wed, 18 Mar 2026 19:35:06 +0000 Subject: [PATCH 06/21] Add UseConsistentParameterSetName Rule (#2124) * Add UseConsistentParameterSetName Rule * Update GetScriptAnalyzerRule.tests.ps1 to fix test about number of rules * Make rule optional, disabled by default * Update tests to reflect need to enable rule * Update docs to refelct that the rule is not enabeld by default * Fix alignment of README markdown table * Update Rules/UseConsistentParameterSetName.cs Co-authored-by: Christoph Bergmeister * Update expected rule count to 73 for new UseConsistentParameterSetName rule --------- Co-authored-by: Christoph Bergmeister Co-authored-by: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> --- Rules/Strings.resx | 27 ++ Rules/UseConsistentParameterSetName.cs | 453 ++++++++++++++++++ .../UseConsistentParameterSetName.tests.ps1 | 399 +++++++++++++++ docs/Rules/README.md | 1 + docs/Rules/UseConsistentParameterSetName.md | 135 ++++++ 5 files changed, 1015 insertions(+) create mode 100644 Rules/UseConsistentParameterSetName.cs create mode 100644 Tests/Rules/UseConsistentParameterSetName.tests.ps1 create mode 100644 docs/Rules/UseConsistentParameterSetName.md diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 67f925ede..482cec69f 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1224,6 +1224,33 @@ AvoidUsingAllowUnencryptedAuthentication + + Use Consistent Parameter Set Name + + + Parameter set names are case-sensitive in PowerShell. This rule checks for case mismatches between DefaultParameterSetName and ParameterSetName values, case mismatches between different ParameterSetName values, and missing DefaultParameterSetName when parameter sets are used. + + + Param block uses parameter sets but does not specify a DefaultParameterSetName. Consider adding DefaultParameterSetName to the CmdletBinding attribute. + + + DefaultParameterSetName '{0}' does not match the case of ParameterSetName '{1}'. Parameter set names are case-sensitive. + + + ParameterSetName '{0}' does not match the case of '{1}'. Parameter set names are case-sensitive and should use consistent casing. + + + Parameter '{0}' is declared in parameter-set '{1}' multiple times. + + + Parameter set names should not contain new lines. + + + Rename ParameterSet '{0}' to '{1}'. + + + UseConsistentParameterSetName + Avoid reserved words as function names diff --git a/Rules/UseConsistentParameterSetName.cs b/Rules/UseConsistentParameterSetName.cs new file mode 100644 index 000000000..bf12f37e2 --- /dev/null +++ b/Rules/UseConsistentParameterSetName.cs @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Management.Automation.Language; +#if !CORECLR +using System.ComponentModel.Composition; +#endif +using System.Globalization; +using System.Linq; +using System.Management.Automation; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// UseConsistentParameterSetName: Check for case-sensitive parameter set + /// name mismatches, missing default parameter set names, and parameter set + /// names containing new lines. + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + public class UseConsistentParameterSetName : ConfigurableRule + { + + private const string AllParameterSetsName = "__AllParameterSets"; + + /// + /// AnalyzeScript: Check for parameter set name issues. + /// + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) + { + throw new ArgumentNullException(Strings.NullAstErrorMessage); + } + + var allParameterBlocks = ast + .FindAll(testAst => testAst is ParamBlockAst, true) + .Cast() + .Where(pb => pb.Parameters?.Count > 0); + + foreach (var paramBlock in allParameterBlocks) + { + // If the paramblock has no parameters, skip it + if (paramBlock.Parameters.Count == 0) + { + continue; + } + + // Get the CmdletBinding attribute and default parameter set name + // Or null if not present + var cmdletBindingAttr = Helper.Instance.GetCmdletBindingAttributeAst(paramBlock.Attributes); + var defaultParamSetName = GetNamedArgumentValue(cmdletBindingAttr, "DefaultParameterSetName"); + + // For each parameter block, build up a list of all the parameters + // and the parameter sets in which they appear. + List paramBlockInfo = new List(); + + foreach (var parameter in paramBlock.Parameters) + { + // If the parameter has no attributes, it is part of all + // parameter sets. We can ignore it for these checks. + if (parameter.Attributes.Count == 0) + { + continue; + } + + // For each parameter attribute a parameter has, extract + // the parameter set and add it to our knowledge of the + // param block. + foreach (var attribute in parameter.Attributes.Where(attr => attr is AttributeAst).Cast()) + { + if (string.Equals(attribute.TypeName?.Name, "Parameter", StringComparison.OrdinalIgnoreCase)) + { + var parameterSetName = GetNamedArgumentValue(attribute, "ParameterSetName", AllParameterSetsName); + paramBlockInfo.Add(new ParameterSetInfo(parameter.Name.VariablePath.UserPath, parameterSetName, attribute)); + } + } + } + + // We now have a picture of the parameters and parameterset + // usage of this paramblock. We can make each check. + + // Check 1: Default parameter set name + // ------------------------------------------------------------- + // If we have parameter sets in use and the CmdletBinding + // attribute, but no default specified, warn about this. + if (string.IsNullOrEmpty(defaultParamSetName) && + cmdletBindingAttr != null && + paramBlockInfo.Any(p => p.ParameterSetName != AllParameterSetsName) + ) + { + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameMissingDefaultError), + cmdletBindingAttr?.Extent ?? paramBlock.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName); + } + + // Check 2: Parameter Declared Multiple Times in Same Set + // ------------------------------------------------------------- + // If any parameter has more than one parameter attribute for + // the same parameterset, warn about each instance. + // Parameters cannot be declared multiple times in the same set. + // Calling a function that has a parameter declared multiple + // times in the same parameterset is a runtime exception - + // specifically a [System.Management.Automation.MetadataException] + // It'd be better to know before runtime. + // We use the same message text as the MetadataException for + // consistency + var duplicateAttributes = paramBlockInfo + .GroupBy(p => new { p.ParameterName, p.ParameterSetName }) + .Where(g => g.Count() > 1) + .SelectMany(g => g); + + foreach (var duplicate in duplicateAttributes) + { + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameMultipleDeclarationsError, + duplicate.ParameterName, + duplicate.ParameterSetName), + duplicate.ParameterAttributeAst.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName); + } + + // Check 3: Validate Default Parameter Set + // ------------------------------------------------------------- + // If a default parameter set is specified and matches one of + // the used parameter set names ignoring case, but not otherwise + // then we should warn about this + if (!string.IsNullOrEmpty(defaultParamSetName)) + { + // Look for an exact (case-sensitive) match + var exactMatch = paramBlockInfo + .FirstOrDefault(p => + string.Equals( + p.ParameterSetName, + defaultParamSetName, + StringComparison.Ordinal + ) + ); + + if (exactMatch == null) + { + // No exact match, look for a case-insensitive match + var caseInsensitiveMatch = paramBlockInfo + .FirstOrDefault(p => + string.Equals( + p.ParameterSetName, + defaultParamSetName, + StringComparison.OrdinalIgnoreCase + ) + ); + + if (caseInsensitiveMatch != null) + { + var defaultParameterSetNameExtents = GetDefaultParameterSetNameValueExtent(cmdletBindingAttr); + + // Emit a diagnostic for the first case-insensitive match + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameCaseMismatchDefaultError, + defaultParamSetName, + caseInsensitiveMatch.ParameterSetName), + defaultParameterSetNameExtents ?? cmdletBindingAttr?.Extent ?? paramBlock.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName); + } + } + } + + // Check 4: Parameter Set Name Consistency + // ------------------------------------------------------------- + // If a parameter set name is used in multiple places, it must + // be consistently used across all usages. This means the casing + // must match exactly. We should warn about any inconsistencies + // found. + var paramSetGroups = paramBlockInfo + .GroupBy(p => p.ParameterSetName, StringComparer.OrdinalIgnoreCase) + .Where(g => + g.Select(p => p.ParameterSetName) + .Distinct(StringComparer.Ordinal) + .Skip(1).Any() + ); + + foreach (var group in paramSetGroups) + { + // Take the first instance as the canonical casing + var canonical = group.First(); + foreach (var entry in group.Skip(1)) + { + if (!string.Equals( + entry.ParameterSetName, + canonical.ParameterSetName, + StringComparison.Ordinal + ) + ) + { + var parameterSetNameExtents = GetParameterSetNameValueExtent(entry.ParameterAttributeAst); + + if (parameterSetNameExtents != null) + { + var correction = new CorrectionExtent( + parameterSetNameExtents.StartLineNumber, + parameterSetNameExtents.EndLineNumber, + parameterSetNameExtents.StartColumnNumber, + parameterSetNameExtents.EndColumnNumber, + $"'{canonical.ParameterSetName}'", + fileName, + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameCaseMismatchSuggestedCorrectionDescription, + entry.ParameterSetName, + canonical.ParameterSetName + ) + ); + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameCaseMismatchParameterError, + entry.ParameterSetName, + canonical.ParameterSetName), + parameterSetNameExtents, + GetName(), + DiagnosticSeverity.Warning, + fileName, + null, + new List { correction }); + } + else + { + // If we couldn't find the parameter set name extents, we can't create a correction + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameCaseMismatchParameterError, + entry.ParameterSetName, + canonical.ParameterSetName), + entry.ParameterAttributeAst.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName); + } + } + } + } + + // Check 5: Parameter Set Names should not contain New Lines + // ------------------------------------------------------------- + // There is no practical purpose for parameterset names to + // contain a newline + foreach (var entry in paramBlockInfo) + { + if (entry.ParameterSetName.Contains('\n') || entry.ParameterSetName.Contains('\r')) + { + var parameterSetNameExtents = GetParameterSetNameValueExtent(entry.ParameterAttributeAst); + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameNewLineError), + parameterSetNameExtents ?? entry.ParameterAttributeAst.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName); + } + } + if (defaultParamSetName != null && + (defaultParamSetName.Contains('\n') || defaultParamSetName.Contains('\r'))) + { + // If the default parameter set name contains new lines, warn about it + var defaultParameterSetNameExtents = GetDefaultParameterSetNameValueExtent(cmdletBindingAttr); + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameNewLineError, + defaultParamSetName), + defaultParameterSetNameExtents ?? cmdletBindingAttr?.Extent ?? paramBlock.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName); + } + + } + } + + /// + /// Retrieves the value of a named argument from an AttributeAst's NamedArguments collection. + /// If the named argument is not found, returns the provided default value. + /// If the argument value is a constant, returns its string representation; otherwise, returns the argument's text. + /// + /// The AttributeAst to search for the named argument. + /// The name of the argument to look for (case-insensitive). + /// The value to return if the named argument is not found. Defaults to null. + /// + /// The value of the named argument as a string if found; otherwise, the default value. + /// + private static string GetNamedArgumentValue(AttributeAst attributeAst, string argumentName, string defaultValue = null) + { + if (attributeAst == null || attributeAst.NamedArguments == null) + { + return defaultValue; + } + + foreach (var namedArg in attributeAst.NamedArguments) + { + if (namedArg?.ArgumentName == null) continue; + + if (string.Equals(namedArg.ArgumentName, argumentName, StringComparison.OrdinalIgnoreCase)) + { + // Try to evaluate the argument value as a constant string + if (namedArg.Argument is ConstantExpressionAst constAst) + { + return constAst.Value?.ToString(); + } + // If not a constant, try to get the string representation + return namedArg.Argument.Extent.Text; + } + } + return defaultValue; + } + + /// + /// Finds the IScriptExtent of the value assigned to the ParameterSetName argument + /// in the given AttributeAst (if it is a [Parameter()] attribute). + /// Returns null if not found. + /// + /// The AttributeAst to search. + /// The IScriptExtent of the ParameterSetName value, or null if not found. + private static IScriptExtent GetParameterSetNameValueExtent(AttributeAst attributeAst) + { + return GetAttributeNamedArgumentValueExtent(attributeAst, "ParameterSetName", "Parameter"); + } + + /// + /// Finds the IScriptExtent of the value assigned to the DefaultParameterSetName argument + /// in the given AttributeAst (if it is a [CmdletBinding()] attribute). + /// Returns null if not found. + /// + /// The AttributeAst to search. + /// The IScriptExtent of the DefaultParameterSetName value, or null if not found. + private static IScriptExtent GetDefaultParameterSetNameValueExtent(AttributeAst attributeAst) + { + return GetAttributeNamedArgumentValueExtent(attributeAst, "DefaultParameterSetName", "CmdletBinding"); + } + + /// + /// Finds the IScriptExtent of the value of a named argument in the given AttributeAst. + /// Returns null if not found. + /// + /// The AttributeAst to search. + /// The name of the argument to find. + /// The expected type name of the attribute. i.e. Parameter (optional). + /// The IScriptExtent of the named argument value, or null if not found. + private static IScriptExtent GetAttributeNamedArgumentValueExtent(AttributeAst attributeAst, string argumentName, string expectedAttributeName = null) + { + if (attributeAst == null || attributeAst.NamedArguments == null) + return null; + + if (!string.IsNullOrEmpty(expectedAttributeName) && + !string.Equals( + attributeAst.TypeName?.Name, + expectedAttributeName, + StringComparison.OrdinalIgnoreCase) + ) + return null; + + foreach (var namedArg in attributeAst.NamedArguments) + { + if (string.Equals(namedArg.ArgumentName, argumentName, StringComparison.OrdinalIgnoreCase)) + { + return namedArg.Argument?.Extent; + } + } + return null; + } + + /// + /// Represents information about a parameter and its parameter set. + /// + private class ParameterSetInfo + { + public string ParameterName { get; } + public string ParameterSetName { get; } + public AttributeAst ParameterAttributeAst { get; } + + public ParameterSetInfo(string parameterName, string parameterSetName, AttributeAst parameterAttributeAst) + { + ParameterName = parameterName; + ParameterSetName = parameterSetName; + ParameterAttributeAst = parameterAttributeAst; + } + } + + /// + /// GetName: Retrieves the name of this rule. + /// + /// The name of this rule + public override string GetName() => string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.UseConsistentParameterSetNameName + ); + + /// + /// GetCommonName: Retrieves the common name of this rule. + /// + /// The common name of this rule + public override string GetCommonName() => string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameCommonName + ); + + /// + /// GetDescription: Retrieves the description of this rule. + /// + /// The description of this rule + public override string GetDescription() => string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameDescription + ); + + /// + /// Method: Retrieves the type of the rule: builtin, managed or module. + /// + public override SourceType GetSourceType() => SourceType.Builtin; + + /// + /// GetSeverity: Retrieves the severity of the rule: error, warning of information. + /// + /// + public override RuleSeverity GetSeverity() => RuleSeverity.Warning; + + /// + /// Method: Retrieves the module/assembly name the rule is from. + /// + public override string GetSourceName() => string.Format( + CultureInfo.CurrentCulture, Strings.SourceName + ); + } +} diff --git a/Tests/Rules/UseConsistentParameterSetName.tests.ps1 b/Tests/Rules/UseConsistentParameterSetName.tests.ps1 new file mode 100644 index 000000000..3ca229fb5 --- /dev/null +++ b/Tests/Rules/UseConsistentParameterSetName.tests.ps1 @@ -0,0 +1,399 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $ruleName = 'PSUseConsistentParameterSetName' + + $ruleSettings = @{ + Enable = $true + } + $settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = $ruleSettings } + } +} + +Describe "UseConsistentParameterSetName" { + Context "When there are case mismatch violations between DefaultParameterSetName and ParameterSetName" { + It "detects case mismatch between DefaultParameterSetName and ParameterSetName" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName='SetOne')] + param( + [Parameter(ParameterSetName='setone')] + [string]$Parameter1, + + [Parameter(ParameterSetName='SetTwo')] + [string]$Parameter2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].Severity | Should -Be 'Warning' + $violations[0].Message | Should -Match "DefaultParameterSetName 'SetOne' does not match the case of ParameterSetName 'setone'" + } + + It "detects multiple case mismatches with DefaultParameterSetName" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName='SetOne')] + param( + [Parameter(ParameterSetName='setone')] + [string]$Parameter1, + + [Parameter(ParameterSetName='SETONE')] + [string]$Parameter2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + $violations | ForEach-Object { $_.Severity | Should -Be 'Warning' } + } + } + + Context "When there are case mismatch violations between ParameterSetName values" { + It "detects case mismatch between different ParameterSetName values" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName='SetOne')] + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1, + + [Parameter(ParameterSetName='setone')] + [string]$Parameter2, + + [Parameter(ParameterSetName='SetTwo')] + [string]$Parameter3 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].Severity | Should -Be 'Warning' + $violations[0].Message | Should -Match "ParameterSetName 'setone' does not match the case of 'SetOne'" + } + + It "detects multiple case variations of the same parameter set name" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1, + + [Parameter(ParameterSetName='setone')] + [string]$Parameter2, + + [Parameter(ParameterSetName='SETONE')] + [string]$Parameter3 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 # Two mismatches with the first occurrence + $violations | ForEach-Object { $_.Severity | Should -Be 'Warning' } + } + } + + Context "When DefaultParameterSetName is missing" { + It "warns when parameter sets are used but DefaultParameterSetName is not specified" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding()] + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1, + + [Parameter(ParameterSetName='SetTwo')] + [string]$Parameter2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].Severity | Should -Be 'Warning' + $violations[0].Message | Should -Match "uses parameter sets but does not specify a DefaultParameterSetName" + } + } + + Context "When a parameter is declared multiple times in the same parameter set" { + It "detects duplicate parameter declarations in the same parameter set (explicit)" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ParameterSetName='SetOne')] + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + $violations | ForEach-Object { $_.Message | Should -Be "Parameter 'Parameter1' is declared in parameter-set 'SetOne' multiple times." } + } + + It "detects duplicate parameter declarations in the same parameter set (implicit via omitted ParameterSetName)" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter()] + [Parameter()] + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + $violations | ForEach-Object { $_.Message | Should -Be "Parameter 'Parameter1' is declared in parameter-set '__AllParameterSets' multiple times." } + } + + It "detects duplicate parameter declarations in explicit and implicit parameter sets" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ParameterSetName='__AllParameterSets')] + [Parameter()] + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + $violations | ForEach-Object { $_.Message | Should -Be "Parameter 'Parameter1' is declared in parameter-set '__AllParameterSets' multiple times." } + } + + + } + + Context "When ParameterSetNames contain inadvisable characters" { + It "detects ParameterSetName containing a new line" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ParameterSetName="Set`nOne")] + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].Message | Should -Match "should not contain new lines" + } + + It "detects ParameterSetName containing a carriage return" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ParameterSetName="Set`rOne")] + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].Message | Should -Match "should not contain new lines" + } + + It "detects ParameterSetName containing mixed newline types" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ParameterSetName="Set`r`nOne")] + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + } + + It "detects DefaultParameterSetName containing a new line" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName="Set`nOne")] + param( + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].Message | Should -Match "should not contain new lines" + } + + It "detects DefaultParameterSetName containing a carriage return" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName="Set`rOne")] + param( + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].Message | Should -Match "should not contain new lines" + } + + # Missing: DefaultParameterSetName with newlines + It "detects DefaultParameterSetName containing mixed newline types" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName="Set`r`nOne")] + param([string]$Parameter1) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 1 + } + + } + + Context "When there are no violations" { + It "does not flag functions without CmdletBinding" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It "does not flag functions without parameter sets" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding()] + param( + [string]$Parameter1, + [string]$Parameter2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It "does not flag when DefaultParameterSetName and ParameterSetName cases match exactly" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName='SetOne')] + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1, + + [Parameter(ParameterSetName='SetTwo')] + [string]$Parameter2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It "does not flag when all ParameterSetName cases match exactly" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName='SetOne')] + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1, + + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter2, + + [Parameter(ParameterSetName='SetTwo')] + [string]$Parameter3 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + # This could be a case where the function can be run without any parameters + # in the default set. + It "does not flag when DefaultParameterSetName doesn't match any ParameterSetName" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName='Default')] + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1, + + [Parameter(ParameterSetName='SetTwo')] + [string]$Parameter2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It "handles parameters without attributes correctly" { + $scriptDefinition = @' +function Test-Function { + [CmdletBinding(DefaultParameterSetName='SetOne')] + param( + [Parameter(ParameterSetName='SetOne')] + [string]$Parameter1, + + [string]$CommonParameter # No Parameter attribute + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + } + + Context "Real-world scenarios" { + It "handles complex parameter set definitions correctly" { + $scriptDefinition = @' +function Test-ComplexFunction { + [CmdletBinding(DefaultParameterSetName='ByName')] + param( + [Parameter(ParameterSetName='ByName', Mandatory)] + [string]$Name, + + [Parameter(ParameterSetName='ByName')] + [Parameter(ParameterSetName='ByID')] + [string]$ComputerName, + + [Parameter(ParameterSetName='ByID', Mandatory)] + [int]$ID + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It "detects case issues in complex scenarios" { + $scriptDefinition = @' +function Test-ComplexFunction { + [CmdletBinding(DefaultParameterSetName='ByName')] + param( + [Parameter(ParameterSetName='byname', Mandatory)] + [string]$Name, + + [Parameter(ParameterSetName='ByName')] + [Parameter(ParameterSetName='ByID')] + [string]$ComputerName, + + [Parameter(ParameterSetName='byid', Mandatory)] + [int]$ID + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 # 'byname' and 'byid' case mismatches + $violations | ForEach-Object { $_.Severity | Should -Be 'Warning' } + } + } +} diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 51858d0de..e4a298ef2 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -68,6 +68,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [UseCompatibleSyntax](./UseCompatibleSyntax.md) | Warning | No | Yes | | [UseCompatibleTypes](./UseCompatibleTypes.md) | Warning | No | Yes | | [UseConsistentIndentation](./UseConsistentIndentation.md) | Warning | No | Yes | +| [UseConsistentParameterSetName](./UseConsistentParameterSetName.md) | Warning | No | | | [UseConsistentParametersKind](./UseConsistentParametersKind.md) | Warning | No | Yes | | [UseConsistentWhitespace](./UseConsistentWhitespace.md) | Warning | No | Yes | | [UseCorrectCasing](./UseCorrectCasing.md) | Information | No | Yes | diff --git a/docs/Rules/UseConsistentParameterSetName.md b/docs/Rules/UseConsistentParameterSetName.md new file mode 100644 index 000000000..5ad33eb22 --- /dev/null +++ b/docs/Rules/UseConsistentParameterSetName.md @@ -0,0 +1,135 @@ +--- +description: Use consistent parameter set names and proper parameter set configuration. +ms.date: 08/19/2025 +ms.topic: reference +title: UseConsistentParameterSetName +--- + +# UseConsistentParameterSetName + +**Severity Level: Warning** + +## Description + +Parameter set names in PowerShell are case-sensitive, unlike most other PowerShell elements. This rule ensures consistent casing and proper configuration of parameter sets to avoid runtime errors and improve code clarity. + +The rule performs five different checks: + +1. **Missing DefaultParameterSetName** - Warns when parameter sets are used but no default is specified +2. **Multiple parameter declarations** - Detects when a parameter is declared multiple times in the same parameter set. This is ultimately a runtime exception - this check helps catch it sooner. +3. **Case mismatch between DefaultParameterSetName and ParameterSetName** - Ensures consistent casing +4. **Case mismatch between different ParameterSetName values** - Ensures all references to the same parameter set use identical casing +5. **Parameter set names containing newlines** - Warns against using newline characters in parameter set names + +> [!NOTE] +> This rule is not enabled by default. The user needs to enable it through settings. + +## How + +- Use a `DefaultParameterSetName` when defining multiple parameter sets +- Ensure consistent casing between `DefaultParameterSetName` and `ParameterSetName` values +- Use identical casing for all references to the same parameter set name +- Avoid declaring the same parameter multiple times in a single parameter set +- Do not use newline characters in parameter set names + +## Example + +### Wrong + +```powershell +# Missing DefaultParameterSetName +function Get-Data { + [CmdletBinding()] + param( + [Parameter(ParameterSetName='ByName')] + [string]$Name, + + [Parameter(ParameterSetName='ByID')] + [int]$ID + ) +} + +# Case mismatch between DefaultParameterSetName and ParameterSetName +function Get-Data { + [CmdletBinding(DefaultParameterSetName='ByName')] + param( + [Parameter(ParameterSetName='byname')] + [string]$Name, + + [Parameter(ParameterSetName='ByID')] + [int]$ID + ) +} + +# Inconsistent casing between ParameterSetName values +function Get-Data { + [CmdletBinding(DefaultParameterSetName='ByName')] + param( + [Parameter(ParameterSetName='ByName')] + [string]$Name, + + [Parameter(ParameterSetName='byname')] + [string]$DisplayName + ) +} + +# Multiple parameter declarations in same set +function Get-Data { + param( + [Parameter(ParameterSetName='ByName')] + [Parameter(ParameterSetName='ByName')] + [string]$Name + ) +} + +# Parameter set name with newline +function Get-Data { + param( + [Parameter(ParameterSetName="Set`nOne")] + [string]$Name + ) +} +``` + +### Correct + +```powershell +# Proper parameter set configuration +function Get-Data { + [CmdletBinding(DefaultParameterSetName='ByName')] + param( + [Parameter(ParameterSetName='ByName', Mandatory)] + [string]$Name, + + [Parameter(ParameterSetName='ByName')] + [Parameter(ParameterSetName='ByID')] + [string]$ComputerName, + + [Parameter(ParameterSetName='ByID', Mandatory)] + [int]$ID + ) +} +``` + +## Configuration + +```powershell +Rules = @{ + PSUseConsistentParameterSetName = @{ + Enable = $true + } +} +``` + +### Parameters + +- `Enable`: **bool** (Default value is `$false`) + + Enable or disable the rule during ScriptAnalyzer invocation. + +## Notes + +- Parameter set names are case-sensitive in PowerShell, making this different from most other PowerShell elements +- The first occurrence of a parameter set name in your code is treated as the canonical casing +- Parameters without [Parameter()] attributes are automatically part of all parameter sets +- It's a PowerShell best practice to always specify a DefaultParameterSetName when using parameter sets \ No newline at end of file From ac48fad089275bf17cc1dded7b4ddafd3555ed29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20R=C3=B8nnestad=20Birkeland?= <6450056+o-l-a-v@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:00:17 +0000 Subject: [PATCH 07/21] Added closing bracket test for formatting presets (#2161) * feat(tests): added closing bracket test for formatting presets * Update Tests/Rules/PlaceCloseBrace.tests.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Tests/Rules/PlaceCloseBrace.tests.ps1 | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Tests/Rules/PlaceCloseBrace.tests.ps1 b/Tests/Rules/PlaceCloseBrace.tests.ps1 index 85ccb8979..fb741ee2a 100644 --- a/Tests/Rules/PlaceCloseBrace.tests.ps1 +++ b/Tests/Rules/PlaceCloseBrace.tests.ps1 @@ -383,4 +383,49 @@ if ($true) { $violations.Count | Should -Be 0 } } + + Context "When formatting presets handle if/else" { + BeforeAll { + $AllmanDefinition = @" +if (`$true) +{ + 'yes' +} +else +{ + 'no' +} +"@ + $OTBSDefinition = @" +if (`$true) { + 'yes' +} else { + 'no' +} +"@ + $StroustrupDefinition = @" +if (`$true) { + 'yes' +} +else { + 'no' +} +"@ + } + + It "Allman should have all opening and closing braces on a new line" { + Invoke-Formatter -ScriptDefinition $OTBSDefinition -Settings 'CodeFormattingAllman' | Should -Be $AllmanDefinition + Invoke-Formatter -ScriptDefinition $StroustrupDefinition -Settings 'CodeFormattingAllman' | Should -Be $AllmanDefinition + } + + It "OTBS should place else on same line as the if closing bracket" { + Invoke-Formatter -ScriptDefinition $AllmanDefinition -Settings 'CodeFormattingOTBS' | Should -Be $OTBSDefinition + Invoke-Formatter -ScriptDefinition $StroustrupDefinition -Settings 'CodeFormattingOTBS' | Should -Be $OTBSDefinition + } + + It "Stroustrup should place else on a new line after the if closing bracket" { + Invoke-Formatter -ScriptDefinition $AllmanDefinition -Settings 'CodeFormattingStroustrup' | Should -Be $StroustrupDefinition + Invoke-Formatter -ScriptDefinition $OTBSDefinition -Settings 'CodeFormattingStroustrup' | Should -Be $StroustrupDefinition + } + } } From 669c685ba79ec1acdb819b98f9b0ff8b2b7135a8 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Thu, 19 Mar 2026 17:31:53 +0000 Subject: [PATCH 08/21] Add UseSingleValueFromPipelineParameter Rule (#2119) * Added Rule UseSingleValueFromPipelineParameter * Correct rule name for consistency and to fix failing docs tests * Update handling of empty AST passed to AnalyzeScript * Fixup built-in rule count test * Make rule not enabled by default * Apply suggestions from code review Co-authored-by: Christoph Bergmeister * Fix merge --------- Co-authored-by: Christoph Bergmeister Co-authored-by: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> --- Rules/Strings.resx | 14 +- Rules/UseSingleValueFromPipelineParameter.cs | 188 ++++++++++ ...SingleValueFromPipelineParameter.Tests.ps1 | 335 ++++++++++++++++++ docs/Rules/README.md | 1 + .../UseSingleValueFromPipelineParameter.md | 101 ++++++ 5 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 Rules/UseSingleValueFromPipelineParameter.cs create mode 100644 Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 create mode 100644 docs/Rules/UseSingleValueFromPipelineParameter.md diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 482cec69f..772b8bb0d 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1263,6 +1263,18 @@ The reserved word '{0}' was used as a function name. This should be avoided. + + Use a single ValueFromPipeline parameter per parameter set + + + Use at most a single ValueFromPipeline parameter per parameter set to avoid undefined or unexpected behaviour. + + + Multiple parameters ({0}) in parameter set '{1}' are marked as ValueFromPipeline. Only one parameter per parameter set should accept pipeline input. + + + UseSingleValueFromPipelineParameter + Use correct function parameters definition kind. @@ -1278,4 +1290,4 @@ Use inline parameters definition instead of param() block in function body. - \ No newline at end of file + diff --git a/Rules/UseSingleValueFromPipelineParameter.cs b/Rules/UseSingleValueFromPipelineParameter.cs new file mode 100644 index 000000000..0b69880aa --- /dev/null +++ b/Rules/UseSingleValueFromPipelineParameter.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Management.Automation.Language; +#if !CORECLR +using System.ComponentModel.Composition; +#endif + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + + /// + /// Rule that identifies parameter blocks with multiple parameters in + /// the same parameter set that are marked as ValueFromPipeline=true, which + /// can cause undefined behavior. + /// + public class UseSingleValueFromPipelineParameter : ConfigurableRule + { + private const string AllParameterSetsName = "__AllParameterSets"; + + /// + /// Analyzes the PowerShell AST for parameter sets with multiple ValueFromPipeline parameters. + /// + /// The PowerShell Abstract Syntax Tree to analyze. + /// The name of the file being analyzed (for diagnostic reporting). + /// A collection of diagnostic records for each violating parameter. + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) + { + throw new ArgumentNullException(Strings.NullAstErrorMessage); + } + // Find all param blocks that have a Parameter attribute with + // ValueFromPipeline set to true. + var paramBlocks = ast.FindAll(testAst => testAst is ParamBlockAst, true) + .Where(paramBlock => paramBlock.FindAll( + attributeAst => attributeAst is AttributeAst attr && + ParameterAttributeAstHasValueFromPipeline(attr), + true + ).Any()); + + foreach (var paramBlock in paramBlocks) + { + // Find all parameter declarations in the current param block + // Convert the generic ast objects into ParameterAst Objects + // For each ParameterAst, find all it's attributes that have + // ValueFromPipeline set to true (either explicitly or + // implicitly). Flatten the results into a single collection of + // Annonymous objects relating the parameter with it's attribute + // and then group them by parameter set name. + // + // + // https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_parameter_sets?#reserved-parameter-set-name + // + // The default parameter set name is '__AllParameterSets'. + // Not specifying a parameter set name and using the parameter + // set name '__AllParameterSets' are equivalent, so we shouldn't + // treat them like they're different just because one is an + // empty string and the other is not. + // + // Filter the list to only keep parameter sets that have more + // than one ValueFromPipeline parameter. + var parameterSetGroups = paramBlock.FindAll(n => n is ParameterAst, true) + .Cast() + .SelectMany(parameter => parameter.FindAll( + a => a is AttributeAst attr && ParameterAttributeAstHasValueFromPipeline(attr), + true + ).Cast().Select(attr => new { Parameter = parameter, Attribute = attr })) + .GroupBy(item => GetParameterSetForAttribute(item.Attribute) ?? AllParameterSetsName) + .Where(group => group.Count() > 1); + + + foreach (var group in parameterSetGroups) + { + // __AllParameterSets being the default name is...obscure. + // Instead we'll show the user "default". It's more than + // likely the user has not specified a parameter set name, + // so default will make sense. If they have used 'default' + // as their parameter set name, then we're still correct. + var parameterSetName = group.Key == AllParameterSetsName ? "default" : group.Key; + + // Create a concatenated string of parameter names that + // conflict in this parameter set + var parameterNames = string.Join(", ", group.Select(item => item.Parameter.Name.VariablePath.UserPath)); + + // We emit a diagnostic record for each offending parameter + // attribute in the parameter set so it's obvious where all the + // occurrences are. + foreach (var item in group) + { + var message = string.Format(CultureInfo.CurrentCulture, + Strings.UseSingleValueFromPipelineParameterError, + parameterNames, + parameterSetName); + + yield return new DiagnosticRecord( + message, + item.Attribute.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + parameterSetName); + } + } + } + } + + /// + /// Returns whether the specified AttributeAst represents a Parameter attribute + /// that has the ValueFromPipeline named argument set to true (either explicitly or + /// implicitly). + /// + /// The Parameter attribute to examine. + /// Whether the attribute has the ValueFromPipeline named argument set to true. + private static bool ParameterAttributeAstHasValueFromPipeline(AttributeAst attributeAst) + { + // Exit quickly if the attribute is null, has no named arguments, or + // is not a parameter attribute. + if (attributeAst?.NamedArguments == null || + !string.Equals(attributeAst.TypeName?.Name, "Parameter", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return attributeAst.NamedArguments + .OfType() + .Any(namedArg => string.Equals( + namedArg?.ArgumentName, + "ValueFromPipeline", + StringComparison.OrdinalIgnoreCase + // Helper.Instance.GetNamedArgumentAttributeValue handles both explicit ($true) + // and implicit (no value specified) ValueFromPipeline declarations + ) && Helper.Instance.GetNamedArgumentAttributeValue(namedArg)); + } + + /// + /// Gets the ParameterSetName value from a Parameter attribute. + /// + /// The Parameter attribute to examine. + /// The parameter set name, or null if not found or empty. + private static string GetParameterSetForAttribute(AttributeAst attributeAst) + { + // Exit quickly if the attribute is null, has no named arguments, or + // is not a parameter attribute. + if (attributeAst?.NamedArguments == null || + !string.Equals(attributeAst.TypeName.Name, "Parameter", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return attributeAst.NamedArguments + .OfType() + .Where(namedArg => string.Equals( + namedArg?.ArgumentName, + "ParameterSetName", + StringComparison.OrdinalIgnoreCase + )) + .Select(namedArg => namedArg?.Argument) + .OfType() + .Select(stringConstAst => stringConstAst?.Value) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + } + + public override string GetCommonName() => Strings.UseSingleValueFromPipelineParameterCommonName; + + public override string GetDescription() => Strings.UseSingleValueFromPipelineParameterDescription; + + public override string GetName() => string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.UseSingleValueFromPipelineParameterName); + + public override RuleSeverity GetSeverity() => RuleSeverity.Warning; + + public override string GetSourceName() => Strings.SourceName; + + public override SourceType GetSourceType() => SourceType.Builtin; + } +} \ No newline at end of file diff --git a/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 b/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 new file mode 100644 index 000000000..945130b9e --- /dev/null +++ b/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 @@ -0,0 +1,335 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $ruleName = 'PSUseSingleValueFromPipelineParameter' + + $settings = @{ + IncludeRules = @($ruleName) + Rules = @{ + $ruleName = @{ + Enable = $true + } + } + } +} + +Describe 'UseSingleValueFromPipelineParameter' { + + Context 'When multiple parameters have ValueFromPipeline in same parameter set' { + + It "Should flag explicit ValueFromPipeline=`$true in default parameter set" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $InputObject, + + [Parameter(ValueFromPipeline=$true)] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + $violations[0].Message | Should -Match "Multiple parameters \(InputObject, AnotherParam\) in parameter set 'default'" + } + + It 'Should flag implicit ValueFromPipeline in default parameter set' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + + [Parameter(ValueFromPipeline)] + $SecondParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + } + + It 'Should flag mixed explicit and implicit ValueFromPipeline' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $InputObject, + + [Parameter(ValueFromPipeline)] + $SecondParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + } + + It 'Should flag multiple parameters in named parameter set' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $InputObject, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $SecondParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + $violations[0].Message | Should -Match "parameter set 'MySet'" + } + + It 'Should flag three parameters in same parameter set' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $First, + + [Parameter(ValueFromPipeline=$true)] + $Second, + + [Parameter(ValueFromPipeline=$true)] + $Third + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 3 + $violations[0].Message | Should -Match 'Multiple parameters \(First, Second, Third\)' + } + } + + Context 'When parameters are in different parameter sets' { + + It 'Should not flag parameters in different parameter sets' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')] + $InputObject1, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')] + $InputObject2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It 'Should handle mix of named and default parameter sets correctly' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $DefaultSetParam, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='NamedSet')] + $NamedSetParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + } + + Context 'When only one parameter has ValueFromPipeline' { + + It 'Should not flag single ValueFromPipeline parameter' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $InputObject, + + [Parameter()] + $OtherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + } + + Context 'When ValueFromPipeline is explicitly set to false' { + + It "Should not flag parameters with ValueFromPipeline=`$false" { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$false)] + $InputObject, + + [Parameter(ValueFromPipeline=$false)] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It 'Should only flag the true ValueFromPipeline parameter' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + $TrueParam, + + [Parameter(ValueFromPipeline=$false)] + $FalseParam, + + [Parameter()] + $NoValueParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + } + + Context 'When non-Parameter attributes have ValueFromPipeline property' { + + It 'Should not flag custom attributes with ValueFromPipeline property' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + [CustomAttribute(ValueFromPipeline=$true)] + $InputObject, + + [CustomAttribute(ValueFromPipeline=$true)] + $NonPipelineParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It 'Should not flag ValidateSet with ValueFromPipeline property' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true)] + [ValidateSet('Value1', 'Value2', ValueFromPipeline=$true)] + $InputObject, + + [ValidateSet('Value1', 'Value2', ValueFromPipeline=$true)] + $NonPipelineParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + } + + Context 'When there are no Parameter attributes' { + + It 'Should not flag functions without Parameter attributes' { + $scriptDefinition = @' +function Test-Function { + param( + $InputObject, + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + + It 'Should not flag functions with only non-ValueFromPipeline Parameter attributes' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(Mandatory=$true)] + $InputObject, + + [Parameter(Position=0)] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + } + + Context 'Complex parameter set scenarios' { + + It 'Should flag violations in multiple parameter sets independently' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')] + $Set1Param1, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')] + $Set1Param2, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')] + $Set2Param1, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')] + $Set2Param2 + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 4 # 2 violations per parameter set, each parameter gets flagged + + # Check that both parameter sets are mentioned in violations + $violationMessages = $violations.Message -join ' ' + $violationMessages | Should -Match "parameter set 'Set1'" + $violationMessages | Should -Match "parameter set 'Set2'" + } + + It 'Should handle __AllParameterSets parameter set name correctly' { + $scriptDefinition = @' +function Test-Function { + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='__AllParameterSets')] + $ExplicitAllSets, + + [Parameter(ValueFromPipeline=$true)] + $ImplicitAllSets + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 2 + $violations[0].Message | Should -Match "parameter set 'default'" + } + } + + Context 'Suppression scenarios' { + + It 'Should be suppressible by parameter set name' { + $scriptDefinition = @' +function Test-Function { + [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'MySet')] + param( + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $InputObject, + + [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')] + $AnotherParam + ) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings + $violations.Count | Should -Be 0 + } + } +} \ No newline at end of file diff --git a/docs/Rules/README.md b/docs/Rules/README.md index e4a298ef2..2f8559623 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -78,6 +78,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [UseProcessBlockForPipelineCommand](./UseProcessBlockForPipelineCommand.md) | Warning | Yes | | | [UsePSCredentialType](./UsePSCredentialType.md) | Warning | Yes | | | [UseShouldProcessForStateChangingFunctions](./UseShouldProcessForStateChangingFunctions.md) | Warning | Yes | | +| [UseSingleValueFromPipelineParameter](./UseSingleValueFromPipelineParameter.md) | Warning | No | | | [UseSingularNouns](./UseSingularNouns.md) | Warning | Yes | Yes | | [UseSupportsShouldProcess](./UseSupportsShouldProcess.md) | Warning | Yes | | | [UseToExportFieldsInManifest](./UseToExportFieldsInManifest.md) | Warning | Yes | | diff --git a/docs/Rules/UseSingleValueFromPipelineParameter.md b/docs/Rules/UseSingleValueFromPipelineParameter.md new file mode 100644 index 000000000..bfaa3fe6a --- /dev/null +++ b/docs/Rules/UseSingleValueFromPipelineParameter.md @@ -0,0 +1,101 @@ +--- +description: Use at most a single ValueFromPipeline parameter per parameter set. +ms.date: 08/08/2025 +ms.topic: reference +title: UseSingleValueFromPipelineParameter +--- +# UseSingleValueFromPipelineParameter + +**Severity Level: Warning** + +## Description + +Parameter sets should have at most one parameter marked as +`ValueFromPipeline = true`. + +This rule identifies functions where multiple parameters within the same +parameter set have `ValueFromPipeline` set to `true` (either explicitly or +implicitly). + +## How + +Ensure that only one parameter per parameter set accepts pipeline input by +value. If you need multiple parameters to accept different types of pipeline +input, use separate parameter sets. + +## Example + +### Wrong + +```powershell +function Process-Data { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] + [string] $InputData, + + [Parameter(ValueFromPipeline)] + [string] $ProcessingMode + ) + + process { + Write-Output "$ProcessingMode`: $InputData" + } +} +``` + + +### Correct + +```powershell +function Process-Data { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] + [string] $InputData, + + [Parameter(Mandatory)] + [string] $ProcessingMode + ) + process { + Write-Output "$ProcessingMode`: $InputData" + } +} +``` +## Suppression + +To suppress this rule for a specific parameter set, use the `SuppressMessage` +attribute with the parameter set name: + +```powershell +function Process-Data { + [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'MyParameterSet')] + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] + [string] $InputData, + + [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] + [string] $ProcessingMode + ) + process { + Write-Output "$ProcessingMode`: $InputData" + } +} +``` + +For the default parameter set, use `'default'` as the suppression target: + +```powershell +[Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'default')] +``` + +## Notes + +- This rule applies to both explicit `ValueFromPipeline = $true` and implicit + `ValueFromPipeline` (which is the same as using `= $true`) +- Parameters with `ValueFromPipeline=$false` are not flagged by this rule +- The rule correctly handles the default parameter set (`__AllParameterSets`) + and named parameter sets +- Different parameter sets can each have their own single `ValueFromPipeline` + parameter without triggering this rule From 45e3e6cc6594876979b2dca3ccbe1921076c86ed Mon Sep 17 00:00:00 2001 From: Adam Erickson <5751456+admercs@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:00:03 -0700 Subject: [PATCH 09/21] Added user-friendly installation instructions (#2141) * added user-friendly installation instructions * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Christoph Bergmeister Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d038ec756..24f2704ff 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,11 @@ documentation for the latest release is published on on learn.microsoft.com. ## Installation To install **PSScriptAnalyzer** from the PowerShell Gallery, see -[Installing PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview#installing-psscriptanalyzer). +[Installing PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview#installing-psscriptanalyzer) or simply open PowerShell in a Terminal and run: + +```powershell +Install-Module -Name PSScriptAnalyzer +``` To install **PSScriptAnalyzer** from source code: From 68836181f570abd561d5ad1b1fa74efcd8bb3689 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:17:42 +0000 Subject: [PATCH 10/21] Update .NET SDK, PowerShell SDK dependencies (#2168) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: andyleejordan <2226434+andyleejordan@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- Engine/PSScriptAnalyzer.psm1 | 3 ++- global.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f5afcab86..bd8565ee8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,8 +9,8 @@ - - + + diff --git a/Engine/PSScriptAnalyzer.psm1 b/Engine/PSScriptAnalyzer.psm1 index acd9daf76..7e2ca8f31 100644 --- a/Engine/PSScriptAnalyzer.psm1 +++ b/Engine/PSScriptAnalyzer.psm1 @@ -9,7 +9,8 @@ $PSModuleRoot = $PSModule.ModuleBase # Import the appropriate nested binary module based on the current PowerShell version $binaryModuleRoot = $PSModuleRoot -[Version] $minimumPowerShellCoreVersion = '7.4.13' +# This should be the equivalent of 7.4.x i.e. latest major/minor supported but lowest patch. +[Version] $minimumPowerShellCoreVersion = '7.4.6' if ($PSVersionTable.PSVersion.Major -ge 6) { $binaryModuleRoot = Join-Path -Path $PSModuleRoot -ChildPath "PSv$($PSVersionTable.PSVersion.Major)" # Minimum PowerShell Core version given by PowerShell Core support itself and diff --git a/global.json b/global.json index 875b92095..de3e16bd7 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.416", + "version": "8.0.419", "rollForward": "latestFeature" } } From 6cfc26064f2d2f6d3d6c7f0fa5d5537715c72fd5 Mon Sep 17 00:00:00 2001 From: Josh Corrick Date: Fri, 20 Mar 2026 10:16:19 -0700 Subject: [PATCH 11/21] Add Optional PSUseConstrainedLanguageMode rule (#2165) * Add UseConstrainedLanguageMode rule and tests * Enhance CLM rule: restrict types, COM objects, add tests * Enhance detection of disallowed types in CLM rule * Detect and flag class definitions in Constrained Language * Enhance CLM rule to check module manifests for wildcards/.ps1 * Differentiate CLM checks for signed vs unsigned scripts * Improve dot-sourcing detection and expand CLM rule tests * Add IgnoreSignatures option to CLM rule and improve type checks * Add documentation for PSUseConstrainedLanguageMode rule * Increase severity of UseConstrainedLanguageMode to Warning for optional rule * Update test to expect 'Warning' severity instead of 'Info' * Making copilot suggested edits * Detect and flag [PSCustomObject]@{} in CLM scripts * Add UseConstrainedLanguageMode rule to README.md * Update CLM rule: tighten types, docs, and add error string * Add platform checks to CLM tests for cross-platform support * Add missing end tag after merge conflict from main * Add CLM rule ScriptsToProcess; Fix wildcard and dotsource bug --- Rules/Strings.resx | 54 + Rules/UseConstrainedLanguageMode.cs | 1101 +++++++++++++++++ .../UseConstrainedLanguageMode.tests.ps1 | 864 +++++++++++++ docs/Rules/README.md | 1 + docs/Rules/UseConstrainedLanguageMode.md | 378 ++++++ 5 files changed, 2398 insertions(+) create mode 100644 Rules/UseConstrainedLanguageMode.cs create mode 100644 Tests/Rules/UseConstrainedLanguageMode.tests.ps1 create mode 100644 docs/Rules/UseConstrainedLanguageMode.md diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 772b8bb0d..2a04fd759 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1263,6 +1263,60 @@ The reserved word '{0}' was used as a function name. This should be avoided. + + UseConstrainedLanguageMode + + + Consider Constrained Language Mode Restrictions + + + Identifies script patterns that are restricted in Constrained Language Mode. Constrained Language Mode limits the types, cmdlets, and .NET methods that can be used to help secure PowerShell in environments requiring additional restrictions. + + + Add-Type is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. + + + New-Object with the COM object '{0}' is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. + + + XAML usage is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. + + + Dot-sourcing may be restricted in Constrained Language Mode depending on the source location. Ensure scripts are from trusted locations if running in a restricted environment. + + + Invoke-Expression is restricted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment. + + + New-Object with type '{0}' is not permitted in Constrained Language Mode. Consider using an allowed type. + + + Type constraint [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type. + + + Type expression [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type. + + + Type cast [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type. + + + Member '{1}' accessed on type [{0}] which is not permitted in Constrained Language Mode. Consider using an allowed type. + + + PowerShell class '{0}' is not permitted in Constrained Language Mode. Consider using alternative approaches such as hashtables or PSCustomObject. + + + Module manifest field '{0}' uses wildcard ('*') which is not recommended for Constrained Language Mode. Explicitly list exported items instead. + + + Module manifest field '{0}' contains script file '{1}' (.ps1). Use a module file (.psm1) or a binary module (.dll) instead for Constrained Language Mode compatibility. + + + Module manifest field 'ScriptsToProcess' contains script file '{0}' (.ps1). Scripts in ScriptsToProcess run in the caller's session state and are restricted in Constrained Language Mode. Consider moving this logic to module initialization code + + + [PSCustomObject]@{{}} syntax is not permitted in Constrained Language Mode. Use New-Object PSObject -Property @{{}} or plain hashtables instead. + Use a single ValueFromPipeline parameter per parameter set diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs new file mode 100644 index 000000000..7d73dd5eb --- /dev/null +++ b/Rules/UseConstrainedLanguageMode.cs @@ -0,0 +1,1101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System.Management.Automation; + +#if !CORECLR +using System.ComponentModel.Composition; +#endif +using System.Globalization; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// UseConstrainedLanguageMode: Checks for patterns that indicate Constrained Language Mode should be considered. + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + public class UseConstrainedLanguageMode : ConfigurableRule + { + // Allowed COM objects in Constrained Language Mode + private static readonly HashSet AllowedComObjects = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Scripting.Dictionary", + "Scripting.FileSystemObject", + "VBScript.RegExp" + }; + + // Allowed types in Constrained Language Mode (type accelerators and common types) + private static readonly HashSet AllowedTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "adsi", "adsisearcher", "Alias", "AllowEmptyCollection", "AllowEmptyString", + "AllowNull", "ArgumentCompleter", "ArgumentCompletions", "array", "bigint", + "bool", "byte", "char", "cimclass", "cimconverter", "ciminstance", "CimSession", + "cimtype", "CmdletBinding", "cultureinfo", "datetime", "decimal", "double", + "DscLocalConfigurationManager", "DscProperty", "DscResource", "ExperimentAction", + "Experimental", "ExperimentalFeature", "float", "guid", "hashtable", "int", + "int16", "int32", "int64", "ipaddress", "IPEndpoint", "long", "mailaddress", + "Microsoft.PowerShell.Commands.ModuleSpecification", "NoRunspaceAffinity", + "NullString", "Object", "ObjectSecurity", "ordered", "OutputType", "Parameter", + "PhysicalAddress", "pscredential", "pscustomobject", "PSDefaultValue", + "pslistmodifier", "psobject", "psprimitivedictionary", "PSTypeNameAttribute", + "regex", "sbyte", "securestring", "semver", "short", "single", "string", + "SupportsWildcards", "switch", "timespan", "uint", "uint16", "uint32", "uint64", + "ulong", "uri", "ushort", "ValidateCount", "ValidateDrive", "ValidateLength", + "ValidateNotNull", "ValidateNotNullOrEmpty", "ValidateNotNullOrWhiteSpace", + "ValidatePattern", "ValidateRange", "ValidateScript", "ValidateSet", + "ValidateTrustedData", "ValidateUserDrive", "version", "void", "WildcardPattern", + "wmi", "wmiclass", "wmisearcher", "X500DistinguishedName", "X509Certificate", "xml", + // Full type names for common allowed types + "System.Object", "System.String", "System.Int32", "System.Boolean", "System.Byte", + "System.Collections.Hashtable", "System.DateTime", "System.Version", "System.Uri", + "System.Guid", "System.TimeSpan", "System.Management.Automation.PSCredential", + "System.Management.Automation.PSObject", "System.Security.SecureString", + "System.Text.RegularExpressions.Regex", "System.Xml.XmlDocument", + "System.Collections.ArrayList", "System.Collections.Generic.List", + "System.Net.IPAddress", "System.Net.Mail.MailAddress" + }; + + /// + /// Cache for typed variable assignments per scope to avoid O(N*M) performance issues. + /// Key: Scope AST (FunctionDefinitionAst or ScriptBlockAst) + /// Value: Dictionary mapping variable names to their type names + /// + private Dictionary> _typedVariableCache; + + /// + /// When True, ignores the presence of script signature blocks and runs all CLM checks + /// regardless of whether a script appears to be signed. + /// When False (default), scripts that contain a PowerShell signature block (for example, + /// one starting with '# SIG # Begin signature block') are treated as having elevated + /// permissions for this rule and only critical checks (dot-sourcing, parameter types, + /// manifests) are performed. No cryptographic validation or trust evaluation of the + /// signature is performed. + /// + [ConfigurableRuleProperty(defaultValue: false)] + public bool IgnoreSignatures { get; set; } + + public UseConstrainedLanguageMode() + { + // This rule is disabled by default - users must explicitly enable it + Enable = false; + + // IgnoreSignatures defaults to false (respects signatures) + IgnoreSignatures = false; + } + + /// + /// Checks if a type name is allowed in Constrained Language Mode + /// + private bool IsTypeAllowed(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + { + return true; // Can't determine, so don't flag + } + + // Handle array types (e.g., string[], System.String[], int[][]) + // Strip array brackets and check the base type + string baseTypeName = typeName; + + + // Handle multi-dimensional or jagged arrays by removing all brackets + while (baseTypeName.EndsWith("[]", StringComparison.Ordinal)) + { + baseTypeName = baseTypeName.Substring(0, baseTypeName.Length - 2); + } + + + // Check exact match first + if (AllowedTypes.Contains(baseTypeName)) + { + return true; + } + + // Check simple name (last part after last dot) + if (baseTypeName.Contains('.')) + { + var simpleTypeName = baseTypeName.Substring(baseTypeName.LastIndexOf('.') + 1); + if (AllowedTypes.Contains(simpleTypeName)) + { + return true; + } + } + + return false; + } + + /// + /// Analyzes the script to check for patterns that may require Constrained Language Mode. + /// + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) + { + throw new ArgumentNullException(nameof(ast)); + } + + // Initialize cache for this analysis to avoid O(N*M) performance issues + _typedVariableCache = new Dictionary>(); + + var diagnosticRecords = new List(); + + // Check if the file is signed (via signature block detection) + bool isFileSigned = IgnoreSignatures ? false : IsScriptSigned(fileName); + + // Note: If IgnoreSignatures is true, isFileSigned will always be false, + // causing all CLM checks to run regardless of actual signature status + + // Check if this is a module manifest (.psd1 file) + bool isModuleManifest = fileName != null && fileName.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase); + + if (isModuleManifest) + { + // Perform PSD1-specific checks + // These checks are ALWAYS enforced, even for signed scripts + CheckModuleManifest(ast, fileName, diagnosticRecords); + } + + // For signed scripts, only check specific patterns that are still restricted + // (unless IgnoreSignatures is true, then this block is skipped) + if (isFileSigned) + { + // Even signed scripts have these restrictions in CLM: + + // 1. Check for dot-sourcing (still restricted in CLM even for signed scripts) + CheckDotSourcing(ast, fileName, diagnosticRecords); + + // 2. Check for type constraints on parameters (still need to be validated) + CheckParameterTypeConstraints(ast, fileName, diagnosticRecords); + + return diagnosticRecords; + } + + // For unsigned scripts (or when IgnoreSignatures is true), perform all CLM checks + CheckAllClmRestrictions(ast, fileName, diagnosticRecords); + + return diagnosticRecords; + } + + /// + /// Checks if a PowerShell script file appears to be digitally signed. + /// Note: This performs a simple text check for the signature block marker. + /// It does NOT validate signature authenticity, certificate trust, or file integrity. + /// For production use, PowerShell's execution policy and Get-AuthenticodeSignature + /// should be used to properly validate signatures. + /// + private bool IsScriptSigned(string fileName) + { + if (string.IsNullOrEmpty(fileName) || !System.IO.File.Exists(fileName)) + { + return false; + } + + // Only check .ps1, .psm1, and .psd1 files + string extension = System.IO.Path.GetExtension(fileName); + if (!extension.Equals(".ps1", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".psm1", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".psd1", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + try + { + // Read the file content + string content = System.IO.File.ReadAllText(fileName); + + // Check for signature block marker + // A signed PowerShell script contains a signature block that starts with: + // # SIG # Begin signature block + // + // IMPORTANT: This is a simple text check only. It does NOT validate: + // - Signature authenticity + // - Certificate validity or trust + // - File integrity (hash matching) + // - Certificate expiration + // + // This check assumes that if a signature block is present, the script + // was intended to be signed. Actual signature validation is performed + // by PowerShell at execution time based on execution policy. + return content.IndexOf("# SIG # Begin signature block", StringComparison.OrdinalIgnoreCase) >= 0; + } + catch + { + // If we can't read the file, assume it's not signed + return false; + } + } + + /// + /// Performs all CLM restriction checks (for unsigned scripts). + /// + private void CheckAllClmRestrictions(Ast ast, string fileName, List diagnosticRecords) + { + var addTypeCommands = ast.FindAll(testAst => + testAst is CommandAst cmdAst && + cmdAst.GetCommandName() != null && + cmdAst.GetCommandName().Equals("Add-Type", StringComparison.OrdinalIgnoreCase), + true); + + foreach (CommandAst cmd in addTypeCommands) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeAddTypeError), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + + // Check for New-Object with COM objects and TypeName (only specific ones are allowed in CLM) + var newObjectCommands = ast.FindAll(testAst => + testAst is CommandAst cmdAst && + cmdAst.GetCommandName() != null && + cmdAst.GetCommandName().Equals("New-Object", StringComparison.OrdinalIgnoreCase), + true); + + foreach (CommandAst cmd in newObjectCommands) + { + // Use StaticParameterBinder to reliably get parameter values + var bindingResult = StaticParameterBinder.BindCommand(cmd, true); + + // Check for -ComObject parameter + if (bindingResult.BoundParameters.ContainsKey("ComObject")) + { + string comObjectValue = null; + + // Try to get the value from the AST directly first + if (bindingResult.BoundParameters["ComObject"].Value is StringConstantExpressionAst strAst) + { + comObjectValue = strAst.Value; + } + else + { + // Fall back to ConstantValue + comObjectValue = bindingResult.BoundParameters["ComObject"].ConstantValue as string; + } + + // Only flag if COM object name was found AND it's not in the allowed list + if (!string.IsNullOrWhiteSpace(comObjectValue) && !AllowedComObjects.Contains(comObjectValue)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeComObjectError, + comObjectValue), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for -TypeName parameter + if (bindingResult.BoundParameters.ContainsKey("TypeName")) + { + var typeNameValue = bindingResult.BoundParameters["TypeName"].ConstantValue as string; + + // If ConstantValue is null, try to extract from the AST Value + if (typeNameValue == null && bindingResult.BoundParameters["TypeName"].Value is StringConstantExpressionAst typeStrAst) + { + typeNameValue = typeStrAst.Value; + } + + // Only flag if type name was found AND it's not in the allowed list + if (!string.IsNullOrWhiteSpace(typeNameValue) && !IsTypeAllowed(typeNameValue)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeNewObjectError, + typeNameValue), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + + // Check for XAML usage (not allowed in Constrained Language Mode) + var xamlPatterns = ast.FindAll(testAst => + testAst is StringConstantExpressionAst strAst && + strAst.Value.Contains("<") && strAst.Value.Contains("xmlns"), + true); + + foreach (StringConstantExpressionAst xamlAst in xamlPatterns) + { + if (xamlAst.Value.Contains("http://schemas.microsoft.com/winfx")) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeXamlError), + xamlAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for dot-sourcing (also called separately for signed scripts) + CheckDotSourcing(ast, fileName, diagnosticRecords); + + // Check for Invoke-Expression usage (restricted in Constrained Language Mode) + var invokeExpressionCommands = ast.FindAll(testAst => + testAst is CommandAst cmdAst && + cmdAst.GetCommandName() != null && + cmdAst.GetCommandName().Equals("Invoke-Expression", StringComparison.OrdinalIgnoreCase), + true); + + foreach (CommandAst cmd in invokeExpressionCommands) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeInvokeExpressionError), + cmd.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + + // Check for class definitions (not allowed in Constrained Language Mode) + var classDefinitions = ast.FindAll(testAst => + testAst is TypeDefinitionAst typeAst && typeAst.IsClass, + true); + + foreach (TypeDefinitionAst classDef in classDefinitions) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeClassError, + classDef.Name), + classDef.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + + // Check for parameter type constraints (also called separately for signed scripts) + CheckParameterTypeConstraints(ast, fileName, diagnosticRecords); + + // Check for disallowed type constraints on variables (e.g., [System.Net.WebClient]$client) + var typeConstraints = ast.FindAll(testAst => + testAst is TypeConstraintAst typeConstraint && + !(typeConstraint.Parent is ParameterAst), // Exclude parameters - handled above + true); + + foreach (TypeConstraintAst typeConstraint in typeConstraints) + { + var typeName = typeConstraint.TypeName.FullName; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeConstrainedTypeError, + typeName), + typeConstraint.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for disallowed type expressions and casts (e.g., [System.Net.WebClient]::new() or $x -as [Type]) + var typeExpressions = ast.FindAll(testAst => testAst is TypeExpressionAst, true); + foreach (TypeExpressionAst typeExpr in typeExpressions) + { + var typeName = typeExpr.TypeName.FullName; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeTypeExpressionError, + typeName), + typeExpr.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for convert expressions (e.g., $x = [System.Net.WebClient]$value) + var convertExpressions = ast.FindAll(testAst => testAst is ConvertExpressionAst, true); + foreach (ConvertExpressionAst convertExpr in convertExpressions) + { + var typeName = convertExpr.Type.TypeName.FullName; + + // Special case: [PSCustomObject]@{} is not allowed in CLM + // Even though PSCustomObject is an allowed type for parameters, + // the type cast syntax with hashtable literal is blocked in CLM + if (typeName.Equals("PSCustomObject", StringComparison.OrdinalIgnoreCase) && + convertExpr.Child is HashtableAst) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModePSCustomObjectError), + convertExpr.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + continue; // Already flagged, skip general type check + } + + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeConvertExpressionError, + typeName), + convertExpr.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + + // Check for member invocations on disallowed types + // This includes method calls and property access on variables with type constraints + var memberInvocations = ast.FindAll(testAst => + testAst is InvokeMemberExpressionAst || testAst is MemberExpressionAst, true); + + foreach (Ast memberAst in memberInvocations) + { + // Skip static member access - already handled by TypeExpressionAst check + if (memberAst is InvokeMemberExpressionAst invokeAst && invokeAst.Static) + { + continue; + } + + if (memberAst is MemberExpressionAst memAst && memAst.Static) + { + continue; + } + + // Get the expression being invoked on (e.g., the variable in $var.Method()) + ExpressionAst targetExpr = memberAst is InvokeMemberExpressionAst invExpr + ? invExpr.Expression + : ((MemberExpressionAst)memberAst).Expression; + + // Check if the target has a type constraint + string constrainedType = GetTypeConstraintFromExpression(targetExpr); + if (!string.IsNullOrWhiteSpace(constrainedType) && !IsTypeAllowed(constrainedType)) + { + string memberName = memberAst is InvokeMemberExpressionAst inv + ? (inv.Member as StringConstantExpressionAst)?.Value ?? "" + : ((memberAst as MemberExpressionAst).Member as StringConstantExpressionAst)?.Value ?? ""; + + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeMemberAccessError, + constrainedType, + memberName), + memberAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + + /// + /// Checks for dot-sourcing patterns which are restricted in CLM even for signed scripts. + /// + private void CheckDotSourcing(Ast ast, string fileName, List diagnosticRecords) + { + // Dot-sourcing is detected by looking for commands where the extent text starts with a dot + // Example: . $PSScriptRoot\Helper.ps1 + // Example: . .\script.ps1 + // PowerShell doesn't have a specific DotSourceExpressionAst, so we check the command extent + var commands = ast.FindAll(testAst => testAst is CommandAst, true); + + foreach (CommandAst cmdAst in commands) + { + // Check if the command extent starts with a dot followed by whitespace + // This indicates dot-sourcing + string extentText = cmdAst.Extent.Text.TrimStart(); + if (extentText.StartsWith(".") && extentText.Length > 1 && char.IsWhiteSpace(extentText[1])) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDotSourceError), + cmdAst.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + + /// + /// Checks parameter type constraints which need validation even for signed scripts. + /// + private void CheckParameterTypeConstraints(Ast ast, string fileName, List diagnosticRecords) + { + // Find all parameter definitions + var parameters = ast.FindAll(testAst => testAst is ParameterAst, true); + + foreach (ParameterAst param in parameters) + { + // Check for type constraints on parameters + var typeConstraints = param.Attributes.OfType(); + + foreach (var typeConstraint in typeConstraints) + { + var typeName = typeConstraint.TypeName.FullName; + if (!IsTypeAllowed(typeName)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeConstrainedTypeError, + typeName), + typeConstraint.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + } + + /// + /// Attempts to determine if an expression has a type constraint. + /// Returns the type name if found, otherwise null. + /// + private string GetTypeConstraintFromExpression(ExpressionAst expr) + { + if (expr == null) + { + return null; + } + + // Check if this is a convert expression with a type (e.g., [Type]$var) + if (expr is ConvertExpressionAst convertExpr) + { + return convertExpr.Type.TypeName.FullName; + } + + // Check if this is a variable expression + if (expr is VariableExpressionAst varExpr) + { + // Walk up the AST to find if this variable has a type constraint in a parameter + var parameterAst = FindParameterForVariable(varExpr); + if (parameterAst != null) + { + // Get the first type constraint attribute + var typeConstraint = parameterAst.Attributes + .OfType() + .FirstOrDefault(); + + if (typeConstraint != null) + { + return typeConstraint.TypeName.FullName; + } + } + + // Check if the variable was declared with a type constraint elsewhere + // Look for assignment statements with type constraints + var assignmentWithType = FindTypedAssignment(varExpr); + if (assignmentWithType != null) + { + return assignmentWithType; + } + } + + // Check if this is a member expression that might have a known return type + // For now, we'll be conservative and only check direct type constraints + + return null; + } + + /// + /// Finds the parameter AST for a given variable expression, if it exists. + /// + private ParameterAst FindParameterForVariable(VariableExpressionAst varExpr) + { + if (varExpr == null) + { + return null; + } + + var varName = varExpr.VariablePath.UserPath; + + // Walk up to find the containing function or script block + Ast current = varExpr.Parent; + while (current != null) + { + if (current is FunctionDefinitionAst funcAst) + { + // Check parameters in the param block + var paramBlock = funcAst.Body?.ParamBlock; + if (paramBlock?.Parameters != null) + { + foreach (var param in paramBlock.Parameters) + { + if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase)) + { + return param; + } + } + } + + // Check function parameters (for functions with parameters outside param block) + if (funcAst.Parameters != null) + { + foreach (var param in funcAst.Parameters) + { + if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase)) + { + return param; + } + } + } + + break; // Don't check outer function scopes + } + + if (current is ScriptBlockAst scriptAst) + { + var paramBlock = scriptAst.ParamBlock; + if (paramBlock?.Parameters != null) + { + foreach (var param in paramBlock.Parameters) + { + if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase)) + { + return param; + } + } + } + break; // Don't check outer script block scopes + } + + current = current.Parent; + } + + return null; + } + + /// + /// Builds and caches typed variable assignments for a given scope. + /// This is called once per scope to avoid O(N*M) performance issues. + /// + private Dictionary GetOrBuildTypedVariableCache(Ast scope) + { + if (scope == null) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + // Check if we already have cached results for this scope + if (_typedVariableCache.TryGetValue(scope, out var cachedResults)) + { + return cachedResults; + } + + // Build the cache for this scope + var typedVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Find all assignment statements in this scope + var assignments = scope.FindAll(testAst => testAst is AssignmentStatementAst, true); + + foreach (AssignmentStatementAst assignment in assignments) + { + // Check if the left side is a convert expression with a variable + if (assignment.Left is ConvertExpressionAst convertExpr && + convertExpr.Child is VariableExpressionAst assignedVar) + { + var varName = assignedVar.VariablePath.UserPath; + var typeName = convertExpr.Type.TypeName.FullName; + + // Store in cache (first assignment wins) + if (!typedVariables.ContainsKey(varName)) + { + typedVariables[varName] = typeName; + } + } + } + + // Cache the results + _typedVariableCache[scope] = typedVariables; + return typedVariables; + } + + /// + /// Looks for a typed assignment to a variable using cached results. + /// + private string FindTypedAssignment(VariableExpressionAst varExpr) + { + if (varExpr == null) + { + return null; + } + + var varName = varExpr.VariablePath.UserPath; + + // Walk up to find the containing function or script block + Ast searchScope = varExpr.Parent; + while (searchScope != null && + !(searchScope is FunctionDefinitionAst) && + !(searchScope is ScriptBlockAst)) + { + searchScope = searchScope.Parent; + } + + if (searchScope == null) + { + return null; + } + + // Use cached results instead of re-scanning the entire scope + var typedVariables = GetOrBuildTypedVariableCache(searchScope); + + if (typedVariables.TryGetValue(varName, out string typeName)) + { + return typeName; + } + + return null; + } + + /// + /// Checks module manifest (.psd1) files for CLM compatibility issues. + /// + private void CheckModuleManifest(Ast ast, string fileName, List diagnosticRecords) + { + // Find the hashtable in the manifest + var hashtableAst = ast.Find(x => x is HashtableAst, false) as HashtableAst; + + if (hashtableAst == null) + { + return; + } + + // Check for wildcard exports in FunctionsToExport, CmdletsToExport, AliasesToExport + CheckWildcardExports(hashtableAst, fileName, diagnosticRecords); + + // Check for .ps1 files in RootModule, NestedModules, and ScriptsToProcess + CheckScriptModules(hashtableAst, fileName, diagnosticRecords); + } + + /// + /// Checks for wildcard ('*') in export fields which are not allowed in CLM. + /// + private void CheckWildcardExports(HashtableAst hashtableAst, string fileName, List diagnosticRecords) + { + //AliasesToExport and VariablesToExport can use wildcards in CLM, but it is not recommended for performance reasons. + string[] exportFields = { "FunctionsToExport", "CmdletsToExport"}; + + foreach (var kvp in hashtableAst.KeyValuePairs) + { + if (kvp.Item1 is StringConstantExpressionAst keyAst) + { + string keyName = keyAst.Value; + + if (exportFields.Contains(keyName, StringComparer.OrdinalIgnoreCase)) + { + // Check if the value contains a wildcard + bool hasWildcard = false; + IScriptExtent wildcardExtent = null; + + // The value in a hashtable is a StatementAst, need to extract the expression + var valueExpr = GetExpressionFromStatement(kvp.Item2); + + if (valueExpr is StringConstantExpressionAst stringValue) + { + if (stringValue.Value == "*") + { + hasWildcard = true; + wildcardExtent = stringValue.Extent; + } + } + else if (valueExpr is ArrayLiteralAst arrayValue) + { + foreach (var element in arrayValue.Elements) + { + if (element is StringConstantExpressionAst strElement && strElement.Value == "*") + { + hasWildcard = true; + wildcardExtent = strElement.Extent; + break; + } + } + } + else if (valueExpr is ArrayExpressionAst arrayExpr) + { + // Array expressions like @('a', 'b') have a SubExpression inside + if (arrayExpr.SubExpression?.Statements != null) + { + foreach (var stmt in arrayExpr.SubExpression.Statements) + { + var expr = GetExpressionFromStatement(stmt); + if (expr is ArrayLiteralAst arrayLiteral) + { + foreach (var element in arrayLiteral.Elements) + { + if (element is StringConstantExpressionAst strElement && strElement.Value == "*") + { + hasWildcard = true; + wildcardExtent = strElement.Extent; + break; + } + } + } + else if (expr is StringConstantExpressionAst strElement && strElement.Value == "*") + { + // Handle single-item array expressions like @('*') + hasWildcard = true; + wildcardExtent = strElement.Extent; + break; + } + if (hasWildcard) break; + } + } + } + + if (hasWildcard && wildcardExtent != null) + { + diagnosticRecords.Add( + new DiagnosticRecord( + String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeWildcardExportError, + keyName), + wildcardExtent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + } + } + + /// + /// Checks for .ps1 files in RootModule, NestedModules, and ScriptsToProcess which are not recommended for CLM. + /// + private void CheckScriptModules(HashtableAst hashtableAst, string fileName, List diagnosticRecords) + { + string[] moduleFields = { "RootModule", "NestedModules", "ScriptsToProcess" }; + + foreach (var kvp in hashtableAst.KeyValuePairs) + { + if (kvp.Item1 is StringConstantExpressionAst keyAst) + { + string keyName = keyAst.Value; + + if (moduleFields.Contains(keyName, StringComparer.OrdinalIgnoreCase)) + { + var valueExpr = GetExpressionFromStatement(kvp.Item2); + CheckForPs1Files(valueExpr, keyName, fileName, diagnosticRecords); + } + } + } + } + + /// + /// Extracts an ExpressionAst from a StatementAst (typically from hashtable values). + /// + private ExpressionAst GetExpressionFromStatement(StatementAst statement) + { + if (statement is PipelineAst pipeline && pipeline.PipelineElements.Count == 1) + { + if (pipeline.PipelineElements[0] is CommandExpressionAst commandExpr) + { + return commandExpr.Expression; + } + } + return null; + } + + /// + /// Helper method to get the appropriate error message for .ps1 file usage in module manifests. + /// + private string GetPs1FileErrorMessage(string fieldName, string scriptFileName) + { + if (fieldName.Equals("ScriptsToProcess", StringComparison.OrdinalIgnoreCase)) + { + return String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeScriptsToProcessError, + scriptFileName); + } + else + { + return String.Format(CultureInfo.CurrentCulture, + Strings.UseConstrainedLanguageModeScriptModuleError, + fieldName, + scriptFileName); + } + } + + /// + /// Helper method to check if an expression contains .ps1 file references. + /// + private void CheckForPs1Files(ExpressionAst valueAst, string fieldName, string fileName, List diagnosticRecords) + { + if (valueAst is StringConstantExpressionAst stringValue) + { + if (stringValue.Value != null && stringValue.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + GetPs1FileErrorMessage(fieldName, stringValue.Value), + stringValue.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + else if (valueAst is ArrayLiteralAst arrayValue) + { + foreach (var element in arrayValue.Elements) + { + if (element is StringConstantExpressionAst strElement && + strElement.Value != null && + strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + GetPs1FileErrorMessage(fieldName, strElement.Value), + strElement.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + else if (valueAst is ArrayExpressionAst arrayExpr) + { + // Array expressions like @('a', 'b') have a SubExpression inside + if (arrayExpr.SubExpression?.Statements != null) + { + foreach (var stmt in arrayExpr.SubExpression.Statements) + { + var expr = GetExpressionFromStatement(stmt); + if (expr is ArrayLiteralAst arrayLiteral) + { + foreach (var element in arrayLiteral.Elements) + { + if (element is StringConstantExpressionAst strElement && + strElement.Value != null && + strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + GetPs1FileErrorMessage(fieldName, strElement.Value), + strElement.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + else if (expr is StringConstantExpressionAst strElement && + strElement.Value != null && + strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + diagnosticRecords.Add( + new DiagnosticRecord( + GetPs1FileErrorMessage(fieldName, strElement.Value), + strElement.Extent, + GetName(), + GetDiagnosticSeverity(), + fileName + )); + } + } + } + } + } + + /// + /// Retrieves the common name of this rule. + /// + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeCommonName); + } + + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDescription); + } + + /// + /// Retrieves the name of this rule. + /// + public override string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.UseConstrainedLanguageModeName); + } + + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// Gets the severity of the returned diagnostic record: error, warning, or information. + /// + public DiagnosticSeverity GetDiagnosticSeverity() + { + return DiagnosticSeverity.Warning; + } + + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + } +} diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 new file mode 100644 index 000000000..02de6b9ed --- /dev/null +++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 @@ -0,0 +1,864 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# Tests for UseConstrainedLanguageMode rule +# +# Some tests are Windows-specific (COM objects, XAML) and will be skipped on non-Windows platforms. + +BeforeDiscovery { + # Detect OS for platform-specific tests + $script:IsWindowsOS = $true + $script:IsLinuxOS = $false + $script:IsMacOSOS = $false + + if ($PSVersionTable.PSVersion.Major -ge 6) { + # PowerShell Core has built-in OS detection variables + $script:IsWindowsOS = $IsWindows + $script:IsLinuxOS = $IsLinux + $script:IsMacOSOS = $IsMacOS + } +} +BeforeAll { + $testRootDirectory = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $testRootDirectory "PSScriptAnalyzerTestHelper.psm1") + + $violationName = "PSUseConstrainedLanguageMode" + $ruleName = $violationName + + # The rule is disabled by default, so we need to enable it + $settings = @{ + IncludeRules = @($ruleName) + Rules = @{ + $ruleName = @{ + Enable = $true + } + } + } +} + +Describe "UseConstrainedLanguageMode" { +Context "When Add-Type is used" { + It "Should detect Add-Type usage" { + $def = @' +Add-Type -TypeDefinition @" + public class TestType { + public static string Test() { return "test"; } + } +"@ +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations.Count | Should -Be 1 + $violations[0].RuleName | Should -Be $violationName + $violations[0].Message | Should -BeLike "*Add-Type*" + } + + It "Should not flag other commands" { + $def = 'Get-Process | Where-Object { $_.Name -eq "powershell" }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + } + + Context "When New-Object with COM is used" { + It "Should detect disallowed New-Object -ComObject usage" -Skip:(-not $script:IsWindowsOS) { + $def = 'New-Object -ComObject "Excel.Application"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*COM object*" + } + + It "Should NOT flag allowed COM objects - Scripting.Dictionary" -Skip:(-not $script:IsWindowsOS) { + $def = 'New-Object -ComObject "Scripting.Dictionary"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag allowed COM objects - Scripting.FileSystemObject" -Skip:(-not $script:IsWindowsOS) { + $def = 'New-Object -ComObject "Scripting.FileSystemObject"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag allowed COM objects - VBScript.RegExp" -Skip:(-not $script:IsWindowsOS) { + $def = 'New-Object -ComObject "VBScript.RegExp"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag New-Object with allowed TypeName" { + $def = 'New-Object -TypeName System.Collections.ArrayList' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should flag New-Object with disallowed TypeName" { + $def = 'New-Object -TypeName System.IO.File' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*" + } + } + + Context "When XAML is used" { + It "Should detect XAML usage" -Skip:(-not $script:IsWindowsOS) { + $def = @' +$xaml = @" + + + +"@ +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*XAML*" + } + } + + Context "When Invoke-Expression is used" { + It "Should detect Invoke-Expression usage" { + $def = 'Invoke-Expression "Get-Process"' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*Invoke-Expression*" + } + } + + Context "When dot-sourcing is used" { + It "Should detect dot-sourcing in unsigned scripts" { + $def = '. $PSScriptRoot\Helper.ps1' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*dot*" + } + } + + Context "When PowerShell classes are used" { + It "Should detect class definition" { + $def = @' +class MyClass { + [string]$Name + [int]$Value + + MyClass([string]$name) { + $this.Name = $name + } +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*class*MyClass*" + } + + It "Should detect multiple class definitions" { + $def = @' +class FirstClass { + [string]$Name +} + +class SecondClass { + [int]$Value +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 2 + } + + It "Should not flag enum definitions" { + $def = @' +enum MyEnum { + Value1 + Value2 +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + # Enums are allowed, so no class-specific violations + # (though we may still flag other issues if present) + $classViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*class*" + } + $classViolations | Should -BeNullOrEmpty + } + } + + Context "When type expressions are used" { + It "Should flag static type reference with new()" { + $def = '$instance = [System.IO.Directory]::new()' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.IO.Directory*" + } + + It "Should flag static method call on disallowed type" { + $def = '[System.IO.File]::ReadAllText("C:\test.txt")' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.IO.File*" + } + + It "Should NOT flag static reference to allowed type" { + $def = '[string]::Empty' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $typeExprViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*type expression*string*" + } + $typeExprViolations | Should -BeNullOrEmpty + } + } + + Context "When module manifests (.psd1) are analyzed" { + BeforeAll { + $tempPath = Join-Path $TestDrive "TestManifests" + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null + } + + It "Should flag wildcard in FunctionsToExport" { + $manifestPath = Join-Path $tempPath "WildcardFunctions.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + FunctionsToExport = '*' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*FunctionsToExport*wildcard*" + } + + It "Should flag wildcard array in FunctionsToExport" { + $manifestPath = Join-Path $tempPath "WildcardFunctions.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + FunctionsToExport = @('*') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*FunctionsToExport*wildcard*" + } + + It "Should flag wildcard in CmdletsToExport" { + $manifestPath = Join-Path $tempPath "WildcardCmdlets.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + CmdletsToExport = '*' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*CmdletsToExport*wildcard*" + } + + It "Should NOT flag explicit list of exports" { + $manifestPath = Join-Path $tempPath "ExplicitExports.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + FunctionsToExport = @('Get-MyFunction', 'Set-MyFunction') + CmdletsToExport = @('Get-MyCmdlet') + AliasesToExport = @() +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $wildcardViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*wildcard*" + } + $wildcardViolations | Should -BeNullOrEmpty + } + + It "Should flag .ps1 file in RootModule" { + $manifestPath = Join-Path $tempPath "ScriptRootModule.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.ps1' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*RootModule*MyModule.ps1*" + } + + It "Should flag .ps1 file in NestedModules" { + $manifestPath = Join-Path $tempPath "ScriptNestedModule.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + NestedModules = @('Helper.ps1', 'Utility.psm1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*NestedModules*Helper.ps1*" + } + + It "Should NOT flag .psm1 or .dll modules" { + $manifestPath = Join-Path $tempPath "BinaryModules.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.psm1' + NestedModules = @('Helper.dll', 'Utility.psm1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $scriptModuleViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*.ps1*" + } + $scriptModuleViolations | Should -BeNullOrEmpty + } + + It "Should flag .ps1 file in ScriptsToProcess" { + $manifestPath = Join-Path $tempPath "ScriptsToProcess.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ScriptsToProcess = @('Init.ps1', 'Setup.ps1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*ScriptsToProcess*Init.ps1*" + } + + It "Should use different error message for ScriptsToProcess" { + $manifestPath = Join-Path $tempPath "ScriptsToProcessMessage.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ScriptsToProcess = 'Init.ps1' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + # ScriptsToProcess should have a specific message about caller's session state + $matchingViolations[0].Message | Should -BeLike "*caller*session state*" + $matchingViolations[0].Message | Should -BeLike "*Init.ps1*" + } + + It "Should flag single-item array in ScriptsToProcess" { + $manifestPath = Join-Path $tempPath "ScriptsToProcessArray.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ScriptsToProcess = @('Init.ps1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*ScriptsToProcess*Init.ps1*" + } + + It "Should NOT flag .psm1 files in ScriptsToProcess" { + $manifestPath = Join-Path $tempPath "ScriptsToProcessPsm1.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ScriptsToProcess = @('Init.psm1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $scriptViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*ScriptsToProcess*" + } + $scriptViolations | Should -BeNullOrEmpty + } + + It "Should flag both wildcard and .ps1 issues in same manifest" { + $manifestPath = Join-Path $tempPath "MultipleIssues.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.ps1' + FunctionsToExport = '*' + CmdletsToExport = '*' +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should have at least 3 violations: RootModule .ps1, FunctionsToExport *, CmdletsToExport * + $matchingViolations.Count | Should -BeGreaterOrEqual 3 + } + + It "Should flag ScriptsToProcess and RootModule with different messages" { + $manifestPath = Join-Path $tempPath "MixedScriptFields.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.ps1' + ScriptsToProcess = @('Init.ps1') +} +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 2 + + # Check that we have both types of messages + $scriptsToProcessMsg = $matchingViolations | Where-Object { $_.Message -like "*caller*session state*" } + $rootModuleMsg = $matchingViolations | Where-Object { $_.Message -like "*RootModule*" -and $_.Message -notlike "*caller*session state*" } + + $scriptsToProcessMsg.Count | Should -Be 1 + $rootModuleMsg.Count | Should -Be 1 + + # Verify the specific field names are mentioned + $scriptsToProcessMsg[0].Message | Should -BeLike "*Init.ps1*" + $rootModuleMsg[0].Message | Should -BeLike "*MyModule.ps1*" + } + } + + Context "Rule severity" { + It "Should have Warning severity" { + $def = 'Add-Type -AssemblyName System.Windows.Forms' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations[0].Severity | Should -Be 'Warning' + } + } + + Context "When type constraints are used" { + It "Should flag disallowed type constraint on parameter" { + $def = 'function Test { param([System.IO.File]$FileHelper) }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*" + } + + It "Should flag disallowed type constraint on variable declaration" { + $def = '[System.IO.File]$fileHelper = $null' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*" + } + + It "Should flag disallowed type cast on variable assignment" { + $def = '$fileHelper = [System.IO.File]$value' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*" + } + + It "Should NOT flag allowed type constraint" { + $def = 'function Test { param([string]$Name, [int]$Count, [hashtable]$Data) }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag allowed type cast on variable" { + $def = '[string]$name = $null' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should flag multiple type issues in same script" { + $def = @' +function Test { + param([System.IO.File]$FileHelper) + [System.IO.Directory]$dirHelper = $null + $pathHelper = [System.IO.Path]::new() +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag: 1) param type constraint, 2) variable type constraint, 3) type expression + # Note: May also flag member access if methods/properties are called on typed variables + $matchingViolations.Count | Should -BeGreaterOrEqual 3 + } + } + + Context "When PSCustomObject type cast is used" { + It "Should flag [PSCustomObject]@{} syntax" { + $def = '$obj = [PSCustomObject]@{ Name = "Test"; Value = 42 }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -BeGreaterThan 0 + $matchingViolations[0].Message | Should -BeLike "*PSCustomObject*" + } + + It "Should flag multiple [PSCustomObject]@{} instances" { + $def = @' +$obj1 = [PSCustomObject]@{ Name = "Test1" } +$obj2 = [PSCustomObject]@{ Name = "Test2" } +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + $matchingViolations.Count | Should -Be 2 + } + + It "Should NOT flag PSCustomObject as parameter type" { + $def = 'function Test { param([PSCustomObject]$InputObject) }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag New-Object PSObject" { + $def = '$obj = New-Object PSObject -Property @{ Name = "Test" }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag plain hashtables" { + $def = '$obj = @{ Name = "Test"; Value = 42 }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag [PSCustomObject] with variable (not hashtable literal)" { + $def = '$hash = @{}; $obj = [PSCustomObject]$hash' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # This is a type cast but not the @{} literal pattern + # Since PSCustomObject is in allowed list, this won't be flagged + $matchingViolations | Should -BeNullOrEmpty + } + + } + + Context "When instance methods are invoked on disallowed types" { + It "Should flag method invocation on parameter with disallowed type constraint" { + $def = @' +function Read-File { + param([System.IO.File]$FileHelper, [string]$Path) + $FileHelper.ReadAllText($Path) +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag both the type constraint AND the member access + $matchingViolations.Count | Should -BeGreaterThan 1 + # At least one violation should mention ReadAllText + ($matchingViolations.Message | Where-Object { $_ -like "*ReadAllText*" }).Count | Should -BeGreaterThan 0 + } + + It "Should flag property access on variable with disallowed type constraint" { + $def = @' +function Test { + param([System.IO.FileInfo]$FileHelper) + $fullPath = $FileHelper.FullName +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag both the type constraint AND the member access + $matchingViolations.Count | Should -BeGreaterThan 1 + # At least one violation should mention FullName + ($matchingViolations.Message | Where-Object { $_ -like "*FullName*" }).Count | Should -BeGreaterThan 0 + } + + It "Should flag method invocation on typed variable assignment" { + $def = @' +[System.IO.File]$fileHelper = $null +$result = $fileHelper.ReadAllText("C:\test.txt") +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag both the type constraint AND the member access + $matchingViolations.Count | Should -BeGreaterThan 1 + # At least one violation should mention ReadAllText + ($matchingViolations.Message | Where-Object { $_ -like "*ReadAllText*" }).Count | Should -BeGreaterThan 0 + } + + It "Should NOT flag method invocation on allowed types" { + $def = @' +function Test { + param([string]$Text) + $upper = $Text.ToUpper() + $length = $Text.Length +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty + } + + It "Should NOT flag static method calls on disallowed types (already caught by type expression check)" { + $def = '[System.IO.File]::Exists("test.txt")' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should only flag once for the type expression, not for member access + $matchingViolations.Count | Should -Be 1 + $matchingViolations[0].Message | Should -BeLike "*System.IO.File*" + } + + It "Should flag chained method calls on disallowed types" { + $def = @' +function Test { + param([System.IO.FileInfo]$FileHelper) + $result = $FileHelper.OpenText().ReadToEnd() +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag the type constraint and at least the first member access + $matchingViolations.Count | Should -BeGreaterThan 1 + } + + It "Should handle complex scenarios with multiple typed variables" { + $def = @' +function Complex-Test { + param( + [System.IO.File]$FileHelper, + [System.IO.Directory]$DirHelper, + [string]$SafeString + ) + + $data = $FileHelper.ReadAllBytes("C:\test.bin") + $DirHelper.GetFiles("C:\temp") + $upper = $SafeString.ToUpper() +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + # Should flag: 2 type constraints + 2 method invocations (not SafeString) + $matchingViolations.Count | Should -BeGreaterThan 2 + } + } + + Context "When scripts are digitally signed" { + BeforeAll { + $tempPath = Join-Path $TestDrive "SignedScripts" + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null + } + + It "Should NOT flag Add-Type in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithAddType.ps1" + $scriptContent = @' +Add-Type -TypeDefinition "public class Test { }" + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $addTypeViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*Add-Type*" + } + $addTypeViolations | Should -BeNullOrEmpty + } + + It "Should NOT flag disallowed types in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithDisallowedType.ps1" + $scriptContent = @' +$fileHelper = New-Object System.IO.FileInfo("C:\test.txt") +$data = $fileHelper.OpenText() + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $typeViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*FileInfo*type*" + } + $typeViolations | Should -BeNullOrEmpty + } + + It "Should NOT flag classes in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithClass.ps1" + $scriptContent = @' +class MyClass { + [string]$Name +} + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $classViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*class*MyClass*" + } + $classViolations | Should -BeNullOrEmpty + } + + It "Should STILL flag dot-sourcing in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithDotSource.ps1" + $scriptContent = @' +. .\Helper.ps1 +. .\U tility.ps1 + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $dotSourceViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*dot*" + } + # Dot-sourcing should still be flagged even in signed scripts + $dotSourceViolations.Count | Should -BeGreaterThan 0 + } + + It "Should STILL flag disallowed parameter types in signed scripts" { + $scriptPath = Join-Path $tempPath "SignedWithBadParam.ps1" + $scriptContent = @' +function Test { + param([System.IO.File]$FileHelper) + Write-Output "Test" +} + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $scriptPath -Value $scriptContent + $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings + $paramViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*File*" + } + # Parameter type constraints should still be flagged + $paramViolations.Count | Should -BeGreaterThan 0 + } + + It "Should STILL flag wildcard exports in signed manifests" { + $manifestPath = Join-Path $tempPath "SignedManifest.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + FunctionsToExport = '*' +} + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $wildcardViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*wildcard*" + } + # Wildcard exports should still be flagged + $wildcardViolations.Count | Should -BeGreaterThan 0 + } + + It "Should STILL flag .ps1 modules in signed manifests" { + $manifestPath = Join-Path $tempPath "SignedManifestWithScript.psd1" + $manifestContent = @' +@{ + ModuleVersion = '1.0.0' + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + RootModule = 'MyModule.ps1' +} + +# SIG # Begin signature block +# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ... +# SIG # End signature block +'@ + Set-Content -Path $manifestPath -Value $manifestContent + $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings + $scriptModuleViolations = $violations | Where-Object { + $_.RuleName -eq $violationName -and $_.Message -like "*.ps1*" + } + # Script modules should still be flagged + $scriptModuleViolations.Count | Should -BeGreaterThan 0 + } + } + + Context "Performance with large scripts" { + It "Should handle scripts with many typed variables and member invocations efficiently" { + # This test verifies the O(N+M) cache optimization + # Without caching, this would be O(N*M) and very slow + + # Build a script with many typed variables and member invocations + $scriptBuilder = [System.Text.StringBuilder]::new() + [void]$scriptBuilder.AppendLine('function Test-Performance {') + [void]$scriptBuilder.AppendLine(' param([string]$Path)') + + # Add 30 typed variable assignments + for ($i = 1; $i -le 30; $i++) { + [void]$scriptBuilder.AppendLine(" [System.IO.File]`$file$i = `$null") + } + + # Add 50 member invocations (testing cache reuse) + for ($i = 1; $i -le 50; $i++) { + $varNum = ($i % 30) + 1 + [void]$scriptBuilder.AppendLine(" `$result$i = `$file$varNum.ReadAllText(`$Path)") + } + + [void]$scriptBuilder.AppendLine('}') + $def = $scriptBuilder.ToString() + + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + + # Should detect violations (30 type constraints + 50 member accesses = 80+) + $matchingViolations.Count | Should -BeGreaterThan 50 + } + + It "Should cache results per scope correctly" { + # Test that cache is scoped properly and doesn't leak between functions + $def = @' +function Function1 { + [System.IO.File]$file1 = $null + $result1 = $file1.ReadAllText("C:\test1.txt") +} + +function Function2 { + [System.IO.Directory]$file1 = $null + $result2 = $file1.GetFiles("C:\temp") +} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings + $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName } + + # Should detect violations in both functions + # Each function has: 1 type constraint + 1 member access = 2 violations each + $matchingViolations.Count | Should -BeGreaterOrEqual 4 + + # Verify both File and Directory are mentioned + $messages = $matchingViolations.Message -join ' ' + $messages | Should -BeLike "*File*" + $messages | Should -BeLike "*Directory*" + } + } +} diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 2f8559623..5df834708 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -71,6 +71,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [UseConsistentParameterSetName](./UseConsistentParameterSetName.md) | Warning | No | | | [UseConsistentParametersKind](./UseConsistentParametersKind.md) | Warning | No | Yes | | [UseConsistentWhitespace](./UseConsistentWhitespace.md) | Warning | No | Yes | +| [UseConstrainedLanguageMode](./UseConstrainedLanguageMode.md) | Warning | No | Yes | | [UseCorrectCasing](./UseCorrectCasing.md) | Information | No | Yes | | [UseDeclaredVarsMoreThanAssignments](./UseDeclaredVarsMoreThanAssignments.md) | Warning | Yes | | | [UseLiteralInitializerForHashtable](./UseLiteralInitializerForHashtable.md) | Warning | Yes | | diff --git a/docs/Rules/UseConstrainedLanguageMode.md b/docs/Rules/UseConstrainedLanguageMode.md new file mode 100644 index 000000000..50ccfd0e2 --- /dev/null +++ b/docs/Rules/UseConstrainedLanguageMode.md @@ -0,0 +1,378 @@ +--- +description: Use patterns compatible with Constrained Language Mode +ms.date: 03/17/2026 +ms.topic: reference +title: UseConstrainedLanguageMode +--- +# UseConstrainedLanguageMode + +**Severity Level: Warning** + +## Description + +This rule identifies PowerShell patterns that are restricted or not permitted in Constrained Language Mode (CLM). + +Constrained Language Mode is a PowerShell security feature that restricts: +- .NET types that can be used +- COM objects that can be instantiated +- Commands that can be executed +- Language features that can be used + +CLM is commonly used in: +- Application Control environments (Application Control for Business, AppLocker) +- Just Enough Administration (JEA) endpoints +- Secure environments requiring additional PowerShell restrictions + +**Signed Script Behavior**: Digitally signed scripts from trusted publishers execute in Full Language Mode (FLM) even in CLM environments. The rule detects signature blocks (`# SIG # Begin signature block`) and adjusts checks accordingly - most restrictions don't apply to signed scripts, but certain checks (dot-sourcing, parameter types, manifest best practices) are always enforced. + +**Important**: The rule performs a simple text check for signature blocks and does NOT validate signature authenticity or certificate trust. Actual signature validation is performed by PowerShell at runtime. + +## Constrained Language Mode Restrictions + +### Unsigned Scripts (Full CLM Checking) + +The following are flagged for unsigned scripts: + +1. **Add-Type** - Code compilation not permitted +2. **Disallowed COM Objects** - Only Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp allowed +3. **Disallowed .NET Types** - Only ~70 allowed types (string, int, hashtable, pscredential, etc.) +4. **Type Constraints** - On parameters and variables +5. **Type Expressions** - Static type references like `[Type]::Method()` +6. **Type Casts** - Converting to disallowed types +7. **Member Invocations** - Methods/properties on disallowed types +8. **PowerShell Classes** - `class` keyword not permitted +9. **XAML/WPF** - Not permitted +10. **Invoke-Expression** - Restricted +11. **Dot-Sourcing** - May be restricted depending on the file being sourced +12. **Module Manifest Wildcards** - Wildcard exports not recommended +13. **Module Manifest .ps1 Files** - Script modules ending with .ps1 not allowed + +Always enforced, even for signed scripts + +### Signed Scripts (Selective Checking) + +For scripts with signature blocks, only these are checked: +- Dot-sourcing +- Parameter type constraints +- Module manifest wildcards (.psd1 files) +- Module manifest script modules (.psd1 files) + +## Configuration + +### Basic Configuration + +```powershell +@{ + Rules = @{ + PSUseConstrainedLanguageMode = @{ + Enable = $true + } + } +} +``` + +### Parameters + +#### Enable: bool (Default value is `$false`) + +Enable or disable the rule during ScriptAnalyzer invocation. This rule is disabled by default because not all scripts need CLM compatibility. + +#### IgnoreSignatures: bool (Default value is `$false`) + +Control signature detection behavior: + +- `$false` (default): Automatically detect signatures. Signed scripts get selective checking, unsigned get full checking. +- `$true`: Bypass signature detection. ALL scripts get full CLM checking regardless of signature status. + +```powershell +@{ + Rules = @{ + PSUseConstrainedLanguageMode = @{ + Enable = $true + IgnoreSignatures = $true # Enforce full CLM compliance for all scripts + } + } +} +``` + +**Use `IgnoreSignatures = $true` when:** +- Auditing signed scripts for complete CLM compatibility +- Preparing scripts for untrusted environments +- Enforcing strict CLM compliance organization-wide +- Development/testing to see all potential issues + +## How to Fix + +### Replace Add-Type + +Use allowed cmdlets or pre-compile assemblies. + +### Replace Disallowed COM Objects + +Use only allowed COM objects (Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp) or PowerShell cmdlets. + +### Replace Disallowed Types + +Use allowed type accelerators (`[string]`, `[int]`, `[hashtable]`, etc.) or allowed cmdlets instead of disallowed .NET types. + +### Replace PowerShell Classes + +Use `New-Object PSObject` with `Add-Member` or hashtables instead of classes. + +**Important**: `[PSCustomObject]@{}` syntax is NOT allowed in CLM because it uses type casting. + +### Avoid XAML + +Don't use WPF/XAML in CLM-compatible scripts. + +### Replace Invoke-Expression + +Use direct execution (`&`) or safer alternatives. + +### Replace Dot-Sourcing + +Use modules with Import-Module instead of dot-sourcing when possible. + +### Fix Module Manifests + +- Replace wildcard exports (`*`) with explicit lists +- Use `.psm1` or `.dll` instead of `.ps1` for RootModule/NestedModules +- Don't use ScriptsToProcess as it loads in the caller's scope and will be blocked. + +## Examples + +### Example 1: Add-Type + +#### Wrong + +```powershell +Add-Type -TypeDefinition @" + public class Helper { + public static string DoWork() { return "Done"; } + } +"@ +``` + +#### Correct + +```powershell + # Code sign your scripts/modules using proper signing tools + # (for example, Set-AuthenticodeSignature or external signing processes) + # Use allowed cmdlets instead of Add-Type-defined types where possible + # Or pre-compile, sign, and load the assembly (for example, via Add-Type -Path) +``` + +### Example 2: COM Objects + +#### Wrong + +```powershell +$excel = New-Object -ComObject Excel.Application +``` + +#### Correct + +```powershell +# Use allowed COM object +$dict = New-Object -ComObject Scripting.Dictionary + +# Or use PowerShell cmdlets +Import-Excel -Path $file # From ImportExcel module +``` + +### Example 3: Disallowed Types + +#### Wrong + +```powershell +# Type constraint and member invocation flagged +function Download-File { + param([System.Net.WebClient]$Client) + $Client.DownloadString($url) +} + +# Type cast and method call flagged +[System.Net.WebClient]$client = New-Object System.Net.WebClient +$data = $client.DownloadData($url) +``` + +#### Correct + +```powershell +# Use allowed cmdlets +function Download-File { + param([string]$Url) + Invoke-WebRequest -Uri $Url +} + +# Use allowed types +function Process-Text { + param([string]$Text) + $upper = $Text.ToUpper() # String methods are allowed +} +``` + +### Example 4: PowerShell Classes + +#### Wrong + +```powershell +class MyClass { + [string]$Name + + [string]GetInfo() { + return $this.Name + } +} + +# Also wrong - uses type cast +$obj = [PSCustomObject]@{ + Name = "Test" +} +``` + +#### Correct + +```powershell +# Option 1: New-Object PSObject with Add-Member +$obj = New-Object PSObject -Property @{ + Name = "Test" +} + +$obj | Add-Member -MemberType ScriptMethod -Name GetInfo -Value { + return $this.Name +} + +Add-Member -InputObject $obj -NotePropertyMembers @{"Number" = 42} + +# Option 2: Hashtable +$obj = @{ + Name = "Test" + Number = 42 +} +``` + +### Example 5: Module Manifests + +#### Wrong + +```powershell +@{ + ModuleVersion = '1.0.0' + RootModule = 'MyModule.ps1' # .ps1 not recommended + FunctionsToExport = '*' # Wildcard not recommended + CmdletsToExport = '*' +} +``` + +#### Correct + +```powershell +@{ + ModuleVersion = '1.0.0' + RootModule = 'MyModule.psm1' # Use .psm1 or .dll + FunctionsToExport = @( # Explicit list + 'Get-MyFunction' + 'Set-MyFunction' + ) + CmdletsToExport = @() +} +``` + +### Example 6: Array Types + +#### Wrong + +```powershell +# Disallowed type in array +param([System.Net.WebClient[]]$Clients) +``` + +#### Correct + +```powershell +# Allowed types in arrays are fine +param([string[]]$Names) +param([int[]]$Numbers) +param([hashtable[]]$Configuration) +``` + +## Detailed Restrictions + +### 1. Add-Type +`Add-Type` allows compiling arbitrary C# code and is not permitted in CLM. + +**Enforced For**: Unsigned scripts only + +### 2. COM Objects +Only three COM objects are allowed: +- `Scripting.Dictionary` +- `Scripting.FileSystemObject` +- `VBScript.RegExp` + +All others (Excel.Application, WScript.Shell, etc.) are flagged. + +**Enforced For**: Unsigned scripts only + +### 3. .NET Types +Only ~70 allowed types including: +- Primitives: `string`, `int`, `bool`, `byte`, `char`, `datetime`, `decimal`, `double`, etc. +- Collections: `hashtable`, `array`, `arraylist` +- PowerShell: `pscredential`, `psobject`, `securestring` +- Utilities: `regex`, `guid`, `version`, `uri`, `xml` +- Arrays: `string[]`, `int[][]`, etc. (array of any allowed type) + +The rule checks type usage in: +- Parameter type constraints (**always enforced, even for signed scripts**) +- Variable type constraints +- New-Object -TypeName +- Type expressions (`[Type]::Method()`) +- Type casts (`[Type]$variable`) +- Member invocations on typed variables + +**Enforced For**: Parameter constraints always; others unsigned only + +### 4. PowerShell Classes +The `class` keyword is not permitted. Use `New-Object PSObject` with `Add-Member` or hashtables. + +**Note**: `[PSCustomObject]@{}` is also not allowed because it uses type casting. + +**Enforced For**: Unsigned scripts only + +### 5. XAML/WPF +XAML and WPF are not permitted in CLM. + +**Enforced For**: Unsigned scripts only + +### 6. Invoke-Expression +`Invoke-Expression` is restricted in CLM. + +**Enforced For**: Unsigned scripts only + +### 7. Dot-Sourcing +Dot-sourcing (`. $PSScriptRoot\script.ps1`) may be restricted depending on source location. + +**Enforced For**: ALL scripts (unsigned and signed) + +### 8. Module Manifest Best Practices + +#### Wildcard Exports +Don't use `*` in: `FunctionsToExport`, `CmdletsToExport`, `AliasesToExport`, `VariablesToExport` + +Use explicit lists for security and clarity. + +**Enforced For**: ALL .psd1 files (unsigned and signed) + +#### Script Module Files +Don't use `.ps1` files in: `RootModule`, `ModuleToProcess`, `NestedModules` + +Use `.psm1` (script modules) or `.dll` (binary modules) for better performance and compatibility. + +**Enforced For**: ALL .psd1 files (unsigned and signed) + +## More Information + +- [About Language Modes](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_language_modes) +- [PowerShell Constrained Language Mode](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/) +- [PowerShell Module Function Export in Constrained Language](https://devblogs.microsoft.com/powershell/powershell-module-function-export-in-constrained-language/) +- [PowerShell Constrained Language Mode and the Dot-Source Operator](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode-and-the-dot-source-operator/) From f05704df81b2aca17dc027ee39b3fce106d418fc Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:54:19 -0700 Subject: [PATCH 12/21] v1.25.0: Thanks to all the new contributors! (#2169) * v1.25.0: Thanks to all the new contributors! * Add internal metadata --- CHANGELOG.MD | 41 ++++++++++++++++++++++++++++++++ Directory.Build.props | 2 +- docs/Cmdlets/PSScriptAnalyzer.md | 2 +- es-metadata.yml | 12 ++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 es-metadata.yml diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 76352e7c7..16dc799c1 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,46 @@ # CHANGELOG +## [1.25.0](https://github.com/PowerShell/PSScriptAnalyzer/releases/tag/1.25.0) + +### What's Changed +* Add configuration instructions for UseCorrectCasing (again) by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/2090 +* Fix for PSUseConsistantWhiteSpace when using statement is present by @AlexandraDorey in https://github.com/PowerShell/PSScriptAnalyzer/pull/2091 +* Exclude PSNativeCommandArgumentPassing variable by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2093 +* Update version check in PSScriptAnalyzer.psm1 to align with SMA version for PowerShell 7 by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2107 +* Change CommandInfo lookup for commands in the form `module\cmdletName` by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2125 +* PSAvoidDefaultValueForMandatoryParameter: Fix param block and parameter set handling by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2121 +* Add AvoidReservedWordsAsFunctionNames Rule by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2128 +* Update .NET SDK, PowerShell SDK, Newtonsoft.Json and codeowners by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2143 +* Preserve braced member access in `UseConsistentWhitespace` by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2140 +* Update docs and diagnostic message for UseCorrectCasing by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2138 +* Fix SuppressMessage CustomRule by @HeyItsGilbert in https://github.com/PowerShell/PSScriptAnalyzer/pull/2142 +* Sync rules docs from docs repo by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/2144 +* Allow contributors to run CI manually by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2153 +* Remove Conditional Compilation and Runtime Checks for v3 or v4 by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2150 +* Add UseConsistentParametersKind rule by @Haimasker in https://github.com/PowerShell/PSScriptAnalyzer/pull/2149 +* Fix typos in resource strings and associated C# references by @casuffitsharp in https://github.com/PowerShell/PSScriptAnalyzer/pull/2163 +* Optimise LINQ queries by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2160 +* AlignAssignmentStatement overhaul to fix issues and include handing of Enums. by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2132 +* Dynamically count expected rules in GetScriptAnalyzerRule test by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2167 +* Add UseConsistentParameterSetName Rule by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2124 +* feat(tests): added closing bracket test for formatting presets by @o-l-a-v in https://github.com/PowerShell/PSScriptAnalyzer/pull/2161 +* Add UseSingleValueFromPipelineParameter Rule by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2119 +* Added user-friendly installation instructions by @admercs in https://github.com/PowerShell/PSScriptAnalyzer/pull/2141 +* Update .NET SDK, PowerShell SDK dependencies by @Copilot in https://github.com/PowerShell/PSScriptAnalyzer/pull/2168 +* Add Optional PSUseConstrainedLanguageMode rule by @joshcorr in https://github.com/PowerShell/PSScriptAnalyzer/pull/2165 + +### New Contributors +* @AlexandraDorey made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2091 +* @HeyItsGilbert made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2142 +* @Haimasker made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2149 +* @casuffitsharp made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2163 +* @o-l-a-v made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2161 +* @admercs made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2141 +* @Copilot made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2168 +* @joshcorr made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2165 + +**Full Changelog**: https://github.com/PowerShell/PSScriptAnalyzer/compare/1.24.0...1.25.0 + ## [1.24.0](https://github.com/PowerShell/PSScriptAnalyzer/releases/tag/1.24.0) ### What's Changed diff --git a/Directory.Build.props b/Directory.Build.props index f7d809c1d..a9ac6cc61 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 1.24.0 + 1.25.0 true diff --git a/docs/Cmdlets/PSScriptAnalyzer.md b/docs/Cmdlets/PSScriptAnalyzer.md index 0af4bf2b5..3a5bc1d0a 100644 --- a/docs/Cmdlets/PSScriptAnalyzer.md +++ b/docs/Cmdlets/PSScriptAnalyzer.md @@ -1,6 +1,6 @@ --- Download Help Link: https://aka.ms/ps-modules-help -Help Version: 1.24.0 +Help Version: 1.25.0 Locale: en-US Module Guid: d6245802-193d-4068-a631-8863a4342a18 Module Name: PSScriptAnalyzer diff --git a/es-metadata.yml b/es-metadata.yml new file mode 100644 index 000000000..e4dc3924a --- /dev/null +++ b/es-metadata.yml @@ -0,0 +1,12 @@ +schemaVersion: 1.0.0 +providers: +- provider: InventoryAsCode + version: 1.0.0 + metadata: + isProduction: true + accountableOwners: + service: cef1de07-99d6-45df-b907-77d0066032ec + routing: + defaultAreaPath: + org: msazure + path: One\MGMT\Compute\Powershell\Powershell From 9b55ac29a91e6de07defa93bdf9f417b1468f66b Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:51:29 -0700 Subject: [PATCH 13/21] Update copyright year (#2171) --- Engine/PSScriptAnalyzer.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Engine/PSScriptAnalyzer.psd1 b/Engine/PSScriptAnalyzer.psd1 index 49fb93227..993677254 100644 --- a/Engine/PSScriptAnalyzer.psd1 +++ b/Engine/PSScriptAnalyzer.psd1 @@ -20,7 +20,7 @@ GUID = 'd6245802-193d-4068-a631-8863a4342a18' CompanyName = 'Microsoft Corporation' # Copyright statement for this module -Copyright = '(c) Microsoft Corporation 2025. All rights reserved.' +Copyright = '(c) Microsoft Corporation 2026. All rights reserved.' # Description of the functionality provided by this module Description = 'PSScriptAnalyzer provides script analysis and checks for potential code defects in the scripts by applying a group of built-in or customized rules on the scripts being analyzed.' From a143b9ff8b54ccbae1224c593bd78b6f46579b14 Mon Sep 17 00:00:00 2001 From: Sean Wheeler Date: Thu, 2 Apr 2026 11:00:32 -0500 Subject: [PATCH 14/21] Sync docs edits from docs repo (#2170) * Sync docs edits from docs repo * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/Rules/AlignAssignmentStatement.md | 50 ++++----- docs/Rules/AvoidLongLines.md | 6 +- docs/Rules/README.md | 2 +- docs/Rules/UseConsistentParameterSetName.md | 41 ++++--- docs/Rules/UseConsistentParametersKind.md | 54 ++++++---- docs/Rules/UseConstrainedLanguageMode.md | 100 ++++++++++++------ .../UseSingleValueFromPipelineParameter.md | 40 ++++--- 7 files changed, 171 insertions(+), 122 deletions(-) diff --git a/docs/Rules/AlignAssignmentStatement.md b/docs/Rules/AlignAssignmentStatement.md index 28ca9f47f..573b54b68 100644 --- a/docs/Rules/AlignAssignmentStatement.md +++ b/docs/Rules/AlignAssignmentStatement.md @@ -1,6 +1,6 @@ --- description: Align assignment statement -ms.date: 06/28/2023 +ms.date: 03/20/2026 ms.topic: reference title: AlignAssignmentStatement --- @@ -10,14 +10,13 @@ title: AlignAssignmentStatement ## Description -Consecutive assignment statements are more readable when they're aligned. -Assignments are considered aligned when their `equals` signs line up vertically. +Consecutive assignment statements are more readable when they're aligned. Assignments are considered +aligned when their `equals` signs line up vertically. -This rule looks at the key-value pairs in hashtables (including DSC -configurations) as well as enum definitions. +This rule looks at the key-value pairs in hashtables (including DSC configurations) as well as enum +definitions. -Consider the following example which has a hashtable and enum which are not -aligned. +Consider the following example with a hashtable and enum that isn't aligned. ```powershell $hashtable = @{ @@ -45,8 +44,8 @@ enum Enum { } ``` -The rule ignores any assignments within hashtables and enums which are on the -same line as others. For example, the rule ignores `$h = @{a = 1; b = 2}`. +The rule ignores any assignments within hashtables and enums which are on the same line as others. +For example, the rule ignores `$h = @{a = 1; b = 2}`. ## Configuration @@ -71,15 +70,14 @@ Enable or disable the rule during ScriptAnalyzer invocation. #### CheckHashtable: bool (Default value is `$true`) -Enforce alignment of assignment statements in a hashtable and in a DSC -Configuration. There is only one setting for hashtable and DSC configuration -because the property value pairs in a DSC configuration are parsed as key-value -pairs of a hashtable. +Enforce alignment of assignment statements in a hashtable and in a DSC Configuration. There is only +one setting for hashtable and DSC configuration because the property value pairs in a DSC +configuration are parsed as key-value pairs of a hashtable. #### AlignHashtableKvpWithInterveningComment: bool (Default value is `$true`) -Include key-value pairs in the alignment that have an intervening comment - that -is to say a comment between the key name and the equals sign. +Include key-value pairs in the alignment that have an intervening comment - that is to say a comment +between the key name and the equals sign. Consider the following: @@ -91,8 +89,7 @@ $hashtable = @{ } ``` -With this setting disabled, the line with the comment is ignored, and it would -be aligned like so: +With this setting disabled, the line with the comment is ignored, and it would be aligned like so: ```powershell $hashtable = @{ @@ -118,8 +115,8 @@ Enforce alignment of assignment statements of an Enum definition. #### AlignEnumMemberWithInterveningComment: bool (Default value is `$true`) -Include enum members in the alignment that have an intervening comment - that -is to say a comment between the member name and the equals sign. +Include enum members in the alignment that have an intervening comment - that is to say a comment +between the member name and the equals sign. Consider the following: @@ -131,8 +128,7 @@ enum Enum { } ``` -With this setting disabled, the line with the comment is ignored, and it would -be aligned like so: +With this setting disabled, the line with the comment is ignored, and it would be aligned like so: ```powershell enum Enum { @@ -154,9 +150,8 @@ enum Enum { #### IncludeValuelessEnumMembers: bool (Default value is `$true`) -Include enum members in the alignment that don't have an initial value - that -is to say they don't have an equals sign. Enum's don't need to be given a value -when they're defined. +Include enum members in the alignment that don't have an explicitly assigned value. Enums don't +need to be given a value when they're defined. Consider the following: @@ -168,8 +163,8 @@ enum Enum { } ``` -With this setting disabled the third line which has no value is not considered -when choosing where to align assignments. It would be aligned like so: +With this setting disabled, the third line, which has no value, isn't considered when choosing where +to align assignments. It would be aligned like so: ```powershell enum Enum { @@ -179,8 +174,7 @@ enum Enum { } ``` -With it enabled, the valueless member is included in alignment as if it had a -value: +With it enabled, the valueless member is included in alignment as if it had a value: ```powershell enum Enum { diff --git a/docs/Rules/AvoidLongLines.md b/docs/Rules/AvoidLongLines.md index c36daaa86..89474c8b8 100644 --- a/docs/Rules/AvoidLongLines.md +++ b/docs/Rules/AvoidLongLines.md @@ -1,6 +1,6 @@ --- description: Avoid long lines -ms.date: 04/29/2025 +ms.date: 03/20/2026 ms.topic: reference title: AvoidLongLines --- @@ -10,8 +10,8 @@ title: AvoidLongLines ## Description -The length of lines, including leading spaces (indentation), should be less than the configured number -of characters. The default length is 120 characters. +The length of lines, including leading spaces (indentation), should be less than the configured +number of characters. The default length is 120 characters. > [!NOTE] > This rule isn't enabled by default. The user needs to enable it through settings. diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 5df834708..fca031e33 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -1,6 +1,6 @@ --- description: List of PSScriptAnalyzer rules -ms.date: 03/27/2024 +ms.date: 03/20/2026 ms.topic: reference title: List of PSScriptAnalyzer rules --- diff --git a/docs/Rules/UseConsistentParameterSetName.md b/docs/Rules/UseConsistentParameterSetName.md index 5ad33eb22..6e9a4598f 100644 --- a/docs/Rules/UseConsistentParameterSetName.md +++ b/docs/Rules/UseConsistentParameterSetName.md @@ -1,6 +1,6 @@ --- description: Use consistent parameter set names and proper parameter set configuration. -ms.date: 08/19/2025 +ms.date: 03/20/2026 ms.topic: reference title: UseConsistentParameterSetName --- @@ -11,18 +11,25 @@ title: UseConsistentParameterSetName ## Description -Parameter set names in PowerShell are case-sensitive, unlike most other PowerShell elements. This rule ensures consistent casing and proper configuration of parameter sets to avoid runtime errors and improve code clarity. +Parameter set names in PowerShell are case-sensitive, unlike most other PowerShell elements. This +rule ensures consistent casing and proper configuration of parameter sets to avoid runtime errors +and improve code clarity. The rule performs five different checks: -1. **Missing DefaultParameterSetName** - Warns when parameter sets are used but no default is specified -2. **Multiple parameter declarations** - Detects when a parameter is declared multiple times in the same parameter set. This is ultimately a runtime exception - this check helps catch it sooner. -3. **Case mismatch between DefaultParameterSetName and ParameterSetName** - Ensures consistent casing -4. **Case mismatch between different ParameterSetName values** - Ensures all references to the same parameter set use identical casing -5. **Parameter set names containing newlines** - Warns against using newline characters in parameter set names +1. **Missing DefaultParameterSetName** - Warns when parameter sets are used but no default is + specified +1. **Multiple parameter declarations** - Detects when a parameter is declared multiple times in the + same parameter set. This is ultimately a runtime exception - this check helps catch it sooner. +1. **Case mismatch between DefaultParameterSetName and ParameterSetName** - Ensures consistent + casing +1. **Case mismatch between different ParameterSetName values** - Ensures all references to the same + parameter set use identical casing +1. **Parameter set names containing newlines** - Warns against using newline characters in parameter + set names > [!NOTE] -> This rule is not enabled by default. The user needs to enable it through settings. +> This rule isn't enabled by default. The user needs to enable it through settings. ## How @@ -43,7 +50,7 @@ function Get-Data { param( [Parameter(ParameterSetName='ByName')] [string]$Name, - + [Parameter(ParameterSetName='ByID')] [int]$ID ) @@ -55,7 +62,7 @@ function Get-Data { param( [Parameter(ParameterSetName='byname')] [string]$Name, - + [Parameter(ParameterSetName='ByID')] [int]$ID ) @@ -67,7 +74,7 @@ function Get-Data { param( [Parameter(ParameterSetName='ByName')] [string]$Name, - + [Parameter(ParameterSetName='byname')] [string]$DisplayName ) @@ -100,11 +107,11 @@ function Get-Data { param( [Parameter(ParameterSetName='ByName', Mandatory)] [string]$Name, - + [Parameter(ParameterSetName='ByName')] [Parameter(ParameterSetName='ByID')] [string]$ComputerName, - + [Parameter(ParameterSetName='ByID', Mandatory)] [int]$ID ) @@ -129,7 +136,9 @@ Rules = @{ ## Notes -- Parameter set names are case-sensitive in PowerShell, making this different from most other PowerShell elements +- Parameter set names are case-sensitive in PowerShell, making this different from most other + PowerShell elements - The first occurrence of a parameter set name in your code is treated as the canonical casing -- Parameters without [Parameter()] attributes are automatically part of all parameter sets -- It's a PowerShell best practice to always specify a DefaultParameterSetName when using parameter sets \ No newline at end of file +- Parameters without `[Parameter()]` attributes are automatically part of all parameter sets +- It's a PowerShell best practice to always specify a `DefaultParameterSetName` when using parameter + sets \ No newline at end of file diff --git a/docs/Rules/UseConsistentParametersKind.md b/docs/Rules/UseConsistentParametersKind.md index 04a323b3d..20a470f30 100644 --- a/docs/Rules/UseConsistentParametersKind.md +++ b/docs/Rules/UseConsistentParametersKind.md @@ -1,26 +1,36 @@ +--- +description: Use the same pattern when defining parameters. +ms.date: 03/20/2026 +ms.topic: reference +title: UseConsistentParametersKind +--- # UseConsistentParametersKind **Severity Level: Warning** ## Description -All functions should have same parameters definition kind specified in the rule. -Possible kinds are: -1. `Inline`, i.e.: -```PowerShell -function f([Parameter()]$FirstParam) { - return -} -``` -2. `ParamBlock`, i.e.: -```PowerShell -function f { - param([Parameter()]$FirstParam) - return -} -``` +All functions should use the same pattern when defining parameters. Possible pattern types are: -* For information: in simple scenarios both function definitions above may be considered as equal. Using this rule as-is is more for consistent code-style than functional, but it can be useful in combination with other rules. +1. `Inline` + + ```powershell + function f([Parameter()]$FirstParam) { + return + } + ``` + +1. `ParamBlock` + + ```powershell + function f { + param([Parameter()]$FirstParam) + return + } + ``` + +In simple scenarios, both function definitions shown are considered to be equal. The purpose of this +rule is to enforce consistent code style across the codebase. ## How to Fix @@ -28,8 +38,9 @@ Rewrite function so it defines parameters as specified in the rule ## Example -### When the rule sets parameters definition kind to 'Inline': -```PowerShell +When the rule sets parameters definition kind to `Inline`: + +```powershell # Correct function f([Parameter()]$FirstParam) { return @@ -42,9 +53,10 @@ function g { } ``` -### When the rule sets parameters definition kind to 'ParamBlock': -```PowerShell -# Inorrect +When the rule sets parameters definition kind to `ParamBlock`: + +```powershell +# Incorrect function f([Parameter()]$FirstParam) { return } diff --git a/docs/Rules/UseConstrainedLanguageMode.md b/docs/Rules/UseConstrainedLanguageMode.md index 50ccfd0e2..e12476531 100644 --- a/docs/Rules/UseConstrainedLanguageMode.md +++ b/docs/Rules/UseConstrainedLanguageMode.md @@ -1,6 +1,6 @@ --- description: Use patterns compatible with Constrained Language Mode -ms.date: 03/17/2026 +ms.date: 03/20/2026 ms.topic: reference title: UseConstrainedLanguageMode --- @@ -10,22 +10,31 @@ title: UseConstrainedLanguageMode ## Description -This rule identifies PowerShell patterns that are restricted or not permitted in Constrained Language Mode (CLM). +This rule identifies PowerShell patterns that are restricted or not permitted in Constrained +Language Mode (CLM). Constrained Language Mode is a PowerShell security feature that restricts: + - .NET types that can be used - COM objects that can be instantiated - Commands that can be executed - Language features that can be used CLM is commonly used in: + - Application Control environments (Application Control for Business, AppLocker) - Just Enough Administration (JEA) endpoints - Secure environments requiring additional PowerShell restrictions -**Signed Script Behavior**: Digitally signed scripts from trusted publishers execute in Full Language Mode (FLM) even in CLM environments. The rule detects signature blocks (`# SIG # Begin signature block`) and adjusts checks accordingly - most restrictions don't apply to signed scripts, but certain checks (dot-sourcing, parameter types, manifest best practices) are always enforced. +Digitally signed scripts from trusted publishers execute in Full Language Mode (FLM) even in CLM +environments. The rule detects signature blocks (`# SIG # Begin signature block`) and adjusts checks +accordingly. Most restrictions don't apply to signed scripts, but certain checks (dot-sourcing, +parameter types, manifest best practices) are always enforced. -**Important**: The rule performs a simple text check for signature blocks and does NOT validate signature authenticity or certificate trust. Actual signature validation is performed by PowerShell at runtime. +> [!IMPORTANT] +> The rule performs a simple text check for signature blocks and does NOT validate signature +> authenticity or certificate trust. Actual signature validation is performed by PowerShell at +> runtime. ## Constrained Language Mode Restrictions @@ -34,24 +43,26 @@ CLM is commonly used in: The following are flagged for unsigned scripts: 1. **Add-Type** - Code compilation not permitted -2. **Disallowed COM Objects** - Only Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp allowed -3. **Disallowed .NET Types** - Only ~70 allowed types (string, int, hashtable, pscredential, etc.) -4. **Type Constraints** - On parameters and variables -5. **Type Expressions** - Static type references like `[Type]::Method()` -6. **Type Casts** - Converting to disallowed types -7. **Member Invocations** - Methods/properties on disallowed types -8. **PowerShell Classes** - `class` keyword not permitted -9. **XAML/WPF** - Not permitted -10. **Invoke-Expression** - Restricted -11. **Dot-Sourcing** - May be restricted depending on the file being sourced -12. **Module Manifest Wildcards** - Wildcard exports not recommended -13. **Module Manifest .ps1 Files** - Script modules ending with .ps1 not allowed +1. **Disallowed COM Objects** - Only Scripting.Dictionary, Scripting.FileSystemObject, + VBScript.RegExp allowed +1. **Disallowed .NET Types** - Only ~70 allowed types (string, int, hashtable, pscredential, etc.) +1. **Type Constraints** - On parameters and variables +1. **Type Expressions** - Static type references like `[Type]::Method()` +1. **Type Casts** - Converting to disallowed types +1. **Member Invocations** - Methods/properties on disallowed types +1. **PowerShell Classes** - `class` keyword not permitted +1. **XAML/WPF** - Not permitted +1. **Invoke-Expression** - Restricted +1. **Dot-Sourcing** - May be restricted depending on the file being sourced +1. **Module Manifest Wildcards** - Wildcard exports not recommended +1. **Module Manifest .ps1 Files** - Script modules ending with .ps1 not allowed Always enforced, even for signed scripts ### Signed Scripts (Selective Checking) For scripts with signature blocks, only these are checked: + - Dot-sourcing - Parameter type constraints - Module manifest wildcards (.psd1 files) @@ -75,14 +86,17 @@ For scripts with signature blocks, only these are checked: #### Enable: bool (Default value is `$false`) -Enable or disable the rule during ScriptAnalyzer invocation. This rule is disabled by default because not all scripts need CLM compatibility. +Enable or disable the rule during ScriptAnalyzer invocation. This rule is disabled by default +because not all scripts need CLM compatibility. #### IgnoreSignatures: bool (Default value is `$false`) Control signature detection behavior: -- `$false` (default): Automatically detect signatures. Signed scripts get selective checking, unsigned get full checking. -- `$true`: Bypass signature detection. ALL scripts get full CLM checking regardless of signature status. +- `$false` (default): Automatically detect signatures. Signed scripts get selective checking, + unsigned get full checking. +- `$true`: Bypass signature detection. ALL scripts get full CLM checking regardless of signature + status. ```powershell @{ @@ -95,7 +109,8 @@ Control signature detection behavior: } ``` -**Use `IgnoreSignatures = $true` when:** +Use `IgnoreSignatures = $true` when: + - Auditing signed scripts for complete CLM compatibility - Preparing scripts for untrusted environments - Enforcing strict CLM compliance organization-wide @@ -109,17 +124,20 @@ Use allowed cmdlets or pre-compile assemblies. ### Replace Disallowed COM Objects -Use only allowed COM objects (Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp) or PowerShell cmdlets. +Use only allowed COM objects (Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp) or +PowerShell cmdlets. ### Replace Disallowed Types -Use allowed type accelerators (`[string]`, `[int]`, `[hashtable]`, etc.) or allowed cmdlets instead of disallowed .NET types. +Use allowed type accelerators (`[string]`, `[int]`, `[hashtable]`, etc.) or allowed cmdlets instead +of disallowed .NET types. ### Replace PowerShell Classes Use `New-Object PSObject` with `Add-Member` or hashtables instead of classes. -**Important**: `[PSCustomObject]@{}` syntax is NOT allowed in CLM because it uses type casting. +> [!IMPORTANT] +> `[PSCustomObject]@{}` syntax is NOT allowed in CLM because it uses type casting. ### Avoid XAML @@ -135,9 +153,9 @@ Use modules with Import-Module instead of dot-sourcing when possible. ### Fix Module Manifests -- Replace wildcard exports (`*`) with explicit lists -- Use `.psm1` or `.dll` instead of `.ps1` for RootModule/NestedModules -- Don't use ScriptsToProcess as it loads in the caller's scope and will be blocked. +- Replace wildcard exports (`*`) with explicit lists. +- Use `.psm1` or `.dll` instead of `.ps1` for RootModule/NestedModules. +- Don't use `ScriptsToProcess`. These scripts are loaded in the caller's scope and are blocked. ## Examples @@ -219,7 +237,7 @@ function Process-Text { ```powershell class MyClass { [string]$Name - + [string]GetInfo() { return $this.Name } @@ -300,12 +318,15 @@ param([hashtable[]]$Configuration) ## Detailed Restrictions ### 1. Add-Type -`Add-Type` allows compiling arbitrary C# code and is not permitted in CLM. + +`Add-Type` allows compiling arbitrary C# code and isn't permitted in CLM. **Enforced For**: Unsigned scripts only ### 2. COM Objects + Only three COM objects are allowed: + - `Scripting.Dictionary` - `Scripting.FileSystemObject` - `VBScript.RegExp` @@ -315,7 +336,9 @@ All others (Excel.Application, WScript.Shell, etc.) are flagged. **Enforced For**: Unsigned scripts only ### 3. .NET Types + Only ~70 allowed types including: + - Primitives: `string`, `int`, `bool`, `byte`, `char`, `datetime`, `decimal`, `double`, etc. - Collections: `hashtable`, `array`, `arraylist` - PowerShell: `pscredential`, `psobject`, `securestring` @@ -323,6 +346,7 @@ Only ~70 allowed types including: - Arrays: `string[]`, `int[][]`, etc. (array of any allowed type) The rule checks type usage in: + - Parameter type constraints (**always enforced, even for signed scripts**) - Variable type constraints - New-Object -TypeName @@ -333,6 +357,7 @@ The rule checks type usage in: **Enforced For**: Parameter constraints always; others unsigned only ### 4. PowerShell Classes + The `class` keyword is not permitted. Use `New-Object PSObject` with `Add-Member` or hashtables. **Note**: `[PSCustomObject]@{}` is also not allowed because it uses type casting. @@ -340,16 +365,19 @@ The `class` keyword is not permitted. Use `New-Object PSObject` with `Add-Member **Enforced For**: Unsigned scripts only ### 5. XAML/WPF + XAML and WPF are not permitted in CLM. **Enforced For**: Unsigned scripts only ### 6. Invoke-Expression + `Invoke-Expression` is restricted in CLM. **Enforced For**: Unsigned scripts only ### 7. Dot-Sourcing + Dot-sourcing (`. $PSScriptRoot\script.ps1`) may be restricted depending on source location. **Enforced For**: ALL scripts (unsigned and signed) @@ -357,6 +385,7 @@ Dot-sourcing (`. $PSScriptRoot\script.ps1`) may be restricted depending on sourc ### 8. Module Manifest Best Practices #### Wildcard Exports + Don't use `*` in: `FunctionsToExport`, `CmdletsToExport`, `AliasesToExport`, `VariablesToExport` Use explicit lists for security and clarity. @@ -364,6 +393,7 @@ Use explicit lists for security and clarity. **Enforced For**: ALL .psd1 files (unsigned and signed) #### Script Module Files + Don't use `.ps1` files in: `RootModule`, `ModuleToProcess`, `NestedModules` Use `.psm1` (script modules) or `.dll` (binary modules) for better performance and compatibility. @@ -372,7 +402,13 @@ Use `.psm1` (script modules) or `.dll` (binary modules) for better performance a ## More Information -- [About Language Modes](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_language_modes) -- [PowerShell Constrained Language Mode](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/) -- [PowerShell Module Function Export in Constrained Language](https://devblogs.microsoft.com/powershell/powershell-module-function-export-in-constrained-language/) -- [PowerShell Constrained Language Mode and the Dot-Source Operator](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode-and-the-dot-source-operator/) +- [About Language Modes][01] +- [PowerShell Constrained Language Mode][03] +- [PowerShell Module Function Export in Constrained Language][04] +- [PowerShell Constrained Language Mode and the Dot-Source Operator][02] + + +[01]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_language_modes +[02]: https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode-and-the-dot-source-operator/ +[03]: https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/ +[04]: https://devblogs.microsoft.com/powershell/powershell-module-function-export-in-constrained-language/ diff --git a/docs/Rules/UseSingleValueFromPipelineParameter.md b/docs/Rules/UseSingleValueFromPipelineParameter.md index bfaa3fe6a..92c350eb4 100644 --- a/docs/Rules/UseSingleValueFromPipelineParameter.md +++ b/docs/Rules/UseSingleValueFromPipelineParameter.md @@ -1,6 +1,6 @@ --- description: Use at most a single ValueFromPipeline parameter per parameter set. -ms.date: 08/08/2025 +ms.date: 03/20/2026 ms.topic: reference title: UseSingleValueFromPipelineParameter --- @@ -10,18 +10,15 @@ title: UseSingleValueFromPipelineParameter ## Description -Parameter sets should have at most one parameter marked as -`ValueFromPipeline = true`. +Parameter sets should have at most one parameter marked as `ValueFromPipeline = true`. -This rule identifies functions where multiple parameters within the same -parameter set have `ValueFromPipeline` set to `true` (either explicitly or -implicitly). +This rule identifies functions where multiple parameters within the same parameter set have +`ValueFromPipeline` set to `true` (either explicitly or implicitly). ## How -Ensure that only one parameter per parameter set accepts pipeline input by -value. If you need multiple parameters to accept different types of pipeline -input, use separate parameter sets. +Ensure that only one parameter per parameter set accepts pipeline input by value. If you need +multiple parameters to accept different types of pipeline input, use separate parameter sets. ## Example @@ -33,11 +30,11 @@ function Process-Data { param( [Parameter(ValueFromPipeline)] [string] $InputData, - + [Parameter(ValueFromPipeline)] [string] $ProcessingMode ) - + process { Write-Output "$ProcessingMode`: $InputData" } @@ -53,7 +50,7 @@ function Process-Data { param( [Parameter(ValueFromPipeline)] [string] $InputData, - + [Parameter(Mandatory)] [string] $ProcessingMode ) @@ -62,10 +59,11 @@ function Process-Data { } } ``` + ## Suppression -To suppress this rule for a specific parameter set, use the `SuppressMessage` -attribute with the parameter set name: +To suppress this rule for a specific parameter set, use the `SuppressMessage` attribute with the +parameter set name: ```powershell function Process-Data { @@ -74,7 +72,7 @@ function Process-Data { param( [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] [string] $InputData, - + [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] [string] $ProcessingMode ) @@ -92,10 +90,10 @@ For the default parameter set, use `'default'` as the suppression target: ## Notes -- This rule applies to both explicit `ValueFromPipeline = $true` and implicit - `ValueFromPipeline` (which is the same as using `= $true`) +- This rule applies to both explicit `ValueFromPipeline = $true` and implicit `ValueFromPipeline` + (which is the same as using `= $true`) - Parameters with `ValueFromPipeline=$false` are not flagged by this rule -- The rule correctly handles the default parameter set (`__AllParameterSets`) - and named parameter sets -- Different parameter sets can each have their own single `ValueFromPipeline` - parameter without triggering this rule +- The rule correctly handles the default parameter set (`__AllParameterSets`) and named parameter + sets +- Different parameter sets can each have their own single `ValueFromPipeline` parameter without + triggering this rule From d97ddd4862eaa0e5981d10fcfec7df957232e9bc Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Mon, 4 May 2026 21:24:36 +0100 Subject: [PATCH 15/21] Improve pipeline indentation handling in UseConsistentIndentation rule (#2173) * Improve pipeline indentation handling in UseConsistentIndentation rule * Fix over-indentation when multiple openers appear on the same line --- Rules/UseConsistentIndentation.cs | 185 +++++-- .../Rules/UseConsistentIndentation.tests.ps1 | 480 ++++++++++++++++++ 2 files changed, 626 insertions(+), 39 deletions(-) diff --git a/Rules/UseConsistentIndentation.cs b/Rules/UseConsistentIndentation.cs index 41aa4ef4d..2c77787c6 100644 --- a/Rules/UseConsistentIndentation.cs +++ b/Rules/UseConsistentIndentation.cs @@ -130,16 +130,27 @@ public override IEnumerable AnalyzeScript(Ast ast, string file var tokens = Helper.Instance.Tokens; var diagnosticRecords = new List(); var indentationLevel = 0; - var currentIndenationLevelIncreaseDueToPipelines = 0; var onNewLine = true; var pipelineAsts = ast.FindAll(testAst => testAst is PipelineAst && (testAst as PipelineAst).PipelineElements.Count > 1, true).ToList(); - /* - When an LParen and LBrace are on the same line, it can lead to too much de-indentation. - In order to prevent the RParen code from de-indenting too much, we keep a stack of when we skipped the indentation - caused by tokens that require a closing RParen (which are LParen, AtParen and DollarParen). - */ - var lParenSkippedIndentation = new Stack(); - + // Sort by end position so that inner (nested) pipelines appear before outer ones. + // This is required by MatchingPipelineAstEnd, whose early-break optimization + // would otherwise skip nested pipelines that end before their outer pipeline. + pipelineAsts.Sort((a, b) => + { + int lineCmp = a.Extent.EndScriptPosition.LineNumber.CompareTo(b.Extent.EndScriptPosition.LineNumber); + return lineCmp != 0 ? lineCmp : a.Extent.EndScriptPosition.ColumnNumber.CompareTo(b.Extent.EndScriptPosition.ColumnNumber); + }); + // Track pipeline indentation increases per PipelineAst instead of as a single + // flat counter. A flat counter caused all accumulated pipeline indentation to be + // subtracted when *any* pipeline ended, instead of only the contribution from + // that specific pipeline - leading to runaway indentation with nested pipelines. + var pipelineIndentationIncreases = new Dictionary(); + // When multiple openers appear on the same line (e.g. ({ or @(@{), + // only the last unclosed opener should affect indentation. We + // track, for every opener, whether its indentation increment was + // skipped so that the matching closer knows not to decrement. + var openerSkippedIndentation = new Stack(); + for (int tokenIndex = 0; tokenIndex < tokens.Length; tokenIndex++) { var token = tokens[tokenIndex]; @@ -153,27 +164,39 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do { case TokenKind.AtCurly: case TokenKind.LCurly: - AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); - break; - case TokenKind.DollarParen: case TokenKind.AtParen: - lParenSkippedIndentation.Push(false); - AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); + AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); + if (HasUnclosedOpenerBeforeLineEnd(tokens, tokenIndex)) + { + openerSkippedIndentation.Push(true); + } + else + { + indentationLevel++; + openerSkippedIndentation.Push(false); + } break; case TokenKind.LParen: AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); - // When a line starts with a parenthesis and it is not the last non-comment token of that line, - // then indentation does not need to be increased. + // When a line starts with a parenthesis and it is not the + // last non-comment token of that line, indentation does + // not need to be increased. if ((tokenIndex == 0 || tokens[tokenIndex - 1].Kind == TokenKind.NewLine) && NextTokenIgnoringComments(tokens, tokenIndex)?.Kind != TokenKind.NewLine) { - onNewLine = false; - lParenSkippedIndentation.Push(true); + openerSkippedIndentation.Push(true); break; } - lParenSkippedIndentation.Push(false); + // General case: skip when another opener follows so that + // only the last unclosed opener on a line is indent-affecting. + if (HasUnclosedOpenerBeforeLineEnd(tokens, tokenIndex)) + { + openerSkippedIndentation.Push(true); + break; + } + openerSkippedIndentation.Push(false); indentationLevel++; break; @@ -188,40 +211,50 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do if (pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationAfterEveryPipeline) { AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); - currentIndenationLevelIncreaseDueToPipelines++; + // Attribute this increase to the innermost pipeline containing + // this pipe token so it is only reversed when that specific + // pipeline ends, not when an unrelated outer pipeline ends. + PipelineAst containingPipeline = FindInnermostContainingPipeline(pipelineAsts, token); + if (containingPipeline != null) + { + if (!pipelineIndentationIncreases.ContainsKey(containingPipeline)) + pipelineIndentationIncreases[containingPipeline] = 0; + pipelineIndentationIncreases[containingPipeline]++; + } break; } if (pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationForFirstPipeline) { - bool isFirstPipeInPipeline = pipelineAsts.Any(pipelineAst => - PositionIsEqual(LastPipeOnFirstLineWithPipeUsage((PipelineAst)pipelineAst).Extent.EndScriptPosition, - tokens[tokenIndex - 1].Extent.EndScriptPosition)); - if (isFirstPipeInPipeline) + // Capture which specific PipelineAst this is the first pipe for, + // so the indentation increase is attributed to that pipeline only. + PipelineAst firstPipePipeline = pipelineAsts + .Cast() + .FirstOrDefault(pipelineAst => + PositionIsEqual(LastPipeOnFirstLineWithPipeUsage(pipelineAst).Extent.EndScriptPosition, + tokens[tokenIndex - 1].Extent.EndScriptPosition)); + if (firstPipePipeline != null) { AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); - currentIndenationLevelIncreaseDueToPipelines++; + if (!pipelineIndentationIncreases.ContainsKey(firstPipePipeline)) + pipelineIndentationIncreases[firstPipePipeline] = 0; + pipelineIndentationIncreases[firstPipePipeline]++; } } break; case TokenKind.RParen: - bool matchingLParenIncreasedIndentation = false; - if (lParenSkippedIndentation.Count > 0) + case TokenKind.RCurly: + if (openerSkippedIndentation.Count > 0 && openerSkippedIndentation.Pop()) { - matchingLParenIncreasedIndentation = lParenSkippedIndentation.Pop(); + // The matching opener skipped its increment, so we + // skip the decrement but still enforce indentation. + AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); } - if (matchingLParenIncreasedIndentation) + else { - onNewLine = false; - break; + indentationLevel = ClipNegative(indentationLevel - 1); + AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); } - indentationLevel = ClipNegative(indentationLevel - 1); - AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); - break; - - case TokenKind.RCurly: - indentationLevel = ClipNegative(indentationLevel - 1); - AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); break; case TokenKind.NewLine: @@ -290,14 +323,62 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do if (pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationForFirstPipeline || pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationAfterEveryPipeline) { - indentationLevel = ClipNegative(indentationLevel - currentIndenationLevelIncreaseDueToPipelines); - currentIndenationLevelIncreaseDueToPipelines = 0; + // Only subtract the indentation contributed by this specific pipeline, + // leaving contributions from outer/unrelated pipelines intact. + if (pipelineIndentationIncreases.TryGetValue(matchingPipeLineAstEnd, out int contribution)) + { + indentationLevel = ClipNegative(indentationLevel - contribution); + pipelineIndentationIncreases.Remove(matchingPipeLineAstEnd); + } } } return diagnosticRecords; } + /// + /// Scans forward from the current opener to the end of the line. + /// Returns true if there is at least one unclosed opener when + /// the line ends, meaning the current opener should skip its + /// indentation increment. If the current opener's own closer + /// is found on the same line (depth drops below zero), returns + /// false so that it indents normally. + /// + private static bool HasUnclosedOpenerBeforeLineEnd(Token[] tokens, int currentIndex) + { + int depth = 0; + for (int i = currentIndex + 1; i < tokens.Length; i++) + { + switch (tokens[i].Kind) + { + case TokenKind.NewLine: + case TokenKind.LineContinuation: + case TokenKind.EndOfInput: + return depth > 0; + + case TokenKind.LCurly: + case TokenKind.AtCurly: + case TokenKind.LParen: + case TokenKind.AtParen: + case TokenKind.DollarParen: + depth++; + break; + + case TokenKind.RCurly: + case TokenKind.RParen: + depth--; + if (depth < 0) + { + // Our own closer was found on this line. + return false; + } + break; + } + } + + return depth > 0; + } + private static Token NextTokenIgnoringComments(Token[] tokens, int startIndex) { if (startIndex >= tokens.Length - 1) @@ -432,6 +513,32 @@ private static PipelineAst MatchingPipelineAstEnd(List pipelineAsts, Token return matchingPipeLineAstEnd; } + /// + /// Finds the innermost (smallest) PipelineAst whose extent fully contains the given token. + /// Used to attribute pipeline indentation increases to the correct pipeline when + /// using IncreaseIndentationAfterEveryPipeline. + /// + private static PipelineAst FindInnermostContainingPipeline(List pipelineAsts, Token token) + { + PipelineAst best = null; + int bestSize = int.MaxValue; + foreach (var ast in pipelineAsts) + { + var pipeline = (PipelineAst)ast; + int pipelineStart = pipeline.Extent.StartOffset; + int pipelineEnd = pipeline.Extent.EndOffset; + int pipelineSize = pipelineEnd - pipelineStart; + if (pipelineStart <= token.Extent.StartOffset && + token.Extent.EndOffset <= pipelineEnd && + pipelineSize < bestSize) + { + best = pipeline; + bestSize = pipelineSize; + } + } + return best; + } + private static bool PositionIsEqual(IScriptPosition position1, IScriptPosition position2) { return position1.ColumnNumber == position2.ColumnNumber && diff --git a/Tests/Rules/UseConsistentIndentation.tests.ps1 b/Tests/Rules/UseConsistentIndentation.tests.ps1 index 0d26ff39d..6bf241116 100644 --- a/Tests/Rules/UseConsistentIndentation.tests.ps1 +++ b/Tests/Rules/UseConsistentIndentation.tests.ps1 @@ -549,6 +549,486 @@ foo | } } + Context "When a nested multi-line pipeline is inside a pipelined script block" { + + It "Should preserve indentation with nested pipeline using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$Test | +ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should recover indentation after nested pipeline block using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle multiple sequential nested pipeline blocks using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle inner pipeline with 3+ elements using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$Test | +ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle outer pipeline on same line as command using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle deeply nested pipelines (3 levels) using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$a | + ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + } +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$a | + ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + } +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$a | +ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$a | + ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle single-line inner pipeline inside multi-line outer pipeline using " -TestCases @( + @{ PipelineIndentation = 'IncreaseIndentationForFirstPipeline' } + @{ PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' } + @{ PipelineIndentation = 'NoIndentation' } + @{ PipelineIndentation = 'None' } + ) { + param ($PipelineIndentation) + + $idempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | Select-Object -Last 1 +} +'@ + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + } + + Context "When multiple openers appear on the same line" { + It "Should not double-indent for paren-then-brace: .foreach({" { + $def = @' +@('a', 'b').foreach({ + $_.ToUpper() + }) +'@ + $expected = @' +@('a', 'b').foreach({ + $_.ToUpper() +}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should not double-indent for brace-then-paren: {(" { + $def = @' +@('a', 'b').foreach({( + $_.ToUpper() + )}) +'@ + $expected = @' +@('a', 'b').foreach({( + $_.ToUpper() +)}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should not double-indent for array-then-hashtable on same line: @(@{" { + $idempotentScriptDefinition = @' +$x = @(@{ + key = 'value' +}) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + + It "Should not double-indent when non-opener tokens separate openers: ([PSCustomObject]@{" { + $def = @' +$list.Add([PSCustomObject]@{ + Name = "Test" + Value = 123 + }) +'@ + $expected = @' +$list.Add([PSCustomObject]@{ + Name = "Test" + Value = 123 +}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should indent normally when all openers are closed on the same line" { + $idempotentScriptDefinition = @' +$list.Add([PSCustomObject]@{Name = "Test"; Value = 123}) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + + It "Should handle closing brace and paren on separate lines" { + $def = @' +@('a', 'b').foreach({ + $_.ToUpper() + } + ) +'@ + $expected = @' +@('a', 'b').foreach({ + $_.ToUpper() +} +) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should handle nested .foreach({ }) calls" { + $def = @' +@(1, 2).foreach({ +@('a', 'b').foreach({ +"$_ and $_" +}) +}) +'@ + $expected = @' +@(1, 2).foreach({ + @('a', 'b').foreach({ + "$_ and $_" + }) +}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should still indent each opener separately when on different lines" { + $idempotentScriptDefinition = @' +$x = @( + @{ + key = 'value' + } +) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + + It "Should still indent normally for sub-expressions" { + $idempotentScriptDefinition = @' +$( + Get-Process +) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + } + Context "When tabs instead of spaces are used for indentation" { BeforeEach { $settings.Rules.PSUseConsistentIndentation.Kind = 'tab' From 940024a12dfc1f9240984104aaab8fd38c195817 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Thu, 7 May 2026 21:48:51 +0100 Subject: [PATCH 16/21] Prevent duplicate rule ID suppression errors in `-Fix` and DSC scenarios (#2181) * Enhance suppression error handling in ScriptAnalyzer and add tests for unapplied suppression errors * Skip new -Fix suppression test under Library Usage harness The `Library Usage` describe block in `LibraryUsage.tests.ps1` (only active on Windows PowerShell 5.1, since it's gated `-Skip:$IsCoreCLR`) re-runs `RuleSuppression.tests.ps1` against a hand-rolled `Invoke-ScriptAnalyzer` wrapper that drives the analyzer as a .NET library. That wrapper plugs in `PesterTestOutputWriter`, whose `WriteError` is intentionally a no-op: public void WriteError(ErrorRecord error) { // We don't write errors to avoid misleading // error messages in test output } So the unapplied-suppression `ErrorRecord` we now emit during the final `-Fix` pass never reaches `-ErrorVariable`, and `$fixErr | Should -HaveCount 1` fails with "Expected a collection with size 1, but got an empty collection". The behaviour itself is correct - the assertion is just unobservable through this test harness. Mark the new `It` block `-Skip:$testingLibraryUsage`, matching the existing pattern already used by the `Bad Rule Suppression` and `External Rule Suppression` contexts in the same file for the same reason. The regular pwsh and WinPS runs of `RuleSuppression.tests.ps1` (which `[+]` in the failing CI log) continue to exercise the assertion. The new `UseDSCResourceFunctions.tests.ps1` test isn't dot-sourced by `LibraryUsage.tests.ps1`, so it doesn't need the same guard. Drafted by Copilot (Claude Opus 4.7). * Add copilot review suggestions --------- Co-authored-by: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> --- Engine/ScriptAnalyzer.cs | 74 +++++++++++++++---- Tests/Engine/RuleSuppression.tests.ps1 | 24 ++++++ Tests/Rules/UseDSCResourceFunctions.tests.ps1 | 30 ++++++++ 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs index 46e267fc6..adc81f2d3 100644 --- a/Engine/ScriptAnalyzer.cs +++ b/Engine/ScriptAnalyzer.cs @@ -1447,10 +1447,24 @@ public IEnumerable AnalyzeAndFixPath(string path, Func /// The script to be analyzed. /// Parsed AST of . - /// Parsed tokens of + /// Parsed tokens of . /// Whether variable analysis can be skipped (applicable if rules do not use variable analysis APIs). /// public List AnalyzeScriptDefinition(string scriptDefinition, out ScriptBlockAst scriptAst, out Token[] scriptTokens, bool skipVariableAnalysis = false) + { + return AnalyzeScriptDefinition(scriptDefinition, out scriptAst, out scriptTokens, skipVariableAnalysis, emitSuppressionErrors: true); + } + + /// + /// Analyzes a script definition in the form of a string input. + /// + /// The script to be analysed. + /// Parsed AST of . + /// Parsed tokens of . + /// Whether variable analysis can be skipped (applicable if rules do not use variable analysis APIs). + /// Whether to emit errors for unapplied rule suppression IDs. + /// A list of diagnostics found by rules. + public List AnalyzeScriptDefinition(string scriptDefinition, out ScriptBlockAst scriptAst, out Token[] scriptTokens, bool skipVariableAnalysis, bool emitSuppressionErrors) { scriptAst = null; scriptTokens = null; @@ -1490,7 +1504,7 @@ public List AnalyzeScriptDefinition(string scriptDefinition, o } // now, analyze the script definition - diagnosticRecords.AddRange(this.AnalyzeSyntaxTree(scriptAst, scriptTokens, null, skipVariableAnalysis)); + diagnosticRecords.AddRange(this.AnalyzeSyntaxTree(scriptAst, scriptTokens, null, skipVariableAnalysis, emitSuppressionErrors)); return diagnosticRecords; } @@ -1549,11 +1563,11 @@ public EditableText Fix(EditableText text, Range range, bool skipParsing, out Ra IEnumerable records; if (skipParsing && previousUnusedCorrections == 0) { - records = AnalyzeSyntaxTree(scriptAst, scriptTokens, String.Empty, skipVariableAnalysis); + records = AnalyzeSyntaxTree(scriptAst, scriptTokens, String.Empty, skipVariableAnalysis, emitSuppressionErrors: false); } else { - records = AnalyzeScriptDefinition(text.ToString(), out scriptAst, out scriptTokens, skipVariableAnalysis); + records = AnalyzeScriptDefinition(text.ToString(), out scriptAst, out scriptTokens, skipVariableAnalysis, emitSuppressionErrors: false); } var corrections = records .Select(r => r.SuggestedCorrections) @@ -1986,7 +2000,8 @@ bool IsRuleAllowed(IRule rule) private Tuple, List> SuppressRule( string ruleName, Dictionary> ruleSuppressions, - List ruleDiagnosticRecords) + List ruleDiagnosticRecords, + bool emitSuppressionErrors = true) { List suppressRuleErrors; var records = Helper.Instance.SuppressRule( @@ -1994,9 +2009,12 @@ private Tuple, List> SuppressRule( ruleSuppressions, ruleDiagnosticRecords, out suppressRuleErrors); - foreach (var error in suppressRuleErrors) + if (emitSuppressionErrors) { - this.outputWriter.WriteError(error); + foreach (var error in suppressRuleErrors) + { + this.outputWriter.WriteError(error); + } } return records; } @@ -2014,13 +2032,15 @@ private Tuple, List> SuppressRule( /// Returns a tuple of suppressed and diagnostic records private Tuple, List> SuppressRule( Dictionary> ruleSuppressions, - DiagnosticRecord ruleDiagnosticRecord + DiagnosticRecord ruleDiagnosticRecord, + bool emitSuppressionErrors = true ) { return SuppressRule( ruleDiagnosticRecord.RuleName, ruleSuppressions, - new List { ruleDiagnosticRecord }); + new List { ruleDiagnosticRecord }, + emitSuppressionErrors); } /// @@ -2038,6 +2058,27 @@ public IEnumerable AnalyzeSyntaxTree( Token[] scriptTokens, string filePath, bool skipVariableAnalysis = false) + { + return AnalyzeSyntaxTree(scriptAst, scriptTokens, filePath, skipVariableAnalysis, emitSuppressionErrors: true); + } + + /// + /// Analyzes the syntax tree of a script file that has already been parsed. + /// + /// The ScriptBlockAst from the parsed script. + /// The tokens found in the script. + /// The path to the file that was parsed. + /// If AnalyzeSyntaxTree is called from an AST obtained via ParseInput, this field will be String.Empty. + /// + /// Whether to skip variable analysis. + /// Whether to emit errors for unapplied rule suppression IDs. + /// An enumeration of DiagnosticRecords found by rules. + public IEnumerable AnalyzeSyntaxTree( + ScriptBlockAst scriptAst, + Token[] scriptTokens, + string filePath, + bool skipVariableAnalysis, + bool emitSuppressionErrors) { Dictionary> ruleSuppressions = new Dictionary>(); ConcurrentBag diagnostics = new ConcurrentBag(); @@ -2117,7 +2158,10 @@ public IEnumerable AnalyzeSyntaxTree( ruleSuppressions, ruleRecords, out suppressRuleErrors); - result.AddRange(suppressRuleErrors); + if (emitSuppressionErrors) + { + result.AddRange(suppressRuleErrors); + } foreach (var record in records.Item2) { diagnostics.Add(record); @@ -2177,7 +2221,7 @@ public IEnumerable AnalyzeSyntaxTree( try { var ruleRecords = tokenRule.AnalyzeTokens(scriptTokens, filePath).ToList(); - var records = SuppressRule(tokenRule.GetName(), ruleSuppressions, ruleRecords); + var records = SuppressRule(tokenRule.GetName(), ruleSuppressions, ruleRecords, emitSuppressionErrors); foreach (var record in records.Item2) { diagnostics.Add(record); @@ -2215,7 +2259,7 @@ public IEnumerable AnalyzeSyntaxTree( try { var ruleRecords = dscResourceRule.AnalyzeDSCClass(scriptAst, filePath).ToList(); - var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords); + var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords, emitSuppressionErrors); foreach (var record in records.Item2) { diagnostics.Add(record); @@ -2234,7 +2278,7 @@ public IEnumerable AnalyzeSyntaxTree( } // Check if the supplied artifact is indeed part of the DSC resource - if (!filePathIsNullOrWhiteSpace && Helper.Instance.IsDscResourceModule(filePath)) + else if (!filePathIsNullOrWhiteSpace && Helper.Instance.IsDscResourceModule(filePath)) { // Run all DSC Rules foreach (IDSCResourceRule dscResourceRule in this.DSCResourceRules) @@ -2248,7 +2292,7 @@ public IEnumerable AnalyzeSyntaxTree( try { var ruleRecords = dscResourceRule.AnalyzeDSCResource(scriptAst, filePath).ToList(); - var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords); + var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords, emitSuppressionErrors); foreach (var record in records.Item2) { diagnostics.Add(record); @@ -2297,7 +2341,7 @@ public IEnumerable AnalyzeSyntaxTree( foreach (var ruleRecord in this.GetExternalRecord(scriptAst, scriptTokens, exRules.ToArray(), filePath)) { - var records = SuppressRule(ruleSuppressions, ruleRecord); + var records = SuppressRule(ruleSuppressions, ruleRecord, emitSuppressionErrors); foreach (var record in records.Item2) { diagnostics.Add(record); diff --git a/Tests/Engine/RuleSuppression.tests.ps1 b/Tests/Engine/RuleSuppression.tests.ps1 index 2d31a6ddf..26ca9df78 100644 --- a/Tests/Engine/RuleSuppression.tests.ps1 +++ b/Tests/Engine/RuleSuppression.tests.ps1 @@ -244,6 +244,30 @@ function MyFunc $suppErr.TargetObject.RuleSuppressionID | Should -BeExactly "banana" } } + + It "Issues one unapplied suppression error when -Fix reanalyzes a file" -Skip:$testingLibraryUsage { + $scriptPath = Join-Path $TestDrive 'SuppressionFix.ps1' + $script = @( + 'function Test-Function1 {' + " [System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidUsingWriteHost','NonExistentID123')]" + ' param() ; Write-Host ''x''' + '}' + ) -join "`n" + + [System.IO.File]::WriteAllText($scriptPath, $script + "`n") + + $diagnostics = Invoke-ScriptAnalyzer ` + -Path $scriptPath ` + -Fix ` + -ErrorVariable fixErr ` + -ErrorAction SilentlyContinue + + $diagnostics | Should -HaveCount 1 + $diagnostics[0].RuleName | Should -BeExactly 'PSAvoidUsingWriteHost' + $fixErr | Should -HaveCount 1 + $fixErr[0].TargetObject.RuleName | Should -BeExactly 'PSAvoidUsingWriteHost' + $fixErr[0].TargetObject.RuleSuppressionID | Should -BeExactly 'NonExistentID123' + } } Context "RuleSuppressionID with named arguments" { diff --git a/Tests/Rules/UseDSCResourceFunctions.tests.ps1 b/Tests/Rules/UseDSCResourceFunctions.tests.ps1 index 9112d6e22..1ebda2d44 100644 --- a/Tests/Rules/UseDSCResourceFunctions.tests.ps1 +++ b/Tests/Rules/UseDSCResourceFunctions.tests.ps1 @@ -46,4 +46,34 @@ Describe "StandardDSCFunctionsInClass" { $noClassViolations.Count | Should -Be 0 } } + + Context "When a class-based DSC resource is also in DSC resource module layout" { + It "does not duplicate the unapplied suppression error" { + $resourceRoot = Join-Path $TestDrive 'DSCResources' + $resourceDir = Join-Path $resourceRoot 'MyRes' + $resourcePath = Join-Path $resourceDir 'MyRes.psm1' + $schemaPath = Join-Path $resourceDir 'MyRes.schema.mof' + + New-Item -ItemType Directory -Path $resourceDir -Force | Out-Null + [System.IO.File]::WriteAllText($resourcePath, @' +[System.Diagnostics.CodeAnalysis.SuppressMessage('PSDSCStandardDSCFunctionsInResource', 'BadDscId', Scope='Class', Target='MyRes')] +[DscResource()] +class MyRes { + [DscProperty(Key)] [string] $Name + [MyRes] Get() { return $this } +} +'@.TrimStart() + "`n") + Set-Content -Path $schemaPath -Value '' + + Invoke-ScriptAnalyzer ` + -Path $resourcePath ` + -ErrorVariable dscErr ` + -ErrorAction SilentlyContinue | + Out-Null + + $dscErr | Should -HaveCount 1 + $dscErr[0].TargetObject.RuleName | Should -BeExactly $violationName + $dscErr[0].TargetObject.RuleSuppressionID | Should -BeExactly 'BadDscId' + } + } } From 9c04a44e36d6ba3ab1dc5fb0431abef592e87054 Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Thu, 7 May 2026 21:57:23 +0100 Subject: [PATCH 17/21] Settings file creation and validation (#2176) * Add RuleOptionInfo class and update RuleInfo to include options for configurable rules * Add Test and New Cmdlets for PSScriptAnalyzer Settings Management - Implemented `New-ScriptAnalyzerSettingsFile` cmdlet to create a new PSScriptAnalyzer settings file, with options for presets and overwriting existing files. - Added `Test-ScriptAnalyzerSettingsFile` cmdlet to validate settings files, checking for parseability, rule existence, and valid options. - Created comprehensive tests for both cmdlets to ensure functionality and error handling. - Updated module manifest to export the new cmdlets. - Added documentation for both cmdlets, including usage examples and parameter descriptions. - Enhanced error messages in the strings resource file for better clarity during validation failures. * Enhance ScriptAnalyzer settings file validation and documentation - Update Helper.cs to return null for empty output paths instead of an empty array. - Add new error message for invalid option types in Strings.resx. - Extend tests for New-ScriptAnalyzerSettingsFile to check for new keys: CustomRulePath, IncludeDefaultRules, and RecurseCustomRulePath. - Modify Test-ScriptAnalyzerSettingsFile tests to validate output and error handling for various scenarios, including type mismatches and invalid values. - Improve documentation for New-ScriptAnalyzerSettingsFile and Test-ScriptAnalyzerSettingsFile to clarify behavior and parameters, including handling of custom rules and output format. * Apply docs suggestions from Sean's review Co-authored-by: Sean Wheeler * Add Copilot review suggestions --------- Co-authored-by: Sean Wheeler --- .../Commands/GetScriptAnalyzerRuleCommand.cs | 6 +- .../NewScriptAnalyzerSettingsFileCommand.cs | 537 +++++++++++++++ .../TestScriptAnalyzerSettingsFileCommand.cs | 635 ++++++++++++++++++ Engine/Generic/RuleInfo.cs | 35 + Engine/Generic/RuleOptionInfo.cs | 131 ++++ Engine/Helper.cs | 2 +- Engine/PSScriptAnalyzer.psd1 | 2 +- Engine/PSScriptAnalyzer.psm1 | 4 + Engine/Strings.resx | 36 + Tests/Engine/GetScriptAnalyzerRule.tests.ps1 | 30 + .../NewScriptAnalyzerSettingsFile.tests.ps1 | 283 ++++++++ .../TestScriptAnalyzerSettingsFile.tests.ps1 | 424 ++++++++++++ .../Cmdlets/New-ScriptAnalyzerSettingsFile.md | 187 ++++++ .../Test-ScriptAnalyzerSettingsFile.md | 151 +++++ 14 files changed, 2460 insertions(+), 3 deletions(-) create mode 100644 Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs create mode 100644 Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs create mode 100644 Engine/Generic/RuleOptionInfo.cs create mode 100644 Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 create mode 100644 Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 create mode 100644 docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md create mode 100644 docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md diff --git a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs index 3219affa7..9a2782a45 100644 --- a/Engine/Commands/GetScriptAnalyzerRuleCommand.cs +++ b/Engine/Commands/GetScriptAnalyzerRuleCommand.cs @@ -114,8 +114,12 @@ protected override void ProcessRecord() foreach (IRule rule in rules) { + var ruleOptions = rule is ConfigurableRule + ? RuleOptionInfo.GetRuleOptions(rule) + : null; + WriteObject(new RuleInfo(rule.GetName(), rule.GetCommonName(), rule.GetDescription(), - rule.GetSourceType(), rule.GetSourceName(), rule.GetSeverity(), rule.GetType())); + rule.GetSourceType(), rule.GetSourceName(), rule.GetSeverity(), rule.GetType(), ruleOptions)); } } } diff --git a/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs new file mode 100644 index 000000000..3f2b36844 --- /dev/null +++ b/Engine/Commands/NewScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,537 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Text; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands +{ + /// + /// Creates a new PSScriptAnalyzer settings file. + /// The emitted file is always named PSScriptAnalyzerSettings.psd1 so that automatic + /// settings discovery works when the file is placed in a project directory. + /// + [Cmdlet(VerbsCommon.New, "ScriptAnalyzerSettingsFile", SupportsShouldProcess = true, + HelpUri = "https://github.com/PowerShell/PSScriptAnalyzer")] + public class NewScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter + { + private const string SettingsFileName = "PSScriptAnalyzerSettings.psd1"; + + #region Parameters + + /// + /// The directory where the settings file will be created. + /// Defaults to the current working directory. + /// + [Parameter(Mandatory = false, Position = 0)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// The name of a built-in preset to use as the basis for the + /// generated settings file. When omitted, all rules and their default + /// configurable options are included. Valid values are resolved dynamically + /// from the shipped preset files and tab-completed via an argument completer + /// registered in PSScriptAnalyzer.psm1. + /// + [Parameter(Mandatory = false)] + [ValidateNotNullOrEmpty] + public string BaseOnPreset { get; set; } + + /// + /// Overwrite an existing settings file at the target path. + /// + [Parameter(Mandatory = false)] + public SwitchParameter Force { get; set; } + + #endregion Parameters + + #region Overrides + + /// + /// Initialise the analyser engine so that rule metadata is available. + /// + protected override void BeginProcessing() + { + Helper.Instance = new Helper(SessionState.InvokeCommand); + Helper.Instance.Initialize(); + + ScriptAnalyzer.Instance.Initialize(this, null, null, null, null, true); + } + + /// + /// Generate and write the settings file. + /// + protected override void ProcessRecord() + { + // Validate -BaseOnPreset against the dynamically discovered presets. + if (!string.IsNullOrEmpty(BaseOnPreset)) + { + var validPresets = Settings.GetSettingPresets().ToList(); + if (!validPresets.Contains(BaseOnPreset, StringComparer.OrdinalIgnoreCase)) + { + ThrowTerminatingError( + new ErrorRecord( + new ArgumentException( + string.Format( + CultureInfo.CurrentCulture, + Strings.InvalidPresetName, + BaseOnPreset, + string.Join(", ", validPresets) + ) + ), + "InvalidPresetName", + ErrorCategory.InvalidArgument, + BaseOnPreset + ) + ); + } + } + + string directory = string.IsNullOrEmpty(Path) + ? SessionState.Path.CurrentFileSystemLocation.Path + : GetUnresolvedProviderPathFromPSPath(Path); + + string targetPath = System.IO.Path.Combine(directory, SettingsFileName); + + // Guard against overwriting an existing settings file unless -Force is specified. + if (File.Exists(targetPath) && !Force) + { + ThrowTerminatingError( + new ErrorRecord( + new IOException( + string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsFileAlreadyExists, + targetPath + ) + ), + "SettingsFileAlreadyExists", + ErrorCategory.ResourceExists, + targetPath + ) + ); + } + + string content; + if (!string.IsNullOrEmpty(BaseOnPreset)) + { + content = GenerateFromPreset(BaseOnPreset); + } + else + { + content = GenerateFromAllRules(); + } + + if (ShouldProcess(targetPath, "Create settings file")) + { + // Ensure the target directory exists. + Directory.CreateDirectory(directory); + File.WriteAllText(targetPath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + WriteObject(new FileInfo(targetPath)); + } + } + + #endregion Overrides + + #region Settings generation + + /// + /// Generates settings content from a built-in preset. The preset is parsed and + /// the output is normalised to include all top-level fields. + /// + private string GenerateFromPreset(string presetName) + { + string presetPath = Settings.GetSettingPresetFilePath(presetName); + if (presetPath == null || !File.Exists(presetPath)) + { + ThrowTerminatingError( + new ErrorRecord( + new ArgumentException( + string.Format( + CultureInfo.CurrentCulture, + Strings.PresetNotFound, + presetName + ) + ), + "PresetNotFound", + ErrorCategory.ObjectNotFound, + presetName + ) + ); + } + + var parsed = new Settings(presetPath); + var ruleOptionMap = BuildRuleOptionMap(); + + var sb = new StringBuilder(); + WriteHeader(sb, presetName); + sb.AppendLine("@{"); + + sb.AppendLine(" # Rules to run. When populated, only these rules are used."); + sb.AppendLine(" # Leave empty to run all rules."); + WriteStringArray(sb, "IncludeRules", parsed.IncludeRules); + sb.AppendLine(); + + sb.AppendLine(" # Rules to skip. Takes precedence over IncludeRules."); + WriteStringArray(sb, "ExcludeRules", parsed.ExcludeRules); + sb.AppendLine(); + + sb.AppendLine(" # Only report diagnostics at these severity levels."); + sb.AppendLine(" # Leave empty to report all severities."); + WriteSeverityArray(sb, parsed.Severities); + sb.AppendLine(); + + sb.AppendLine(" # Paths to modules or directories containing custom rules."); + sb.AppendLine(" # When specified, these rules are loaded in addition to (or instead"); + sb.AppendLine(" # of) the built-in rules, depending on IncludeDefaultRules."); + sb.AppendLine(" # Note: Relative paths are resolved from the caller's working directory,"); + sb.AppendLine(" # not the location of this settings file."); + WriteStringArray(sb, "CustomRulePath", parsed.CustomRulePath); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true and CustomRulePath is specified, built-in rules"); + sb.AppendLine(" # are loaded alongside custom rules. Has no effect without CustomRulePath."); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " IncludeDefaultRules = {0}", parsed.IncludeDefaultRules ? "$true" : "$false")); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true, searches sub-folders under CustomRulePath for"); + sb.AppendLine(" # additional rule modules. Has no effect without CustomRulePath."); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + " RecurseCustomRulePath = {0}", parsed.RecurseCustomRulePath ? "$true" : "$false")); + sb.AppendLine(); + + sb.AppendLine(" # Per-rule configuration. Only configurable rules appear here."); + sb.AppendLine(" # Values from the preset are shown; other properties use defaults."); + + if (parsed.RuleArguments != null && parsed.RuleArguments.Count > 0) + { + sb.AppendLine(" Rules = @{"); + + bool firstRule = true; + foreach (var ruleEntry in parsed.RuleArguments.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + if (!firstRule) + { + sb.AppendLine(); + } + firstRule = false; + + string ruleName = ruleEntry.Key; + var presetArgs = ruleEntry.Value; + + if (ruleOptionMap.TryGetValue(ruleName, out var optionInfos)) + { + WriteRuleSettings(sb, ruleName, optionInfos, presetArgs); + } + else + { + WriteRuleSettingsRaw(sb, ruleName, presetArgs); + } + } + + sb.AppendLine(" }"); + } + else + { + sb.AppendLine(" Rules = @{}"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Generates settings content that includes every available rule with all + /// configurable properties set to their defaults. + /// + private string GenerateFromAllRules() + { + var ruleNames = new List(); + var ruleOptionMap = BuildRuleOptionMap(ruleNames); + + var sb = new StringBuilder(); + WriteHeader(sb, presetName: null); + sb.AppendLine("@{"); + + sb.AppendLine(" # Rules to run. When populated, only these rules are used."); + sb.AppendLine(" # Leave empty to run all rules."); + WriteStringArray(sb, "IncludeRules", ruleNames); + sb.AppendLine(); + + sb.AppendLine(" # Rules to skip. Takes precedence over IncludeRules."); + WriteStringArray(sb, "ExcludeRules", Enumerable.Empty()); + sb.AppendLine(); + + sb.AppendLine(" # Only report diagnostics at these severity levels."); + sb.AppendLine(" # Leave empty to report all severities."); + WriteSeverityArray(sb, Enumerable.Empty()); + sb.AppendLine(); + + sb.AppendLine(" # Paths to modules or directories containing custom rules."); + sb.AppendLine(" # When specified, these rules are loaded in addition to (or instead"); + sb.AppendLine(" # of) the built-in rules, depending on IncludeDefaultRules."); + sb.AppendLine(" # Note: Relative paths are resolved from the caller's working directory,"); + sb.AppendLine(" # not the location of this settings file."); + WriteStringArray(sb, "CustomRulePath", Enumerable.Empty()); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true and CustomRulePath is specified, built-in rules"); + sb.AppendLine(" # are loaded alongside custom rules. Has no effect without CustomRulePath."); + sb.AppendLine(" IncludeDefaultRules = $false"); + sb.AppendLine(); + + sb.AppendLine(" # When set to $true, searches sub-folders under CustomRulePath for"); + sb.AppendLine(" # additional rule modules. Has no effect without CustomRulePath."); + sb.AppendLine(" RecurseCustomRulePath = $false"); + sb.AppendLine(); + + sb.AppendLine(" # Per-rule configuration. Only configurable rules appear here."); + sb.AppendLine(" Rules = @{"); + + bool firstRule = true; + foreach (var kvp in ruleOptionMap.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + if (!firstRule) + { + sb.AppendLine(); + } + firstRule = false; + + WriteRuleSettings(sb, kvp.Key, kvp.Value, presetArgs: null); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + /// + /// Builds a map of rule name to its configurable property metadata. + /// Optionally populates a list of all rule names encountered. + /// + private Dictionary> BuildRuleOptionMap(List allRuleNames = null) + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + string[] modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); + IEnumerable rules = ScriptAnalyzer.Instance.GetRule(modNames, null) + ?? Enumerable.Empty(); + + foreach (IRule rule in rules) + { + string name = rule.GetName(); + allRuleNames?.Add(name); + + if (rule is ConfigurableRule) + { + var options = RuleOptionInfo.GetRuleOptions(rule); + if (options.Count > 0) + { + map[name] = options; + } + } + } + + return map; + } + + #endregion Settings generation + + #region Formatting helpers + + /// + /// Writes a comment header identifying the tool and version that generated + /// the file, along with the preset if one was specified. + /// + private static void WriteHeader(StringBuilder sb, string presetName) + { + Version version = typeof(ScriptAnalyzer).Assembly.GetName().Version; + string versionStr = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", version.Major, version.Minor, version.Build); + + sb.AppendLine("#"); + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + "# PSScriptAnalyzer settings file ({0})", + versionStr)); + + if (!string.IsNullOrEmpty(presetName)) + { + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + "# Based on the '{0}' preset.", + presetName)); + } + + sb.AppendLine("#"); + sb.AppendLine("# Generated by New-ScriptAnalyzerSettingsFile."); + sb.AppendLine("#"); + sb.AppendLine(); + } + + /// + /// Writes a PowerShell string-array assignment such as IncludeRules = @( ... ). + /// + private static void WriteStringArray(StringBuilder sb, string key, IEnumerable values) + { + var items = values?.ToList() ?? new List(); + + if (items.Count == 0) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @()", key)); + return; + } + + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @(", key)); + foreach (string item in items) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " '{0}'", item)); + } + sb.AppendLine(" )"); + } + + /// + /// Writes the Severity array with an inline comment listing valid values. + /// + private static void WriteSeverityArray(StringBuilder sb, IEnumerable values) + { + string validValues = string.Join(", ", Enum.GetNames(typeof(RuleSeverity))); + var items = values?.ToList() ?? new List(); + + if (items.Count == 0) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Severity = @() # {0}", validValues)); + return; + } + + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " Severity = @( # {0}", validValues)); + foreach (string item in items) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " '{0}'", item)); + } + sb.AppendLine(" )"); + } + + /// + /// Writes a rule settings block using option metadata, optionally merging + /// with values from a preset. Enable always appears first, followed by + /// the remaining properties sorted alphabetically. + /// + private static void WriteRuleSettings( + StringBuilder sb, + string ruleName, + List optionInfos, + Dictionary presetArgs) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @{{", ruleName)); + + foreach (RuleOptionInfo option in optionInfos) + { + object value = option.DefaultValue; + if (presetArgs != null + && presetArgs.TryGetValue(option.Name, out object presetVal)) + { + value = presetVal; + } + + string formatted = FormatValue(value); + string comment = FormatPossibleValuesComment(option); + + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + " {0} = {1}{2}", + option.Name, + formatted, + comment)); + } + + sb.AppendLine(" }"); + } + + /// + /// Writes preset rule arguments verbatim when no option metadata is available. + /// + private static void WriteRuleSettingsRaw( + StringBuilder sb, + string ruleName, + Dictionary args) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, " {0} = @{{", ruleName)); + + foreach (var kvp in args.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + { + sb.AppendLine(string.Format( + CultureInfo.InvariantCulture, + " {0} = {1}", + kvp.Key, + FormatValue(kvp.Value))); + } + + sb.AppendLine(" }"); + } + + /// + /// Formats a value as a PowerShell literal suitable for inclusion in a .psd1 file. + /// + private static string FormatValue(object value) + { + if (value is bool boolVal) + { + return boolVal ? "$true" : "$false"; + } + + if (value is int || value is long || value is double || value is float) + { + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + + if (value is string strVal) + { + return string.Format(CultureInfo.InvariantCulture, "'{0}'", strVal); + } + + if (value is Array arr) + { + if (arr.Length == 0) + { + return "@()"; + } + + var elements = new List(); + foreach (object item in arr) + { + elements.Add(FormatValue(item)); + } + return string.Format(CultureInfo.InvariantCulture, "@({0})", string.Join(", ", elements)); + } + + // Fallback - treat as string. + return string.Format(CultureInfo.InvariantCulture, "'{0}'", value); + } + + /// + /// Returns an inline comment listing the valid values, or an empty string + /// when the option is unconstrained. + /// + private static string FormatPossibleValuesComment(RuleOptionInfo option) + { + if (option.PossibleValues == null || option.PossibleValues.Length == 0) + { + return string.Empty; + } + + return " # " + string.Join(", ", option.PossibleValues.Select(v => v.ToString())); + } + + #endregion Formatting helpers + } +} diff --git a/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs b/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs new file mode 100644 index 000000000..1eb8f8bb3 --- /dev/null +++ b/Engine/Commands/TestScriptAnalyzerSettingsFileCommand.cs @@ -0,0 +1,635 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Commands +{ + /// + /// Validates a PSScriptAnalyzer settings file as a self-contained unit. + /// Checks that the file is parseable, that referenced rules exist, and that all + /// rule options and their values are valid. + /// + /// Custom rule paths, RecurseCustomRulePath and IncludeDefaultRules are read + /// from the settings file itself so that validation reflects what + /// Invoke-ScriptAnalyzer would see when given the same file. + /// + /// In the default mode each problem is emitted as a DiagnosticRecord with the + /// source extent of the offending text. When -Quiet is specified, returns only + /// $true or $false - indicating whether the settings file is valid. + /// + [Cmdlet(VerbsDiagnostic.Test, "ScriptAnalyzerSettingsFile", + HelpUri = "https://github.com/PowerShell/PSScriptAnalyzer")] + [OutputType(typeof(DiagnosticRecord))] + [OutputType(typeof(bool))] + public class TestScriptAnalyzerSettingsFileCommand : PSCmdlet, IOutputWriter + { + private const string RuleName = "Test-ScriptAnalyzerSettingsFile"; + + #region Parameters + + /// + /// The path to the settings file to validate. + /// + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// When specified, returns only $true or $false without emitting + /// diagnostic records. Without this switch the cmdlet writes a + /// DiagnosticRecord for every problem found and produces no output + /// when the file is valid. + /// + [Parameter(Mandatory = false)] + public SwitchParameter Quiet { get; set; } + + #endregion Parameters + + #region Private state + + private string _resolvedPath; + private List _diagnostics; + + #endregion Private state + + #region Overrides + + /// + /// Initialise the helper. Full engine initialisation is + /// deferred to ProcessRecord because we need to read CustomRulePath and + /// IncludeDefaultRules from the settings file first. + /// + protected override void BeginProcessing() + { + Helper.Instance = new Helper(SessionState.InvokeCommand); + Helper.Instance.Initialize(); + } + + /// + /// ProcessRecord: Parse and validate the settings file. + /// + protected override void ProcessRecord() + { + _resolvedPath = GetUnresolvedProviderPathFromPSPath(Path); + _diagnostics = new List(); + + if (!File.Exists(_resolvedPath)) + { + if (Quiet) + { + WriteObject(false); + } + else + { + WriteError(new ErrorRecord( + new FileNotFoundException(string.Format( + CultureInfo.CurrentCulture, + Strings.SettingsFileNotFound, + _resolvedPath)), + "SettingsFileNotFound", + ErrorCategory.ObjectNotFound, + _resolvedPath)); + } + + return; + } + + // Parse with the PowerShell AST to get source extents. + ScriptBlockAst scriptAst = Parser.ParseFile( + _resolvedPath, + out Token[] tokens, + out ParseError[] parseErrors + ); + + if (parseErrors != null && parseErrors.Length > 0) + { + if (Quiet) + { + WriteObject(false); + } + else + { + foreach (ParseError pe in parseErrors) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, pe.Message), + pe.Extent, + DiagnosticSeverity.ParseError); + } + + EmitDiagnostics(); + } + + return; + } + + // Locate the root hashtable. + HashtableAst rootHashtable = scriptAst.Find(ast => ast is HashtableAst, searchNestedScriptBlocks: false) as HashtableAst; + if (rootHashtable == null) + { + if (Quiet) + { + WriteObject(false); + } + else + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, "File does not contain a hashtable."), + scriptAst.Extent, + DiagnosticSeverity.Error); + EmitDiagnostics(); + } + + return; + } + + // Also parse via Settings to get the evaluated data. + Settings parsed; + try + { + parsed = new Settings(_resolvedPath); + } + catch (Exception ex) + { + if (Quiet) + { + WriteObject(false); + } + else + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileParseError, ex.Message), + rootHashtable.Extent, + DiagnosticSeverity.Error); + EmitDiagnostics(); + } + + return; + } + + // Initialise the analyser engine using custom rule paths and + // IncludeDefaultRules from the settings file so that validation + // reflects the same rule set Invoke-ScriptAnalyzer would use (given + // this settings file). + string[] rulePaths = Helper.ProcessCustomRulePaths( + parsed.CustomRulePath?.ToArray(), + SessionState, + parsed.RecurseCustomRulePath); + + // Treat an empty array the same as null — no custom paths were specified. + if (rulePaths != null && rulePaths.Length == 0) + { + rulePaths = null; + } + + bool includeDefaultRules = rulePaths == null || parsed.IncludeDefaultRules; + ScriptAnalyzer.Instance.Initialize(this, rulePaths, null, null, null, includeDefaultRules); + + // Build lookup structures. + var topLevelMap = BuildAstKeyMap(rootHashtable); + + string[] modNames = ScriptAnalyzer.Instance.GetValidModulePaths(); + IEnumerable knownRules = ScriptAnalyzer.Instance.GetRule(modNames, null) + ?? Enumerable.Empty(); + + var ruleMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (IRule rule in knownRules) + { + ruleMap[rule.GetName()] = rule; + } + + // Validate IncludeRules. + ValidateRuleNameArray(parsed.IncludeRules, ruleMap, "IncludeRules", topLevelMap); + + // Validate ExcludeRules. + ValidateRuleNameArray(parsed.ExcludeRules, ruleMap, "ExcludeRules", topLevelMap); + + // Validate Severity values. + ValidateSeverityArray(parsed.Severities, topLevelMap); + + // Validate rule arguments. + if (parsed.RuleArguments != null) + { + HashtableAst rulesHashtable = GetNestedHashtable(topLevelMap, "Rules"); + + var rulesAstMap = rulesHashtable != null + ? BuildAstKeyMap(rulesHashtable) + : new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var ruleEntry in parsed.RuleArguments) + { + string ruleName = ruleEntry.Key; + IScriptExtent ruleKeyExtent = GetKeyExtent(rulesAstMap, ruleName) + ?? rulesHashtable?.Extent + ?? rootHashtable.Extent; + + if (!ruleMap.TryGetValue(ruleName, out IRule rule)) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleArgRuleNotFound, ruleName), + ruleKeyExtent, + DiagnosticSeverity.Error); + continue; + } + + if (!(rule is ConfigurableRule)) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleNotConfigurable, ruleName), + ruleKeyExtent, + DiagnosticSeverity.Error); + continue; + } + + var optionInfos = RuleOptionInfo.GetRuleOptions(rule); + var optionMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var opt in optionInfos) + { + optionMap[opt.Name] = opt; + } + + // Get the AST for this rule's nested hashtable. + HashtableAst ruleHashtable = GetNestedHashtable(rulesAstMap, ruleName); + var ruleArgAstMap = ruleHashtable != null + ? BuildAstKeyMap(ruleHashtable) + : new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var arg in ruleEntry.Value) + { + string argName = arg.Key; + IScriptExtent argKeyExtent = GetKeyExtent(ruleArgAstMap, argName) + ?? ruleKeyExtent; + + if (!optionMap.TryGetValue(argName, out RuleOptionInfo optionInfo)) + { + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileUnrecognisedOption, ruleName, argName), + argKeyExtent, + DiagnosticSeverity.Error); + continue; + } + + // Validate that the value is compatible with the expected type. + if (arg.Value != null && !IsValueCompatible(arg.Value, optionInfo.OptionType)) + { + IScriptExtent valueExtent = GetValueExtent(ruleArgAstMap, argName) + ?? argKeyExtent; + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidOptionType, + ruleName, argName, GetFriendlyTypeName(optionInfo.OptionType)), + valueExtent, + DiagnosticSeverity.Error); + } + // Validate constrained string values against the set of possible values. + else if (optionInfo.PossibleValues != null + && optionInfo.PossibleValues.Length > 0 + && arg.Value is string strValue) + { + bool valueValid = optionInfo.PossibleValues.Any(pv => + string.Equals(pv.ToString(), strValue, StringComparison.OrdinalIgnoreCase)); + + if (!valueValid) + { + IScriptExtent valueExtent = GetValueExtent(ruleArgAstMap, argName) + ?? argKeyExtent; + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidOptionValue, + ruleName, argName, strValue, + string.Join(", ", optionInfo.PossibleValues.Select(v => v.ToString()))), + valueExtent, + DiagnosticSeverity.Error); + } + } + } + } + } + + if (Quiet) + { + WriteObject(_diagnostics.Count == 0); + } + else + { + EmitDiagnostics(); + } + } + + #endregion Overrides + + #region Diagnostics + + /// + /// Records a DiagnosticRecord for later emission. + /// + private void AddDiagnostic(string message, IScriptExtent extent, DiagnosticSeverity severity) + { + _diagnostics.Add(new DiagnosticRecord( + message, + extent, + RuleName, + severity, + _resolvedPath)); + } + + /// + /// Writes all collected DiagnosticRecord objects to the output pipeline. + /// + private void EmitDiagnostics() + { + foreach (var diag in _diagnostics) + { + WriteObject(diag); + } + } + + #endregion Diagnostics + + #region AST helpers + + /// + /// Builds a case-insensitive dictionary mapping key names to their + /// (key-expression, value-statement) tuples in a HashtableAst. + /// + private static Dictionary> BuildAstKeyMap(HashtableAst hashtableAst) + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (hashtableAst?.KeyValuePairs == null) + { + return map; + } + + foreach (var pair in hashtableAst.KeyValuePairs) + { + if (pair.Item1 is StringConstantExpressionAst keyAst) + { + map[keyAst.Value] = pair; + } + } + + return map; + } + + /// + /// Returns the IScriptExtent of a key expression in an AST key map, + /// or null if the key is not found. + /// + private static IScriptExtent GetKeyExtent( + Dictionary> astMap, + string keyName) + { + if (astMap.TryGetValue(keyName, out var pair)) + { + return pair.Item1.Extent; + } + + return null; + } + + /// + /// Returns the IScriptExtent of a value expression in an AST key map, + /// or null if the key is not found. + /// + private static IScriptExtent GetValueExtent( + Dictionary> astMap, + string keyName) + { + if (astMap.TryGetValue(keyName, out var pair)) + { + ExpressionAst valueExpr = (pair.Item2 as PipelineAst)?.GetPureExpression(); + if (valueExpr != null) + { + return valueExpr.Extent; + } + + return pair.Item2.Extent; + } + + return null; + } + + /// + /// Returns the HashtableAst for a nested hashtable value, or null. + /// + private static HashtableAst GetNestedHashtable( + Dictionary> astMap, + string keyName) + { + if (astMap.TryGetValue(keyName, out var pair)) + { + ExpressionAst valueExpr = (pair.Item2 as PipelineAst)?.GetPureExpression(); + return valueExpr as HashtableAst; + } + + return null; + } + + /// + /// Returns the IScriptExtent of a specific string element within an + /// array value in the AST, matching by string value. Falls back to + /// the array extent or key extent if not found. + /// + private static IScriptExtent FindArrayElementExtent( + Dictionary> astMap, + string keyName, + string elementValue) + { + if (!astMap.TryGetValue(keyName, out var pair)) + { + return null; + } + + ExpressionAst valueExpr = (pair.Item2 as PipelineAst)?.GetPureExpression(); + if (valueExpr == null) + { + return pair.Item2.Extent; + } + + // Look for the string element in array expressions. + IEnumerable stringNodes = valueExpr.FindAll( + ast => ast is StringConstantExpressionAst strAst + && string.Equals(strAst.Value, elementValue, StringComparison.OrdinalIgnoreCase), + searchNestedScriptBlocks: false); + + Ast match = stringNodes.FirstOrDefault(); + return match?.Extent ?? valueExpr.Extent; + } + + #endregion AST helpers + + #region Validation helpers + + /// + /// Validates that rule names in an array field exist in the known rule set. + /// Wildcard entries are skipped. + /// + private void ValidateRuleNameArray( + IEnumerable ruleNames, + Dictionary ruleMap, + string fieldName, + Dictionary> topLevelMap) + { + if (ruleNames == null) + { + return; + } + + foreach (string name in ruleNames) + { + if (WildcardPattern.ContainsWildcardCharacters(name)) + { + continue; + } + + if (!ruleMap.ContainsKey(name)) + { + IScriptExtent extent = FindArrayElementExtent(topLevelMap, fieldName, name) + ?? GetKeyExtent(topLevelMap, fieldName); + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileRuleNotFound, fieldName, name), + extent, + DiagnosticSeverity.Error); + } + } + } + + /// + /// Validates severity values against the RuleSeverity enum. + /// + private void ValidateSeverityArray( + IEnumerable severities, + Dictionary> topLevelMap) + { + if (severities == null) + { + return; + } + + foreach (string sev in severities) + { + if (!Enum.TryParse(sev, ignoreCase: true, out _)) + { + IScriptExtent extent = FindArrayElementExtent(topLevelMap, "Severity", sev) + ?? GetKeyExtent(topLevelMap, "Severity"); + + AddDiagnostic( + string.Format(CultureInfo.CurrentCulture, + Strings.SettingsFileInvalidSeverity, + sev, + string.Join(", ", Enum.GetNames(typeof(RuleSeverity)))), + extent, + DiagnosticSeverity.Error); + } + } + } + + /// + /// Checks whether a value from the settings file is compatible with the + /// target CLR property type. + /// + private static bool IsValueCompatible(object value, Type targetType) + { + if (value == null) + { + return !targetType.IsValueType; + } + + Type valueType = value.GetType(); + + // Direct assignment. + if (targetType.IsAssignableFrom(valueType)) + { + return true; + } + + // Bool property — only accept bool. + if (targetType == typeof(bool)) + { + return value is bool; + } + + // Int property — accept int, long within range, or a string that parses as int. + if (targetType == typeof(int)) + { + if (value is int) + { + return true; + } + + if (value is long l) + { + return l >= int.MinValue && l <= int.MaxValue; + } + + return value is string s && int.TryParse(s, out _); + } + + // String property — only accept actual strings so that non-string + // values for constrained options (with PossibleValues) are caught by + // the type check rather than silently skipping enum validation. + if (targetType == typeof(string)) + { + return value is string; + } + + // Array property — accept arrays or a single element of the right kind. + if (targetType.IsArray) + { + Type elementType = targetType.GetElementType(); + + if (valueType.IsArray) + { + // Check that each element is compatible. + foreach (object item in (Array)value) + { + if (!IsValueCompatible(item, elementType)) + { + return false; + } + } + + return true; + } + + // A single value can be wrapped into a one-element array. + return IsValueCompatible(value, elementType); + } + + return false; + } + + /// + /// Returns a user-friendly name for a CLR type for use in error messages. + /// + private static string GetFriendlyTypeName(Type type) + { + if (type == typeof(bool)) return "bool"; + if (type == typeof(int)) return "int"; + if (type == typeof(string)) return "string"; + if (type == typeof(string[])) return "string[]"; + if (type == typeof(int[])) return "int[]"; + return type.Name; + } + + #endregion Validation helpers + } +} diff --git a/Engine/Generic/RuleInfo.cs b/Engine/Generic/RuleInfo.cs index 755d16d15..8d8977d12 100644 --- a/Engine/Generic/RuleInfo.cs +++ b/Engine/Generic/RuleInfo.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic @@ -18,6 +19,7 @@ public class RuleInfo private string sourceName; private RuleSeverity ruleSeverity; private Type implementingType; + private IReadOnlyList options; /// /// Name: The name of the rule. @@ -90,6 +92,16 @@ public Type ImplementingType private set { implementingType = value; } } + /// + /// Options : The configurable properties for this rule, if any. + /// + [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + public IReadOnlyList Options + { + get { return options; } + private set { options = value; } + } + /// /// Constructor for a RuleInfo. /// @@ -128,6 +140,29 @@ public RuleInfo(string name, string commonName, string description, SourceType s ImplementingType = implementingType; } + /// + /// Constructor for a RuleInfo. + /// + /// Name of the rule. + /// Common Name of the rule. + /// Description of the rule. + /// Source type of the rule. + /// Source name of the rule. + /// Severity of the rule. + /// The dotnet type of the rule. + /// The configurable properties for this rule. + public RuleInfo(string name, string commonName, string description, SourceType sourceType, string sourceName, RuleSeverity severity, Type implementingType, IReadOnlyList options) + { + RuleName = name; + CommonName = commonName; + Description = description; + SourceType = sourceType; + SourceName = sourceName; + Severity = severity; + ImplementingType = implementingType; + Options = options; + } + public override string ToString() { return RuleName; diff --git a/Engine/Generic/RuleOptionInfo.cs b/Engine/Generic/RuleOptionInfo.cs new file mode 100644 index 000000000..71e704612 --- /dev/null +++ b/Engine/Generic/RuleOptionInfo.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic +{ + /// + /// Holds metadata for a single configurable rule property. + /// + public class RuleOptionInfo + { + /// + /// The name of the configurable property. + /// + public string Name { get; internal set; } + + /// + /// The CLR type of the property value. + /// + public Type OptionType { get; internal set; } + + /// + /// The default value declared via the ConfigurableRuleProperty attribute. + /// + public object DefaultValue { get; internal set; } + + /// + /// The set of valid values for this property, if constrained. + /// Null when any value of the declared type is acceptable. + /// + public object[] PossibleValues { get; internal set; } + + /// + /// Extracts RuleOptionInfo entries for every ConfigurableRuleProperty on + /// the given rule. For string properties backed by a private enum, the + /// possible values are populated from the enum members. + /// + /// The rule instance to inspect. + /// + /// A list of option metadata, ordered with Enable first then the + /// remainder sorted alphabetically. + /// + public static List GetRuleOptions(IRule rule) + { + var options = new List(); + Type ruleType = rule.GetType(); + + PropertyInfo[] properties = ruleType.GetProperties(BindingFlags.Instance | BindingFlags.Public); + + // Collect all private nested enums declared on the rule type so we + // can match them against string properties whose default value is an + // enum member name. + Type[] nestedEnums = ruleType + .GetNestedTypes(BindingFlags.NonPublic | BindingFlags.Public) + .Where(t => t.IsEnum) + .ToArray(); + + foreach (PropertyInfo prop in properties) + { + var attr = prop.GetCustomAttribute(inherit: true); + if (attr == null) + { + continue; + } + + var info = new RuleOptionInfo + { + Name = prop.Name, + OptionType = prop.PropertyType, + DefaultValue = attr.DefaultValue, + PossibleValues = null + }; + + // For string properties, attempt to find a matching private enum + // whose member names include the default value. This mirrors the + // pattern used by rules such as UseConsistentIndentation and + // ProvideCommentHelp where a string property is parsed into a + // private enum via Enum.TryParse. + // + // When multiple enums contain the default value (e.g. both have + // a "None" member), prefer the enum whose name contains the + // property name or vice-versa (e.g. property "Kind" matches enum + // "IndentationKind"). This helps avoid incorrect matches when a rule + // declares several enums with possible overlapping member names. + if (prop.PropertyType == typeof(string) && attr.DefaultValue is string defaultStr) + { + Type bestMatch = null; + bool bestHasNameRelation = false; + + foreach (Type enumType in nestedEnums) + { + if (!Enum.GetNames(enumType).Any(n => + string.Equals(n, defaultStr, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + bool hasNameRelation = + enumType.Name.IndexOf(prop.Name, StringComparison.OrdinalIgnoreCase) >= 0 || + prop.Name.IndexOf(enumType.Name, StringComparison.OrdinalIgnoreCase) >= 0; + + // Take this enum if we have no match yet, or if it has a + // name-based relationship and the previous match did not. + if (bestMatch == null || (hasNameRelation && !bestHasNameRelation)) + { + bestMatch = enumType; + bestHasNameRelation = hasNameRelation; + } + } + + if (bestMatch != null) + { + info.PossibleValues = Enum.GetNames(bestMatch); + } + } + + options.Add(info); + } + + // Sort with "Enable" first, then alphabetically by name for consistent ordering. + return options + .OrderBy(o => string.Equals(o.Name, "Enable", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(o => o.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/Engine/Helper.cs b/Engine/Helper.cs index a162bfbcf..f36d17433 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -1468,7 +1468,7 @@ public static string[] ProcessCustomRulePaths(string[] rulePaths, SessionState s outPaths.Add(path); } - return outPaths.ToArray(); + return outPaths.Count == 0 ? null : outPaths.ToArray(); } diff --git a/Engine/PSScriptAnalyzer.psd1 b/Engine/PSScriptAnalyzer.psd1 index 993677254..80a5822da 100644 --- a/Engine/PSScriptAnalyzer.psd1 +++ b/Engine/PSScriptAnalyzer.psd1 @@ -65,7 +65,7 @@ FormatsToProcess = @('ScriptAnalyzer.format.ps1xml') FunctionsToExport = @() # Cmdlets to export from this module -CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter') +CmdletsToExport = @('Get-ScriptAnalyzerRule', 'Invoke-ScriptAnalyzer', 'Invoke-Formatter', 'New-ScriptAnalyzerSettingsFile', 'Test-ScriptAnalyzerSettingsFile') # Variables to export from this module VariablesToExport = @() diff --git a/Engine/PSScriptAnalyzer.psm1 b/Engine/PSScriptAnalyzer.psm1 index 7e2ca8f31..50348fa75 100644 --- a/Engine/PSScriptAnalyzer.psm1 +++ b/Engine/PSScriptAnalyzer.psm1 @@ -49,6 +49,10 @@ if (Get-Command Register-ArgumentCompleter -ErrorAction Ignore) { } + Register-ArgumentCompleter -CommandName 'New-ScriptAnalyzerSettingsFile' ` + -ParameterName 'BaseOnPreset' ` + -ScriptBlock $settingPresetCompleter + Function RuleNameCompleter { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParmeter) diff --git a/Engine/Strings.resx b/Engine/Strings.resx index 346a25aa6..86ad0cf2c 100644 --- a/Engine/Strings.resx +++ b/Engine/Strings.resx @@ -324,4 +324,40 @@ Ignoring 'TypeNotFound' parse error on type '{0}'. Check if the specified type is correct. This can also be due the type not being known at parse time due to types imported by 'using' statements. + + '{0}' is not a recognised preset. Valid presets are: {1} + + + Could not locate the preset '{0}'. + + + A settings file already exists at '{0}'. Use -Force to overwrite. + + + The settings file '{0}' does not exist. + + + Failed to parse settings file: {0} + + + {0}: rule '{1}' not found. + + + Rules.{0}: rule not found. + + + Rules.{0}: this rule is not configurable. + + + Rules.{0}.{1}: unrecognised option. + + + Rules.{0}.{1}: '{2}' is not a valid value. Expected one of: {3} + + + Severity: '{0}' is not a valid severity. Expected one of: {1} + + + Rules.{0}.{1}: expected a value of type {2}. + \ No newline at end of file diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index 422b585bf..d1e2cd98d 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -180,3 +180,33 @@ Describe "TestImplementingType" { $type.BaseType.Name | Should -Be "ConfigurableRule" } } + +Describe "TestOptions" { + BeforeAll { + $configurableRule = Get-ScriptAnalyzerRule PSUseConsistentIndentation + $nonConfigurableRule = Get-ScriptAnalyzerRule PSAvoidUsingInvokeExpression + } + + It "returns Options for a configurable rule" { + $configurableRule.Options | Should -Not -BeNullOrEmpty + } + + It "includes the Enable option" { + $configurableRule.Options.Name | Should -Contain 'Enable' + } + + It "places Enable as the first option" { + $configurableRule.Options[0].Name | Should -Be 'Enable' + } + + It "populates PossibleValues for enum-backed string properties" { + $kindOption = $configurableRule.Options | Where-Object Name -eq 'Kind' + $kindOption.PossibleValues | Should -Not -BeNullOrEmpty + $kindOption.PossibleValues | Should -Contain 'Space' + $kindOption.PossibleValues | Should -Contain 'Tab' + } + + It "returns null Options for a non-configurable rule" { + $nonConfigurableRule.Options | Should -BeNullOrEmpty + } +} diff --git a/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 new file mode 100644 index 000000000..f23cdf5f6 --- /dev/null +++ b/Tests/Engine/NewScriptAnalyzerSettingsFile.tests.ps1 @@ -0,0 +1,283 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $settingsFileName = 'PSScriptAnalyzerSettings.psd1' +} + +Describe "New-ScriptAnalyzerSettingsFile" { + Context "When creating a default settings file (no preset)" { + BeforeAll { + $testDir = Join-Path $TestDrive 'default' + New-Item -ItemType Directory -Path $testDir | Out-Null + $result = New-ScriptAnalyzerSettingsFile -Path $testDir + $settingsPath = Join-Path $testDir $settingsFileName + } + + It "Should return a FileInfo object" { + $result | Should -BeOfType ([System.IO.FileInfo]) + } + + It "Should create the settings file" { + $settingsPath | Should -Exist + } + + It "Should produce a valid PSD1 that can be parsed" { + { Import-PowerShellDataFile -Path $settingsPath } | Should -Not -Throw + } + + It "Should contain the IncludeRules key with at least one rule" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeRules') | Should -BeTrue + $data['IncludeRules'].Count | Should -BeGreaterThan 0 + } + + It "Should contain the ExcludeRules key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('ExcludeRules') | Should -BeTrue + } + + It "Should contain the Severity key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('Severity') | Should -BeTrue + } + + It "Should contain the CustomRulePath key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('CustomRulePath') | Should -BeTrue + } + + It "Should contain the IncludeDefaultRules key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeDefaultRules') | Should -BeTrue + } + + It "Should contain the RecurseCustomRulePath key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('RecurseCustomRulePath') | Should -BeTrue + } + + It "Should contain the Rules key" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('Rules') | Should -BeTrue + } + + It "Should include all available rules in IncludeRules" { + $data = Import-PowerShellDataFile -Path $settingsPath + $allRules = Get-ScriptAnalyzerRule | ForEach-Object RuleName + foreach ($rule in $allRules) { + $data['IncludeRules'] | Should -Contain $rule + } + } + + It "Should place Enable first in rule settings" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '(?s)PSUseConsistentIndentation = @\{\s+Enable' + } + + It "Should include inline comments listing valid values for constrained properties" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match "# Space, Tab" + } + + It "Should include a comment with valid severity values" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Information, Warning, Error, ParseError' + } + + It "Should be usable with Invoke-ScriptAnalyzer" { + { Invoke-ScriptAnalyzer -ScriptDefinition '"hello"' -Settings $settingsPath } | Should -Not -Throw + } + + It "Should contain a header with the PSScriptAnalyzer version" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# PSScriptAnalyzer settings file \(\d+\.\d+\.\d+\)' + } + + It "Should contain a header with the generation tool name" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Generated by New-ScriptAnalyzerSettingsFile\.' + } + + It "Should not mention a preset in the header" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Not -Match '# Based on the' + } + + It "Should contain a section comment before IncludeRules" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Rules to run\. When populated, only these rules are used\.' + } + + It "Should contain a section comment before ExcludeRules" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Rules to skip\. Takes precedence over IncludeRules\.' + } + + It "Should contain a section comment before Severity" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Only report diagnostics at these severity levels\.' + } + + It "Should contain a section comment before Rules" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Per-rule configuration\. Only configurable rules appear here\.' + } + } + + Context "When creating a settings file based on a preset" { + BeforeAll { + $testDir = Join-Path $TestDrive 'preset' + New-Item -ItemType Directory -Path $testDir | Out-Null + $result = New-ScriptAnalyzerSettingsFile -Path $testDir -BaseOnPreset CodeFormatting + $settingsPath = Join-Path $testDir $settingsFileName + } + + It "Should create the settings file" { + $settingsPath | Should -Exist + } + + It "Should produce a valid PSD1" { + { Import-PowerShellDataFile -Path $settingsPath } | Should -Not -Throw + } + + It "Should contain all top-level fields" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeRules') | Should -BeTrue + $data.ContainsKey('ExcludeRules') | Should -BeTrue + $data.ContainsKey('Severity') | Should -BeTrue + $data.ContainsKey('CustomRulePath') | Should -BeTrue + $data.ContainsKey('IncludeDefaultRules') | Should -BeTrue + $data.ContainsKey('RecurseCustomRulePath') | Should -BeTrue + $data.ContainsKey('Rules') | Should -BeTrue + } + + It "Should include the preset rules in IncludeRules" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data['IncludeRules'] | Should -Contain 'PSPlaceOpenBrace' + $data['IncludeRules'] | Should -Contain 'PSUseConsistentIndentation' + } + + It "Should include rule configuration from the preset" { + $data = Import-PowerShellDataFile -Path $settingsPath + $data['Rules'].ContainsKey('PSPlaceOpenBrace') | Should -BeTrue + $data['Rules']['PSPlaceOpenBrace']['Enable'] | Should -BeTrue + } + + It "Should be usable with Invoke-ScriptAnalyzer" { + { Invoke-ScriptAnalyzer -ScriptDefinition '"hello"' -Settings $settingsPath } | Should -Not -Throw + } + + It "Should mention the preset name in the header" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match "# Based on the 'CodeFormatting' preset\." + } + + It "Should contain section comments" { + $content = Get-Content -Path $settingsPath -Raw + $content | Should -Match '# Rules to run' + $content | Should -Match '# Rules to skip' + $content | Should -Match '# Only report diagnostics at these severity levels' + $content | Should -Match '# Per-rule configuration' + } + } + + Context "When a settings file already exists at the target path" { + BeforeAll { + $testDir = Join-Path $TestDrive 'exists' + New-Item -ItemType Directory -Path $testDir | Out-Null + Set-Content -Path (Join-Path $testDir $settingsFileName) -Value '@{}' + } + + It "Should throw a terminating error without -Force" { + { New-ScriptAnalyzerSettingsFile -Path $testDir -ErrorAction Stop } | + Should -Throw -ErrorId 'SettingsFileAlreadyExists*' + } + } + + Context "When using -Force to overwrite an existing file" { + BeforeAll { + $testDir = Join-Path $TestDrive 'force' + New-Item -ItemType Directory -Path $testDir | Out-Null + Set-Content -Path (Join-Path $testDir $settingsFileName) -Value '@{}' + $result = New-ScriptAnalyzerSettingsFile -Path $testDir -Force + $settingsPath = Join-Path $testDir $settingsFileName + } + + It "Should overwrite the existing file" { + $settingsPath | Should -Exist + $data = Import-PowerShellDataFile -Path $settingsPath + $data.ContainsKey('IncludeRules') | Should -BeTrue + } + + It "Should return a FileInfo object" { + $result | Should -BeOfType ([System.IO.FileInfo]) + } + } + + Context "When using -WhatIf" { + It "Should not create the settings file" { + $testDir = Join-Path $TestDrive 'whatif' + New-Item -ItemType Directory -Path $testDir | Out-Null + $settingsPath = Join-Path $testDir $settingsFileName + # WhatIf messages are written directly to the host UI by ShouldProcess, + # bypassing all output streams. Run in a new runspace whose default host + # silently discards host output. + $ps = [powershell]::Create() + try { + $null = $ps.AddCommand('Import-Module').AddParameter('Name', (Get-Module PSScriptAnalyzer).Path).Invoke() + $ps.Commands.Clear() + $null = $ps.AddCommand('New-ScriptAnalyzerSettingsFile').AddParameter('Path', $testDir).AddParameter('WhatIf', $true).Invoke() + } + finally { + $ps.Dispose() + } + $settingsPath | Should -Not -Exist + } + } + + Context "When the -Path parameter points to a non-existent directory" { + BeforeAll { + $nestedDir = Join-Path (Join-Path (Join-Path $TestDrive 'nested') 'sub') 'folder' + $result = New-ScriptAnalyzerSettingsFile -Path $nestedDir + $settingsPath = Join-Path $nestedDir $settingsFileName + } + + It "Should create the directory and the settings file" { + $settingsPath | Should -Exist + } + } + + Context "When using the default path (current directory)" { + BeforeAll { + $testDir = Join-Path $TestDrive 'cwd' + New-Item -ItemType Directory -Path $testDir | Out-Null + Push-Location $testDir + $result = New-ScriptAnalyzerSettingsFile + $settingsPath = Join-Path $testDir $settingsFileName + } + + AfterAll { + Pop-Location + } + + It "Should create the file in the current working directory" { + $settingsPath | Should -Exist + } + } + + Context "Generated settings file for each preset" { + BeforeDiscovery { + $presets = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Settings]::GetSettingPresets() | + ForEach-Object { @{ Preset = $_ } } + } + + It "Should produce a valid PSD1 for the '' preset" -TestCases $presets { + $testDir = Join-Path $TestDrive "preset-$Preset" + New-Item -ItemType Directory -Path $testDir | Out-Null + $settingsPath = Join-Path $testDir $settingsFileName + New-ScriptAnalyzerSettingsFile -Path $testDir -BaseOnPreset $Preset + { Import-PowerShellDataFile -Path $settingsPath } | Should -Not -Throw + } + } +} diff --git a/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 b/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 new file mode 100644 index 000000000..01d2664d1 --- /dev/null +++ b/Tests/Engine/TestScriptAnalyzerSettingsFile.tests.ps1 @@ -0,0 +1,424 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe "Test-ScriptAnalyzerSettingsFile" { + Context "Given a valid generated settings file" { + BeforeAll { + $testDir = Join-Path $TestDrive 'valid' + New-Item -ItemType Directory -Path $testDir | Out-Null + New-ScriptAnalyzerSettingsFile -Path $testDir + $settingsPath = Join-Path $testDir 'PSScriptAnalyzerSettings.psd1' + } + + It "Should produce no output when the file is valid" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue + } + } + + Context "Given a valid preset-based settings file" { + BeforeAll { + $testDir = Join-Path $TestDrive 'preset' + New-Item -ItemType Directory -Path $testDir | Out-Null + New-ScriptAnalyzerSettingsFile -Path $testDir -BaseOnPreset CodeFormatting + $settingsPath = Join-Path $testDir 'PSScriptAnalyzerSettings.psd1' + } + + It "Should produce no output when the file is valid" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue + } + } + + Context "Given a file that does not exist" { + It "Should write a non-terminating error and produce no output" { + $bogusPath = Join-Path $TestDrive 'nonexistent.psd1' + $result = Test-ScriptAnalyzerSettingsFile -Path $bogusPath -ErrorVariable errs -ErrorAction SilentlyContinue + $result | Should -BeNullOrEmpty + $errs | Should -Not -BeNullOrEmpty + $errs[0].FullyQualifiedErrorId | Should -BeLike 'SettingsFileNotFound*' + } + + It "Should return false with -Quiet" { + $bogusPath = Join-Path $TestDrive 'nonexistent.psd1' + Test-ScriptAnalyzerSettingsFile -Path $bogusPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an unknown rule name" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'unknown-rule.psd1' + $content = " + @{ + IncludeRules = @( + 'PSBogusRuleThatDoesNotExist' + ) + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + } + + It "Should report the unknown rule name in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*PSBogusRuleThatDoesNotExist*" + } + + It "Should include an extent pointing to the offending text" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent | Should -Not -BeNullOrEmpty + $result[0].Extent.Text | Should -Be "'PSBogusRuleThatDoesNotExist'" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an invalid rule option name" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-option.psd1' + $content = " + @{ + IncludeRules = @('PSUseConsistentIndentation') + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + CompletelyBogusOption = 42 + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + } + + It "Should report the unrecognised option in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*CompletelyBogusOption*unrecognised option*" + } + + It "Should include an extent pointing to the option name" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent.Text | Should -Be 'CompletelyBogusOption' + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an invalid rule option value" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-value.psd1' + $content = " + @{ + IncludeRules = @('PSUseConsistentIndentation') + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + Kind = 'banana' + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + } + + It "Should report the invalid value in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*banana*not a valid value*" + } + + It "Should include an extent pointing to the bad value" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent.Text | Should -Be "'banana'" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with an invalid severity value" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-severity.psd1' + $content = " + @{ + Severity = @('Critical') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + } + + It "Should report the invalid severity in the message" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Message | Should -BeLike "*Critical*not a valid severity*" + } + + It "Should include an extent pointing to the bad value" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result[0].Extent.Text | Should -Be "'Critical'" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with wildcard rule names in IncludeRules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'wildcard.psd1' + $content = " + @{ + IncludeRules = @('PSDSC*') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should produce no output - wildcards are valid" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + } + + Context "Given an unparseable file" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'broken.psd1' + Set-Content -Path $settingsPath -Value 'this is not valid psd1 content {{{' + } + + It "Should output DiagnosticRecord objects with parse errors" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0] | Should -BeOfType ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]) + $result[0].Severity | Should -Be 'ParseError' + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "DiagnosticRecord properties" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'diag-props.psd1' + $content = " + @{ + Severity = @('Critical') + } + " + Set-Content -Path $settingsPath -Value $content + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + } + + It "Should set RuleName to Test-ScriptAnalyzerSettingsFile" { + $result[0].RuleName | Should -Be 'Test-ScriptAnalyzerSettingsFile' + } + + It "Should set ScriptPath to the settings file path" { + $result[0].ScriptPath | Should -Be $settingsPath + } + + It "Should set Severity to Error for validation problems" { + $result[0].Severity | Should -Be 'Error' + } + + It "Should include line number information in the extent" { + $result[0].Extent.StartLineNumber | Should -BeGreaterThan 0 + } + } + + Context "Given a file with a wrong type for a bool option" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-bool.psd1' + $content = " + @{ + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = 123 + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord for the type mismatch" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0].Message | Should -BeLike "*Enable*expected a value of type bool*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with a string where an int is expected" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-int.psd1' + $content = " + @{ + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + IndentationSize = 'abc' + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should output a DiagnosticRecord for the type mismatch" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0].Message | Should -BeLike "*IndentationSize*expected a value of type int*" + } + + It "Should return false with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeFalse + } + } + + Context "Given a file with a string where a string array is expected" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'bad-array.psd1' + $content = " + @{ + Rules = @{ + PSUseSingularNouns = @{ + NounAllowList = 'Data' + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should accept a single string for a string array property" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + } + + Context "Given a file with valid types for all options" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'valid-types.psd1' + $content = " + @{ + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = `$true + IndentationSize = 4 + } + } + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should produce no output" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + } + + Context "Given a file with IncludeDefaultRules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'include-defaults.psd1' + $content = " + @{ + IncludeDefaultRules = `$true + IncludeRules = @('PSUseConsistentIndentation') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should validate built-in rules when IncludeDefaultRules is true" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue + } + } + + Context "Given a file with CustomRulePath pointing to community rules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'custom-rules.psd1' + $communityRulesPath = Join-Path $PSScriptRoot 'CommunityAnalyzerRules' + $content = " + @{ + CustomRulePath = @('$communityRulesPath') + IncludeDefaultRules = `$true + IncludeRules = @('PSUseConsistentIndentation', 'Measure-RequiresModules') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should validate both built-in and custom rule names" { + $result = Test-ScriptAnalyzerSettingsFile -Path $settingsPath + $result | Should -BeNullOrEmpty + } + + It "Should return true with -Quiet" { + Test-ScriptAnalyzerSettingsFile -Path $settingsPath -Quiet | Should -BeTrue + } + } + + Context "Given a file with CustomRulePath but without IncludeDefaultRules" { + BeforeAll { + $settingsPath = Join-Path $TestDrive 'custom-no-defaults.psd1' + $communityRulesPath = Join-Path $PSScriptRoot 'CommunityAnalyzerRules' + $content = " + @{ + CustomRulePath = @('$communityRulesPath') + IncludeRules = @('PSUseConsistentIndentation') + } + " + Set-Content -Path $settingsPath -Value $content + } + + It "Should report built-in rules as unknown when IncludeDefaultRules is not set" { + $result = @(Test-ScriptAnalyzerSettingsFile -Path $settingsPath) + $result.Count | Should -BeGreaterThan 0 + $result[0].Message | Should -BeLike "*PSUseConsistentIndentation*" + } + } +} diff --git a/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md b/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md new file mode 100644 index 000000000..30b27b978 --- /dev/null +++ b/docs/Cmdlets/New-ScriptAnalyzerSettingsFile.md @@ -0,0 +1,187 @@ +--- +external help file: Microsoft.Windows.PowerShell.ScriptAnalyzer.dll-Help.xml +Module Name: PSScriptAnalyzer +ms.date: 04/17/2026 +schema: 2.0.0 +--- + +# New-ScriptAnalyzerSettingsFile + +## SYNOPSIS +Creates a new PSScriptAnalyzer settings file. + +## SYNTAX + +``` +New-ScriptAnalyzerSettingsFile [[-Path] ] [-BaseOnPreset ] [-Force] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION + +The `New-ScriptAnalyzerSettingsFile` cmdlet creates a `PSScriptAnalyzerSettings.psd1` file in the +specified directory. + +When the **BaseOnPreset** parameter is provided, the generated file contains the rules and +configuration defined by the given preset. + +When **BaseOnPreset** is not provided, the generated file includes all current rules in the +`IncludeRules` list and populates the `Rules` section with all configurable properties, set to their +default values. + +If a settings file already exists at the target path, the cmdlet emits a terminating error unless +the **Force** parameter is specified - in which case it's overwritten. + +## EXAMPLES + +### EXAMPLE 1 - Create a default settings file in the current directory + +```powershell +New-ScriptAnalyzerSettingsFile +``` + +Creates `PSScriptAnalyzerSettings.psd1` in the current working directory including all rules and +all configurable options set to their defaults. + +### EXAMPLE 2 - Create a settings file based on a preset + +```powershell +New-ScriptAnalyzerSettingsFile -BaseOnPreset CodeFormatting +``` + +Creates a settings file pre-populated with the rules and configuration from the `CodeFormatting` +preset. + +### EXAMPLE 3 - Create a settings file in a specific directory + +```powershell +New-ScriptAnalyzerSettingsFile -Path ./src/MyModule +``` + +Creates the settings file in the `./src/MyModule` directory. + +### EXAMPLE 4 - Preview the operation without creating the file + +```powershell +New-ScriptAnalyzerSettingsFile -WhatIf +``` + +Shows what the cmdlet would do without actually writing the file. + +## PARAMETERS + +### -Path + +The directory where the settings file will be created. Defaults to the current working directory when not specified. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: Current directory +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -BaseOnPreset + +The name of a built-in preset to use as the basis for the generated settings file. Valid values are +discovered at runtime from the shipped preset files and can be tab-completed in the shell. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf + +Shows what would happen if the cmdlet runs. The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force + +Overwrite an existing settings file at the target path. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, +-WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +## OUTPUTS + +### System.IO.FileInfo + +The cmdlet returns a **FileInfo** object representing the created settings file. + +## NOTES + +The output file is always named `PSScriptAnalyzerSettings.psd1` so that the automatic settings +discovery in `Invoke-ScriptAnalyzer` picks it up when analysing scripts in the same directory. + +Note: Relative paths in `CustomRulePath` are resolved from the caller's current working directory, +Relative paths in `CustomRulePath` are resolved from the caller's current working directory, +not from the location of the settings file. This matches `Invoke-ScriptAnalyzer` behavior. + +## RELATED LINKS + +[Invoke-ScriptAnalyzer](Invoke-ScriptAnalyzer.md) + +[Get-ScriptAnalyzerRule](Get-ScriptAnalyzerRule.md) + +[Invoke-Formatter](Invoke-Formatter.md) + +[Test-ScriptAnalyzerSettingsFile](Test-ScriptAnalyzerSettingsFile.md) diff --git a/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md b/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md new file mode 100644 index 000000000..b313f93dd --- /dev/null +++ b/docs/Cmdlets/Test-ScriptAnalyzerSettingsFile.md @@ -0,0 +1,151 @@ +--- +external help file: Microsoft.Windows.PowerShell.ScriptAnalyzer.dll-Help.xml +Module Name: PSScriptAnalyzer +ms.date: 04/17/2026 +schema: 2.0.0 +--- + +# Test-ScriptAnalyzerSettingsFile + +## SYNOPSIS +Validates a PSScriptAnalyzer settings file as a self-contained unit. + +## SYNTAX + +``` +Test-ScriptAnalyzerSettingsFile [-Path] [-Quiet] [] +``` + +## DESCRIPTION + +The `Test-ScriptAnalyzerSettingsFile` cmdlet validates a PSScriptAnalyzer settings file as a +self-contained unit. It reads `CustomRulePath`, `RecurseCustomRulePath`, and `IncludeDefaultRules` +directly from the file so that validation reflects the same rule set `Invoke-ScriptAnalyzer` would +see when given the same file. + +The cmdlet verifies that: + +- The file can be parsed as a PowerShell data file. +- All rule names referenced in `IncludeRules`, `ExcludeRules`, and `Rules` correspond to known + rules (wildcard patterns are skipped). +- All `Severity` values are valid. +- Rule option names in the `Rules` section correspond to actual configurable properties. +- Rule option values that are constrained to a set of choices contain a valid value. + +By default, when problems are found the cmdlet outputs a `DiagnosticRecord` for each one, with the +source extent pointing to the offending text in the file. This is the same object type returned by +`Invoke-ScriptAnalyzer`, so existing formatting and tooling works out of the box. When the file is +valid, no output is produced. + +When `-Quiet` is specified the cmdlet returns only `$true` or `$false` and suppresses all +diagnostic output. + +## EXAMPLES + +### EXAMPLE 1 - Validate a settings file + +```powershell +Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 +``` + +Outputs a `DiagnosticRecord` for each problem found, with line and column information. Produces no +output when the file is valid. + +### EXAMPLE 2 - Validate quietly in a conditional + +```powershell +if (Test-ScriptAnalyzerSettingsFile -Path ./PSScriptAnalyzerSettings.psd1 -Quiet) { + Invoke-ScriptAnalyzer -Path ./src -Settings ./PSScriptAnalyzerSettings.psd1 +} +``` + +Returns `$true` or `$false` without producing diagnostic output. + +### EXAMPLE 3 - Validate a file that uses custom rules + +```powershell +# Settings.psd1 contains CustomRulePath and IncludeDefaultRules keys. +# The cmdlet reads those from the file directly — no extra parameters needed. +Test-ScriptAnalyzerSettingsFile -Path ./Settings.psd1 +``` + +Validates rule names against both built-in and custom rules as specified in the settings file. + +## PARAMETERS + +### -Path + +The path to the settings file to validate. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Quiet + +Suppresses diagnostic output and returns only `$true` or `$false`. Without this switch the cmdlet +outputs a `DiagnosticRecord` for each problem found and produces no output when the file is valid. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, +-WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +## OUTPUTS + +### Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord + +Without `-Quiet`, a `DiagnosticRecord` is output for each problem found. Each record includes the +error message, the source extent (file, line and column), a severity, and the rule name +`Test-ScriptAnalyzerSettingsFile`. No output is produced when the file is valid. + +### System.Boolean + +With `-Quiet`, returns `$true` when the file is valid and `$false` otherwise. + +## NOTES + +The cmdlet reads `CustomRulePath`, `RecurseCustomRulePath`, and `IncludeDefaultRules` from the +settings file so it validates rule names against the same set of rules that `Invoke-ScriptAnalyzer` +would load. This means the settings file is validated as a self-contained unit without requiring +extra command-line parameters. + +Note: Relative paths in `CustomRulePath` are resolved from the caller's current working directory, +not from the location of the settings file. This matches `Invoke-ScriptAnalyzer` behaviour. + +The `DiagnosticRecord` objects use the same type as `Invoke-ScriptAnalyzer`, so they benefit from +the same default formatting and can be piped to the same downstream tooling. + +## RELATED LINKS + +[New-ScriptAnalyzerSettingsFile](New-ScriptAnalyzerSettingsFile.md) + +[Invoke-ScriptAnalyzer](Invoke-ScriptAnalyzer.md) + +[Get-ScriptAnalyzerRule](Get-ScriptAnalyzerRule.md) From 97f30d63d8ec2ff4cec492bcb52dae895389c4e1 Mon Sep 17 00:00:00 2001 From: iRon7 Date: Fri, 15 May 2026 20:05:30 +0200 Subject: [PATCH 18/21] Add new MissingTryBlock rule (#2179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #2098 Add new MissingTryBlock rule * Changed severity level of MissingTryBlock rule from Error to Warning in the rule definition and resolved copy-pasta in the rule description. Co-authored-by: Copilot * Updated tests for MissingTryBlock rule to reflect severity change from Error to Warning. * Update Rules/MissingTryBlock.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Grammar: “which is likely a mistake and result in … error” is ungrammatical. * Update Rules/Strings.resx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Rules/MissingTryBlock.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * minor but worthwhile * Update docs/Rules/MissingTryBlock.md Co-authored-by: Sean Wheeler * Applied feedback from @andrewconnell to add a note about the rule not being enabled by default, and to add a note about potential false positives with functions named "catch" or "finally". Also added a test context for when the rule is disabled. Updated the rule implementation to inherit from ConfigurableRule and set Enable to false in the constructor. Updated the AnalyzeScript method to be an override, and added overrides for GetCommonName, GetDescription, GetName, GetSeverity, and GetSourceName. * Update docs/Rules/MissingTryBlock.md Co-authored-by: Liam Peters * Update Tests/Rules/MissingTryBlock.tests.ps1 Co-authored-by: Liam Peters * Update Rules/MissingTryBlock.cs Co-authored-by: Liam Peters * Update Rules/MissingTryBlock.cs Co-authored-by: Liam Peters --------- Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sean Wheeler Co-authored-by: Liam Peters --- Rules/MissingTryBlock.cs | 128 +++++++++++++++++++++ Rules/Strings.resx | 12 ++ Tests/Rules/MissingTryBlock.tests.ps1 | 158 ++++++++++++++++++++++++++ docs/Rules/MissingTryBlock.md | 65 +++++++++++ docs/Rules/README.md | 1 + 5 files changed, 364 insertions(+) create mode 100644 Rules/MissingTryBlock.cs create mode 100644 Tests/Rules/MissingTryBlock.tests.ps1 create mode 100644 docs/Rules/MissingTryBlock.md diff --git a/Rules/MissingTryBlock.cs b/Rules/MissingTryBlock.cs new file mode 100644 index 000000000..3a415f647 --- /dev/null +++ b/Rules/MissingTryBlock.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +#if !CORECLR +using System.ComponentModel.Composition; +#endif +using System.Globalization; +using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + + /// + /// Rule that warns when catch or finally blocks are used without a corresponding try block + /// + + public class MissingTryBlock : ConfigurableRule + { + + /// + /// Construct an object of MissingTryBlock type. + /// + public MissingTryBlock() { + Enable = false; + } + + /// + /// Find bare word "catch" or "finally" tokens that are not part of a TryStatementAst + /// + /// AST to be analyzed. This should be non-null + /// Name of file that corresponds to the input AST. + /// A an enumerable type containing the violations + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage); + + // Find the bare word 'catch' or 'finally' StringConstantExpressionAst nodes used as commands + var missingTryAsts = ast.FindAll(testAst => + // Normally should be part of a TryStatementAst + testAst is StringConstantExpressionAst stringAst && + // Check whether "catch" or "finally" are bare words + stringAst.StringConstantType == StringConstantType.BareWord && + ( + String.Equals(stringAst.Value, "catch", StringComparison.OrdinalIgnoreCase) || + String.Equals(stringAst.Value, "finally", StringComparison.OrdinalIgnoreCase) + ) && + stringAst.Parent is CommandAst commandAst && + // Only violate if the catch or finally is the first command element + commandAst.CommandElements[0] == stringAst, + true + ); + + foreach (StringConstantExpressionAst missingTryAst in missingTryAsts) + { + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.MissingTryBlockError, + CultureInfo.CurrentCulture.TextInfo.ToTitleCase(missingTryAst.Value)), + missingTryAst.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + missingTryAst.Value + ); + } + } + + /// + /// Retrieves the common name of this rule. + /// + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.MissingTryBlockCommonName); + } + + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.MissingTryBlockDescription); + } + + /// + /// Retrieves the name of this rule. + /// + public override string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.MissingTryBlockName); + } + + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + } +} + diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 2a04fd759..a296b04f3 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -276,6 +276,18 @@ Module Manifest Fields + + MissingTryBlock + + + Missing Try Block + + + The catch and finally blocks should be preceded by a try block. + + + {0} is missing a try block + If a script file is in a PowerShell module folder, then that folder must be loadable. diff --git a/Tests/Rules/MissingTryBlock.tests.ps1 b/Tests/Rules/MissingTryBlock.tests.ps1 new file mode 100644 index 000000000..20b25afb8 --- /dev/null +++ b/Tests/Rules/MissingTryBlock.tests.ps1 @@ -0,0 +1,158 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] +param() + +BeforeAll { + $ruleName = "PSMissingTryBlock" +} + +Describe "MissingTryBlock" { + + BeforeAll { + $Settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $true } } + } + } + + Context "Violates" { + It "Catch is missing a try block" { + $scriptDefinition = { catch { "An error occurred." } }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be catch + $violations.Message | Should -Be 'Catch is missing a try block' + $violations.RuleSuppressionID | Should -Be catch + } + + It "Finally is missing a try block" { + $scriptDefinition = { finally { "Finalizing..." } }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be finally + $violations.Message | Should -Be 'Finally is missing a try block' + $violations.RuleSuppressionID | Should -Be finally + } + + It "Single line catch and finally is missing a try block" { + $scriptDefinition = { + catch { "An error occurred." } finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be catch + $violations.Message | Should -Be 'Catch is missing a try block' + $violations.RuleSuppressionID | Should -Be catch + } + + It "Multi line catch and finally is missing a try block" { + $scriptDefinition = { + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 2 + $violations[0].Severity | Should -Be Warning + $violations[0].Extent.Text | Should -Be catch + $violations[0].Message | Should -Be 'Catch is missing a try block' + $violations[0].RuleSuppressionID | Should -Be catch + $violations[1].Severity | Should -Be Warning + $violations[1].Extent.Text | Should -Be finally + $violations[1].Message | Should -Be 'Finally is missing a try block' + $violations[1].RuleSuppressionID | Should -Be finally + } + } + + Context "Compliant" { + It "try-catch block" { + $scriptDefinition = { + try { NonsenseString } + catch { "An error occurred." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "try-catch-final statement" { + $scriptDefinition = { + try { NonsenseString } + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Single line try statement" { + $scriptDefinition = { + try { NonsenseString } catch { "An error occurred." } finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Catch as parameter" { + $scriptDefinition = { Write-Host Catch }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Catch as double quoted string" { + $scriptDefinition = { "Catch" }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Catch as single quoted string" { + $scriptDefinition = { 'Catch' }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Suppressed" { + It "Multi line catch and finally is missing a try block" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSMissingTryBlock', '', Justification = 'Test')] + param() + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Multi line catch and finally is missing a try block for catch only" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSMissingTryBlock', 'finally', Justification = 'Test')] + param() + catch { "An error occurred." } + finally { "Finalizing..." } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + } + } + + Context "Disabled" { + + BeforeAll { + $Settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $false } } + } + } + + It "Doesn't emit a violation" { + $scriptDefinition = { catch { "An error occurred." } }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + +} \ No newline at end of file diff --git a/docs/Rules/MissingTryBlock.md b/docs/Rules/MissingTryBlock.md new file mode 100644 index 000000000..5265259d1 --- /dev/null +++ b/docs/Rules/MissingTryBlock.md @@ -0,0 +1,65 @@ +--- +description: Missing Try Block +ms.date: 04/22/2026 +ms.topic: reference +title: MissingTryBlock +--- +# MissingTryBlock + +**Severity Level: Warning** + +## Description + +The `catch` and `finally` blocks must be preceded by a `try` block. Without a `try` block, the +`catch` and `finally` are interpreted as commands and result in a runtime error, such as: + +> "The term 'catch' is not recognized as a name of a cmdlet" + +This rule identifies instances where `catch` or `finally` blocks are present with out an associated +`try` block. + +> [!NOTE] +> This rule is not enabled by default. The user needs to enable it through settings. + +## How + +Add a `try` block before the `catch` and `finally` blocks. + +> [!NOTE] +> This rule could result in a false positive as it will fire on user code that violates the rule +> [AvoidReservedWordsAsFunctionNames][1] for functions named `catch` or `finally`: +> If you have functions named `catch` or `finally`, you can either rename the function or disable +> this rule. + +## Example + +### Wrong + +```powershell +catch { "An error occurred." } +``` + +### Correct + +```powershell +try { $a = 1 / $b } +catch { "Attempted to divide by zero." } +``` + +## Configuration + +```powershell +Rules = @{ + PSMissingTryBlock = @{ + Enable = $true + } +} +``` + +### Parameters + +- `Enable`: **bool** (Default value is `$false`) + + Enable or disable the rule during ScriptAnalyzer invocation. + +[1]: AvoidReservedWordsAsFunctionNames.md "Avoid using reserved words as function names." \ No newline at end of file diff --git a/docs/Rules/README.md b/docs/Rules/README.md index fca031e33..73e09a4da 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -50,6 +50,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [DSCUseVerboseMessageInDSCResource](./DSCUseVerboseMessageInDSCResource.md) | Error | Yes | | | [MisleadingBacktick](./MisleadingBacktick.md) | Warning | Yes | | | [MissingModuleManifestField](./MissingModuleManifestField.md) | Warning | Yes | | +| [MissingTryBlock](./MissingTryBlock.md) | Warning | No | Yes | | [PlaceCloseBrace](./PlaceCloseBrace.md) | Warning | No | Yes | | [PlaceOpenBrace](./PlaceOpenBrace.md) | Warning | No | Yes | | [PossibleIncorrectComparisonWithNull](./PossibleIncorrectComparisonWithNull.md) | Warning | Yes | | From 6dadae3b7cf6a5913d8d4470012f2e4e10832b01 Mon Sep 17 00:00:00 2001 From: iRon7 Date: Mon, 18 May 2026 19:19:03 +0200 Subject: [PATCH 19/21] Add new AvoidUsingArrayList rule (#2174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implemented the AvoidUsingArrayList rule to warn when the ArrayList class is used in PowerShell scripts. Added tests for both violations and non-violations of this rule. Updated documentation to include the new rule and its guidelines. * Testing-Commit-CSpell-issue * Apply suggestion from @liamjpeters I clearly used several other rules as a kind of template...🤪 Co-authored-by: Liam Peters * Update docs/Rules/AvoidUsingArrayList.md Co-authored-by: Liam Peters * Updated rule help * Changed "unintentionally" * Update Rules/AvoidUsingArrayList.cs Co-authored-by: Liam Peters * Update Tests/Rules/AvoidUsingArrayList.tests.ps1 Co-authored-by: Liam Peters * ArrayListName could be null * Resolved camelCase * Remove ComponentModel namespace * Fixed tests * Updated Tests * fixed and tested empty (dynamic) BoundParameter * Robuster Pester tests * Configurable (enable by default) * Fixed ConstantValue null check test and rule * Better UsingStatements handling and disable AvoidUsingArrayList by default * `[ArrayList]::new()` without a `using namespace System.Collections` * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Resolve Copilot suggestions and resolved `Method not found: ScriptBlockAst.get_UsingStatements()` error --------- Co-authored-by: Liam Peters Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Rules/AvoidUsingArrayList.cs | 195 +++++++++++++++ Rules/Strings.resx | 12 + Tests/Rules/AvoidUsingArrayList.tests.ps1 | 281 ++++++++++++++++++++++ docs/Rules/AvoidUsingArrayList.md | 61 +++++ docs/Rules/README.md | 1 + 5 files changed, 550 insertions(+) create mode 100644 Rules/AvoidUsingArrayList.cs create mode 100644 Tests/Rules/AvoidUsingArrayList.tests.ps1 create mode 100644 docs/Rules/AvoidUsingArrayList.md diff --git a/Rules/AvoidUsingArrayList.cs b/Rules/AvoidUsingArrayList.cs new file mode 100644 index 000000000..14808d2b3 --- /dev/null +++ b/Rules/AvoidUsingArrayList.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +#if !CORECLR +using System.ComponentModel.Composition; +#endif +using System.Globalization; +using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System.Text.RegularExpressions; +using System.Linq; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// AvoidUsingArrayList: Checks for use of the ArrayList class + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + public class AvoidUsingArrayList : ConfigurableRule + { + + /// + /// Construct an object of AvoidUsingArrayList type. + /// + public AvoidUsingArrayList() { + Enable = false; + } + + /// + /// Analyzes the given ast to find the [violation] + /// + /// AST to be analyzed. This should be non-null + /// Name of file that corresponds to the input AST. + /// A an enumerable type containing the violations + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) { throw new ArgumentNullException(nameof(ast), Strings.NullAstErrorMessage); } + + // If there is an using statement for the Collections namespace, check for the full typename. + // Otherwise also check for the bare ArrayList name. + Regex arrayListName = null; + if (ast is ScriptBlockAst sbAst) { + // sbAst.UsingStatements causes an error: Method not found: ScriptBlockAst.get_UsingStatements() + IEnumerable usingStatements = usingStatements = sbAst.FindAll(testAst => testAst is UsingStatementAst, false); + foreach (UsingStatementAst usingAst in usingStatements.Cast()) + { + if ( + usingAst.UsingStatementKind == UsingStatementKind.Namespace && + ( + usingAst.Name.Value.Equals("Collections", StringComparison.OrdinalIgnoreCase) || + usingAst.Name.Value.Equals("System.Collections", StringComparison.OrdinalIgnoreCase) + ) + ) + { + arrayListName = new Regex(@"^((System\.)?Collections\.)?ArrayList$", RegexOptions.IgnoreCase); + break; + } + } + } + if (arrayListName == null) { arrayListName = new Regex(@"^(System\.)?Collections\.ArrayList$", RegexOptions.IgnoreCase); } + + // Find all type initializers that create a new instance of the ArrayList class. + IEnumerable typeAsts = ast.FindAll(testAst => + ( + testAst is ConvertExpressionAst convertAst && + convertAst.StaticType != null && + convertAst.StaticType.FullName == "System.Collections.ArrayList" + ) || + ( + testAst is TypeExpressionAst typeAst && + typeAst.TypeName != null && + arrayListName.IsMatch(typeAst.TypeName.Name) && + typeAst.Parent is InvokeMemberExpressionAst parentAst && + parentAst.Member != null && + parentAst.Member is StringConstantExpressionAst memberAst && + memberAst.Value.Equals("new", StringComparison.OrdinalIgnoreCase) + ), + true + ); + + foreach (Ast typeAst in typeAsts) + { + IScriptExtent Extent = typeAst is ConvertExpressionAst? typeAst.Extent : typeAst.Parent.Extent; + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.AvoidUsingArrayListError, + Extent.Text), + Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName + ); + } + + // Find all New-Object cmdlets that create a new instance of the ArrayList class. + var newObjectCommands = ast.FindAll(testAst => + testAst is CommandAst cmdAst && + cmdAst.GetCommandName() != null && + cmdAst.GetCommandName().Equals("New-Object", StringComparison.OrdinalIgnoreCase), + true); + + foreach (CommandAst cmd in newObjectCommands) + { + // Use StaticParameterBinder to reliably get parameter values + var bindingResult = StaticParameterBinder.BindCommand(cmd, true); + + // Check for -TypeName parameter + if ( + bindingResult.BoundParameters.TryGetValue("TypeName", out ParameterBindingResult typeNameBinding) && + typeNameBinding.ConstantValue is string typeName && + arrayListName.IsMatch(typeName) + ) + { + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.AvoidUsingArrayListError, + cmd.Extent.Text), + cmd.Extent, + GetName(), + DiagnosticSeverity.Warning, + fileName + ); + } + } + } + + /// + /// Retrieves the common name of this rule. + /// + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.AvoidUsingArrayListCommonName); + } + + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.AvoidUsingArrayListDescription); + } + + /// + /// Retrieves the name of this rule. + /// + public override string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.AvoidUsingArrayListName); + } + + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// Gets the severity of the returned diagnostic record: error, warning, or information. + /// + /// + public DiagnosticSeverity GetDiagnosticSeverity() + { + return DiagnosticSeverity.Warning; + } + + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + } +} + diff --git a/Rules/Strings.resx b/Rules/Strings.resx index a296b04f3..4e79088d3 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -945,6 +945,18 @@ Line ends with a semicolon + + Avoid using the ArrayList class + + + Avoid using the ArrayList class in PowerShell scripts. Consider using generic collections or fixed arrays instead. + + + AvoidUsingArrayList + + + The ArrayList class is used in '{0}'. Consider using a generic collection or a fixed array instead. + PlaceOpenBrace diff --git a/Tests/Rules/AvoidUsingArrayList.tests.ps1 b/Tests/Rules/AvoidUsingArrayList.tests.ps1 new file mode 100644 index 000000000..dc32a7a8b --- /dev/null +++ b/Tests/Rules/AvoidUsingArrayList.tests.ps1 @@ -0,0 +1,281 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +using namespace System.Management.Automation.Language + +[Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] +param() + +BeforeAll { + $ruleName = "PSAvoidUsingArrayList" + $ruleMessage = "The ArrayList class is used in '{0}'. Consider using a generic collection or a fixed array instead." + $usingCollections = 'using namespace system.collections' + [Environment]::NewLine + $usingGeneric = 'using namespace System.Collections.Generic' + [Environment]::NewLine +} + +Describe "AvoidArrayList" { + + BeforeAll { + $settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $true } } + } + } + + Context "When there are violations" { + + It "Unquoted New-Object type" { + $scriptDefinition = $usingCollections + { + $List = New-Object ArrayList + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {New-Object ArrayList}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {New-Object ArrayList}) + } + + It "Single quoted New-Object type" { + $scriptDefinition = $usingCollections + { + $List = New-Object 'ArrayList' + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {New-Object 'ArrayList'}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {New-Object 'ArrayList'}) + } + + It "Double quoted New-Object type" { + $scriptDefinition = $usingCollections + { + $List = New-Object "ArrayList" + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {New-Object "ArrayList"}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {New-Object "ArrayList"}) + } + + It "New-Object with full parameter name" { + $scriptDefinition = $usingCollections + { + $List = New-Object -TypeName ArrayList + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {New-Object -TypeName ArrayList}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {New-Object -TypeName ArrayList}) + } + + It "New-Object with abbreviated parameter name and odd casing" { + $scriptDefinition = $usingCollections + { + $List = New-Object -Type ArrayLIST + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {New-Object -Type ArrayLIST}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {New-Object -Type ArrayLIST}) + } + + It "New-Object with full type name" { + $scriptDefinition = $usingCollections + { + $List = New-Object -TypeName System.Collections.ArrayList + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {New-Object -TypeName System.Collections.ArrayList}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {New-Object -TypeName System.Collections.ArrayList}) + } + + It "New-Object with semi full type name and odd casing" { + $scriptDefinition = $usingCollections + { + $List = New-Object COLLECTIONS.ArrayList + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {New-Object COLLECTIONS.ArrayList}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {New-Object COLLECTIONS.ArrayList}) + } + + It "Type initializer with 3 parameters" { + $scriptDefinition = $usingCollections + { + $List = [ArrayList](1,2,3) + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {[ArrayList](1,2,3)}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {[ArrayList](1,2,3)}) + } + + It "Type initializer with array parameters" { + $scriptDefinition = $usingCollections + { + $List = [ArrayList]@(1,2,3) + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {[ArrayList]@(1,2,3)}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {[ArrayList]@(1,2,3)}) + } + + It "Type initializer with new constructor" { + $scriptDefinition = $usingCollections + { + $List = [ArrayList]::new() + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {[ArrayList]::new()}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {[ArrayList]::new()}) + } + + It "Full type name initializer with new constructor" { + $scriptDefinition = $usingCollections + { + $List = [System.Collections.ArrayList]::new() + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {[System.Collections.ArrayList]::new()}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {[System.Collections.ArrayList]::new()}) + } + + It "Semi full type name initializer with new constructor and odd casing" { + $scriptDefinition = $usingCollections + { + $List = [COLLECTIONS.ArrayList]::new() + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Warning + $violations.Extent.Text | Should -Be {[COLLECTIONS.ArrayList]::new()}.ToString() + $violations.Message | Should -Be ($ruleMessage -f {[COLLECTIONS.ArrayList]::new()}) + } + } + + Context "When there are no violations" { + + It "New-Object List[Object]" { + $scriptDefinition = { + $List = New-Object List[Object] + 1..3 | ForEach-Object { $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "[List[Object]]::new()" { + $scriptDefinition = { + $List = [List[Object]]::new() + 1..3 | ForEach-Object { $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Using the pipeline" { + $scriptDefinition = { + $List = 1..3 | ForEach-Object { $_ } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "new()" { + $scriptDefinition = { + $List = [ArrayList]::new() + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Out of the namespace scope" { + $scriptDefinition = $usingGeneric + { + $List = New-Object ArrayList + $List = [ArrayList](1,2,3) + $List = [ArrayList]@(1,2,3) + $List = [ArrayList]::new() + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Disabled" { + + It "New-Object type" { + $scriptDefinition = $usingCollections + { + $List = New-Object ArrayList + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "Type initializer" { + $scriptDefinition = $usingCollections + { + $List = [ArrayList](1,2,3) + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + + It "New constructor" { + $scriptDefinition = $usingCollections + { + $List = [ArrayList]::new() + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName) + $violations | Should -BeNullOrEmpty + } + } + + Context "Explicitly disabled" { + + BeforeAll { + $settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $false } } + } + } + + It "New-Object type" { + $scriptDefinition = $usingCollections + { + $List = New-Object ArrayList + 1..3 | ForEach-Object { $null = $List.Add($_) } + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Test for potential errors" { + + It "Dynamic types shouldn't error" { + $scriptDefinition = { + $type = "System.Collections.ArrayList" + New-Object -TypeName $type + }.ToString() + + $analyzer = { Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings } + $analyzer | Should -Not -Throw # but won't violate either (too complex to cover) + } + } +} diff --git a/docs/Rules/AvoidUsingArrayList.md b/docs/Rules/AvoidUsingArrayList.md new file mode 100644 index 000000000..35c776f10 --- /dev/null +++ b/docs/Rules/AvoidUsingArrayList.md @@ -0,0 +1,61 @@ +--- +description: Avoid using ArrayList +ms.date: 04/16/2025 +ms.topic: reference +title: AvoidUsingArrayList +--- +# AvoidUsingArrayList + +**Severity Level: Warning** + +## Description + +Per .NET best practices, the [`ArrayList` class][1] is not recommended for new development, +the same recommendation applies to PowerShell: + +Avoid the ArrayList class for new development. +The `ArrayList` class is a non-generic collection that can hold objects of any type. +This is in line with the fact that PowerShell is a weakly typed language. However, the +`ArrayList` class does not provide any explicit type safety and performance benefits +of generic collections. Instead of using an `ArrayList`, consider using either a +[`System.Collections.Generic.List[Object]`][2] class or a fixed PowerShell array. +Besides, the `ArrayList.Add` method returns the index of the added element which often +unintentionally pollutes the PowerShell pipeline and therefore might cause unexpected issues. + +## How to Fix + +In cases where only the `Add` method is used, you might just replace the `ArrayList` class +with a generic `List[Object]` class but you could also consider using the idiomatic PowerShell +pipeline syntax instead. + +## Example + +### Wrong + +```powershell +# Using an ArrayList +$List = [System.Collections.ArrayList]::new() +1..3 | ForEach-Object { $List.Add($_) } # Note that this will return the index of the added element +``` + +### Correct + +```powershell +# Using a generic List +$List = [System.Collections.Generic.List[Object]]::new() +1..3 | ForEach-Object { $List.Add($_) } # This will not return anything +``` + +```powershell +# Creating a fixed array by using the PowerShell pipeline +$List = 1..3 | ForEach-Object { $_ } +``` + +### Parameters + +- `Enable`: **bool** (Default value is `$false`) + + Enable or disable the rule during ScriptAnalyzer invocation. + +[1]: https://learn.microsoft.com/dotnet/api/system.collections.arraylist "ArrayList Class" +[2]: https://learn.microsoft.com/dotnet/api/system.collections.generic.list-1 "List Class" \ No newline at end of file diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 73e09a4da..19bd798ce 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -28,6 +28,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [AvoidShouldContinueWithoutForce](./AvoidShouldContinueWithoutForce.md) | Warning | Yes | | | [AvoidTrailingWhitespace](./AvoidTrailingWhitespace.md) | Warning | Yes | | | [AvoidUsingAllowUnencryptedAuthentication](./AvoidUsingAllowUnencryptedAuthentication.md) | Warning | Yes | | +| [AvoidUsingArrayList](./AvoidUsingArrayList.md) | Warning | No | Yes | | [AvoidUsingBrokenHashAlgorithms](./AvoidUsingBrokenHashAlgorithms.md) | Warning | Yes | | | [AvoidUsingCmdletAliases](./AvoidUsingCmdletAliases.md) | Warning | Yes | Yes2 | | [AvoidUsingComputerNameHardcoded](./AvoidUsingComputerNameHardcoded.md) | Error | Yes | | From eb6cebea76c8238a486ff3527d9b30c8ed704967 Mon Sep 17 00:00:00 2001 From: iRon7 Date: Mon, 18 May 2026 19:20:05 +0200 Subject: [PATCH 20/21] Add new InvalidMultiDotValue rule (#2180) * Add new InvalidMultiDotValue rule * Fixed several issues as commented by Liam. Co-authored-by: Copilot * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Improved wording based on Copilot suggestions. * Update docs/Rules/InvalidMultiDotValue.md Co-authored-by: Sean Wheeler * Update docs/Rules/InvalidMultiDotValue.md Co-authored-by: Sean Wheeler * **opt-in by default.** * Remove dead code and change `PSAvoidExclaimOperator` from documentation --------- Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sean Wheeler --- Rules/InvalidMultiDotValue.cs | 155 +++++++++++++++++ Rules/Strings.resx | 15 ++ Tests/Rules/InvalidMultiDotValue.tests.ps1 | 191 +++++++++++++++++++++ docs/Rules/InvalidMultiDotValue.md | 62 +++++++ docs/Rules/README.md | 1 + 5 files changed, 424 insertions(+) create mode 100644 Rules/InvalidMultiDotValue.cs create mode 100644 Tests/Rules/InvalidMultiDotValue.tests.ps1 create mode 100644 docs/Rules/InvalidMultiDotValue.md diff --git a/Rules/InvalidMultiDotValue.cs b/Rules/InvalidMultiDotValue.cs new file mode 100644 index 000000000..008d2cc79 --- /dev/null +++ b/Rules/InvalidMultiDotValue.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Management.Automation.Language; +using System.Linq; + + + +#if !CORECLR +using System.ComponentModel.Composition; +#endif + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + + /// + /// Rule that reports an error when an unquoted value contains multiple dots, + /// which is likely an attempt to construct a version number (e.g., 1.2.3) + /// that is not properly quoted and thus misinterpreted as a double with member access. + /// + public class InvalidMultiDotValue : ConfigurableRule + { + + /// + /// Construct an object of InvalidMultiDotValue type. + /// + public InvalidMultiDotValue() { + Enable = false; + } + + /// + /// Analyzes the given ast to find the [violation] + /// + /// AST to be analyzed. This should be non-null + /// Name of file that corresponds to the input AST. + /// A an enumerable type containing the violations + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage); + + // Find all MemberExpressionAst nodes representing invalid unquoted multi-dot values + IEnumerable invalidAsts = ast.FindAll(testAst => + // An expression with 3 or more dots is seen as a double with an additional property + testAst is MemberExpressionAst memberAst && + // The first two values are seen as a double + memberAst.Expression.StaticType == typeof(double) && + // the rest is seen as a member of type int or double + memberAst.Member is ConstantExpressionAst constantAst && + ( + constantAst.StaticType == typeof(int) || // e.g.: [Version]1.2.3 + constantAst.StaticType == typeof(double) // e.g.: [Version]1.2.3.4 + ), + true + ).Cast(); + + var correctionDescription = Strings.InvalidMultiDotValueCorrectionDescription; + foreach (MemberExpressionAst invalidAst in invalidAsts) + { + var corrections = new List { + new CorrectionExtent( + invalidAst.Extent.StartLineNumber, + invalidAst.Extent.EndLineNumber, + invalidAst.Extent.StartColumnNumber, + invalidAst.Extent.EndColumnNumber, + "'" + invalidAst.Extent.Text + "'", + fileName, + correctionDescription + ) + }; + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.InvalidMultiDotValueError, + invalidAst.Extent.Text + ), + invalidAst.Extent, + GetName(), + DiagnosticSeverity.Error, + fileName, + invalidAst.Extent.Text, + suggestedCorrections: corrections + ); + } + } + + /// + /// Retrieves the common name of this rule. + /// + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.InvalidMultiDotValueCommonName); + } + + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.InvalidMultiDotValueDescription); + } + + /// + /// Retrieves the name of this rule. + /// + public override string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.InvalidMultiDotValueName); + } + + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + + /// + /// Gets the severity of the returned diagnostic record: error, warning, or information. + /// + /// + public DiagnosticSeverity GetDiagnosticSeverity() + { + return DiagnosticSeverity.Warning; + } + + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + } +} + diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 4e79088d3..cf399d783 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1245,6 +1245,21 @@ The insecure AllowUnencryptedAuthentication switch was used. This should be avoided except for compatibility with legacy systems. + + Invalid Multi-Dot Value + + + PowerShell does not support an implicit value with multiple dots. Any unquoted value with 2 or more dots will result in `$null`. + + + InvalidMultiDotValue + + + The unquoted '{0}' expression is not a valid syntax. Types with multiple dots need to be constructed from either a quoted string or individual components. + + + Quote the value that contains multiple dots + AvoidUsingAllowUnencryptedAuthentication diff --git a/Tests/Rules/InvalidMultiDotValue.tests.ps1 b/Tests/Rules/InvalidMultiDotValue.tests.ps1 new file mode 100644 index 000000000..7d4bc9e68 --- /dev/null +++ b/Tests/Rules/InvalidMultiDotValue.tests.ps1 @@ -0,0 +1,191 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] +param() + +BeforeAll { + $ruleName = "PSInvalidMultiDotValue" + $ruleMessage = "The unquoted '{0}' expression is not a valid syntax. Types with multiple dots need to be constructed from either a quoted string or individual components." + $correctionDescription = 'Quote the value that contains multiple dots' +} + +Describe "InvalidMultiDotValue" { + + BeforeAll { + $Settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $true } } + } + } + + Context "Violates" { + It "3 version components" { + $scriptDefinition = { $version = 1.2.3 }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be '1.2.3' + $violations.Message | Should -Be ($ruleMessage -f '1.2.3') + $violations.RuleSuppressionID | Should -Be '1.2.3' + $violations.SuggestedCorrections.Text | Should -Be "'1.2.3'" + $violations.SuggestedCorrections.Description | Should -Be $correctionDescription + } + + It "4 version components" { + $scriptDefinition = { $version = 1.2.3.4 }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be '1.2.3.4' + $violations.Message | Should -Be ($ruleMessage -f '1.2.3.4') + $violations.RuleSuppressionID | Should -Be '1.2.3.4' + $violations.SuggestedCorrections.Text | Should -Be "'1.2.3.4'" + $violations.SuggestedCorrections.Description | Should -Be $correctionDescription + } + + + It "With class initializer" { + $scriptDefinition = { $version = [Version]1.2.3 }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be '1.2.3' + $violations.Message | Should -Be ($ruleMessage -f '1.2.3') + $violations.RuleSuppressionID | Should -Be '1.2.3' + $violations.SuggestedCorrections.Text | Should -Be "'1.2.3'" + $violations.SuggestedCorrections.Description | Should -Be $correctionDescription + } + + It "As parameter" { + $scriptDefinition = { + param( + [Version]$version = 1.2.3 + ) + Write-Verbose $version + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be '1.2.3' + $violations.Message | Should -Be ($ruleMessage -f '1.2.3') + $violations.RuleSuppressionID | Should -Be '1.2.3' + $violations.SuggestedCorrections.Text | Should -Be "'1.2.3'" + $violations.SuggestedCorrections.Description | Should -Be $correctionDescription + } + + # Even an IP address is apparently expect below. + # The violation message and description presume a version + # is expected because this is the more commonly used type. + It "IP Address" { + $scriptDefinition = { $IP = [System.Net.IPAddress]127.0.0.1 }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Error + $violations.Extent.Text | Should -Be '127.0.0.1' + $violations.Message | Should -Be ($ruleMessage -f '127.0.0.1') + $violations.RuleSuppressionID | Should -Be '127.0.0.1' + $violations.SuggestedCorrections.Text | Should -Be "'127.0.0.1'" + $violations.SuggestedCorrections.Description | Should -Be $correctionDescription + } + } + + Context "Compliant" { + It "From string" { + $scriptDefinition = { $Version = [Version]'1.2.3' }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "From version components" { + $scriptDefinition = { $Version = [Version]::new(1, 2, 3, 4) }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "From (bare) double" { + $scriptDefinition = { $Version = [Version]1.2 }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + + It "Dot notation" { #PowerShell:27356 + $scriptDefinition = { + $1.2.3.4 + $intKeys = @{ 1 = @{ 2 = @{ 3 = @{ 4 = 'test' } } } } + $intKeys.1.2.3.4 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Disabled" { + + BeforeAll { + $Settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $false } } + } + } + + It "ConvertFrom-SecureString -AsPlainText" { + $scriptDefinition = { $version = 1.2.3 }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Suppressed" { + It "All" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSInvalidMultiDotValue', '', Justification = 'Test')] + param() + $version = 1.2.3 + $IP = [System.Net.IPAddress]127.0.0.1 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "1.2.3" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSInvalidMultiDotValue', '1.2.3', Justification = 'Test')] + param() + $version = 1.2.3 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "127.0.0.1" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSInvalidMultiDotValue', '127.0.0.1', Justification = 'Test')] + param() + $IP = [System.Net.IPAddress]127.0.0.1 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Fixing" { + + BeforeAll { # See request: #1938 + $tempFile = Join-Path $TestDrive 'TestScript.ps1' + } + + It "Version" { + Set-Content -LiteralPath $tempFile -Value {$version = 1.2.3}.ToString() -NoNewLine + $violations = Invoke-ScriptAnalyzer -Path $tempFile -Settings $Settings -fix + Get-Content -LiteralPath $tempFile -Raw | Should -Be {$version = '1.2.3'}.ToString() + } + + It "IP Address" { + Set-Content -LiteralPath $tempFile -Value {$IP = [System.Net.IPAddress]127.0.0.1}.ToString() -NoNewLine + $violations = Invoke-ScriptAnalyzer -Path $tempFile -Settings $Settings -fix + Get-Content -LiteralPath $tempFile -Raw | Should -Be {$IP = [System.Net.IPAddress]'127.0.0.1'}.ToString() + } + } +} \ No newline at end of file diff --git a/docs/Rules/InvalidMultiDotValue.md b/docs/Rules/InvalidMultiDotValue.md new file mode 100644 index 000000000..90634693e --- /dev/null +++ b/docs/Rules/InvalidMultiDotValue.md @@ -0,0 +1,62 @@ +--- +description: Invalid unquoted multi-dot value construction +ms.date: 04/24/2024 +ms.topic: reference +title: InvalidMultiDotValue +--- +# InvalidMultiDotValue + +**Severity Level: Error** + +## Description + +PowerShell doesn't support unquoted literal values with multiple dots (`.`). Any value with two or +more dots results in `$null`. This rule identifies instances where such values are used, which can +lead to unexpected behavior or errors in the code. + +To create values of the intended type, enclose the value in quotes and use type-casting or use type +constructor methods to create the appropriate object. + + +## Example + +### Wrong + +```powershell +$version = 1.2.3 +``` + +or even: + +```powershell +$IP = [System.Net.IPAddress]127.0.0.1 +``` + +Where both examples will result in `$null` instead of any specific object. + +### Correct + +```powershell +# Use type-casting with quoted value +$IP = [System.Net.IPAddress]'127.0.0.1' +$version = [Version]'1.2.3' + +# Use type constructor method +$version = [Version]::new(1, 2, 3) +``` + +## Configuration + +```powershell +Rules = @{ + PSInvalidMultiDotValue = @{ + Enable = $true + } +} +``` + +### Parameters + +- `Enable`: **bool** (Default value is `$false`) + + Enable or disable the rule during ScriptAnalyzer invocation. diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 19bd798ce..06d9204b0 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -49,6 +49,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [DSCUseIdenticalMandatoryParametersForDSC](./DSCUseIdenticalMandatoryParametersForDSC.md) | Error | Yes | | | [DSCUseIdenticalParametersForDSC](./DSCUseIdenticalParametersForDSC.md) | Error | Yes | | | [DSCUseVerboseMessageInDSCResource](./DSCUseVerboseMessageInDSCResource.md) | Error | Yes | | +| [InvalidMultiDotValue](./InvalidMultiDotValue.md) | Error | No | Yes | | [MisleadingBacktick](./MisleadingBacktick.md) | Warning | Yes | | | [MissingModuleManifestField](./MissingModuleManifestField.md) | Warning | Yes | | | [MissingTryBlock](./MissingTryBlock.md) | Warning | No | Yes | From 4b0117ca7d2887711c9699f467ba7171f8859156 Mon Sep 17 00:00:00 2001 From: iRon7 Date: Mon, 18 May 2026 20:34:47 +0200 Subject: [PATCH 21/21] Add new AvoidDynamicallyCreatingVariableNames rule (#2178) * 1st commit * Avoid dynamic variable names rule implementation and tests * Removed `using System.Linq;` and added some tests * Covering Liam's feedback Co-authored-by: Copilot * Update docs/Rules/AvoidDynamicallyCreatingVariableNames.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/Rules/AvoidDynamicallyCreatingVariableNames.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Rules/Strings.resx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Corrected alphabetical order of rules in README.md * Corrected $ruleMessage in Test Co-authored-by: Copilot * Changed newVariableAst.Parent.Extent to newVariableAst.Extent * Made rule configurable (disabled) and updated documentation and tests accordingly. Increased number of information tests to 20 in Get-ScriptAnalyzerRule tests. * Change rule name from PSAvoidExclaimOperator to PSAvoidDynamicallyCreatingVariableNames in documentation. --------- Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AvoidDynamicallyCreatingVariableNames.cs | 141 +++++++++++++++ Rules/Strings.resx | 12 ++ Tests/Engine/GetScriptAnalyzerRule.tests.ps1 | 2 +- ...DynamicallyCreatingVariableNames.tests.ps1 | 164 ++++++++++++++++++ .../AvoidDynamicallyCreatingVariableNames.md | 77 ++++++++ docs/Rules/README.md | 1 + 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 Rules/AvoidDynamicallyCreatingVariableNames.cs create mode 100644 Tests/Rules/AvoidDynamicallyCreatingVariableNames.tests.ps1 create mode 100644 docs/Rules/AvoidDynamicallyCreatingVariableNames.md diff --git a/Rules/AvoidDynamicallyCreatingVariableNames.cs b/Rules/AvoidDynamicallyCreatingVariableNames.cs new file mode 100644 index 000000000..5e5df7566 --- /dev/null +++ b/Rules/AvoidDynamicallyCreatingVariableNames.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Management.Automation.Language; + +#if !CORECLR +using System.ComponentModel.Composition; +#endif + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// Rule that informs the user when they create variables with dynamic names in the general variable scope. + /// This might lead to conflicts with other variables. + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + public class AvoidDynamicallyCreatingVariableNames : ConfigurableRule + { + + /// + /// Construct an object of AvoidDynamicallyCreatingVariableNames type. + /// + public AvoidDynamicallyCreatingVariableNames() { + Enable = false; + } + + readonly HashSet cmdList = new HashSet(Helper.Instance.CmdletNameAndAliases("New-Variable"), StringComparer.OrdinalIgnoreCase); + + /// + /// Analyzes the given ast to find the [violation] + /// + /// AST to be analyzed. This should be non-null + /// Name of file that corresponds to the input AST. + /// A an enumerable type containing the violations + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage); + + // Find all "New-Variable" commands in the Ast + IEnumerable newVariableAsts = ast.FindAll(testAst => + testAst is CommandAst cmdAst && + cmdList.Contains(cmdAst.GetCommandName()), + true + ).Cast(); + + foreach (CommandAst newVariableAst in newVariableAsts) + { + // Use StaticParameterBinder to reliably get parameter values + var bindingResult = StaticParameterBinder.BindCommand(newVariableAst, true); + if (!bindingResult.BoundParameters.ContainsKey("Name")) { continue; } + var nameBindingResult = bindingResult.BoundParameters["Name"]; + // Dynamic parameters return null for the ConstantValue property + if (nameBindingResult.ConstantValue != null) { continue; } + string variableName = nameBindingResult.Value.ToString(); + if (variableName.StartsWith("\"") && variableName.EndsWith("\"")) + { + variableName = variableName.Substring(1, variableName.Length - 2); + } + yield return new DiagnosticRecord( + string.Format( + CultureInfo.CurrentCulture, + Strings.AvoidDynamicallyCreatingVariableNamesError, + variableName), + newVariableAst.Extent, + GetName(), + DiagnosticSeverity.Information, + fileName, + variableName + ); + } + } + + /// + /// Retrieves the common name of this rule. + /// + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.AvoidDynamicallyCreatingVariableNamesCommonName); + } + + /// + /// Retrieves the description of this rule. + /// + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.AvoidDynamicallyCreatingVariableNamesDescription); + } + + /// + /// Retrieves the name of this rule. + /// + public override string GetName() + { + return string.Format( + CultureInfo.CurrentCulture, + Strings.NameSpaceFormat, + GetSourceName(), + Strings.AvoidDynamicallyCreatingVariableNamesName); + } + + /// + /// Retrieves the severity of the rule: error, warning or information. + /// + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Information; + } + + /// + /// Gets the severity of the returned diagnostic record: error, warning, or information. + /// + /// + public DiagnosticSeverity GetDiagnosticSeverity() + { + return DiagnosticSeverity.Information; + } + + /// + /// Retrieves the name of the module/assembly the rule is from. + /// + public override string GetSourceName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.SourceName); + } + + /// + /// Retrieves the type of the rule, Builtin, Managed or Module. + /// + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + } +} diff --git a/Rules/Strings.resx b/Rules/Strings.resx index cf399d783..2f2b1e1a9 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -885,6 +885,18 @@ The type accelerator '{0}' is not available by default in PowerShell version '{1}' on platform '{2}' + + AvoidDynamicallyCreatingVariableNames + + + Avoid dynamically creating variable names + + + Do not create variables with a dynamic name, as this might introduce conflicts with other variables and is difficult to maintain. + + + '{0}' is a dynamic variable name. Please avoid creating variables with a dynamic name + Avoid global functions and aliases diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index d1e2cd98d..2886a0c88 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -152,7 +152,7 @@ Describe "TestSeverity" { It "filters rules based on multiple severity inputs"{ $rules = Get-ScriptAnalyzerRule -Severity Error,Information - $rules.Count | Should -Be 19 + $rules.Count | Should -Be 20 } It "takes lower case inputs" { diff --git a/Tests/Rules/AvoidDynamicallyCreatingVariableNames.tests.ps1 b/Tests/Rules/AvoidDynamicallyCreatingVariableNames.tests.ps1 new file mode 100644 index 000000000..1a47abbac --- /dev/null +++ b/Tests/Rules/AvoidDynamicallyCreatingVariableNames.tests.ps1 @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False positive')] +[Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidUsingCmdletAliases', 'nv', Justification = 'For test purposes')] +param() + +BeforeAll { + $ruleName = "PSAvoidDynamicallyCreatingVariableNames" + $ruleMessage = "'{0}' is a dynamic variable name. Please avoid creating variables with a dynamic name" +} + +Describe "AvoidDynamicallyCreatingVariableNames" { + + BeforeAll { + $Settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $true } } + } + } + + Context "Violates" { + It "Basic dynamic variable name" { + $scriptDefinition = { New-Variable -Name $Test }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Information + $violations.Extent.Text | Should -Be {New-Variable -Name $Test}.ToString() + $violations.Message | Should -Be ($ruleMessage -f '$Test') + } + + It "Using alias" { + $scriptDefinition = { nv -Name $Test }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Information + $violations.Extent.Text | Should -Be {nv -Name $Test}.ToString() + $violations.Message | Should -Be ($ruleMessage -f '$Test') + } + + It "Using uppercase" { + $scriptDefinition = { NEW-VARIABLE -Name $Test }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Information + $violations.Extent.Text | Should -Be {NEW-VARIABLE -Name $Test}.ToString() + $violations.Message | Should -Be ($ruleMessage -f '$Test') + } + + It "Common dynamic variable iteration" { + $scriptDefinition = { + 'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + New-Variable -Name "My$_" -Value ($i++) + } + $MyTwo # returns 2 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Information + $violations.Extent.Text | Should -Be {New-Variable -Name "My$_" -Value ($i++)}.ToString() + $violations.Message | Should -Be ($ruleMessage -f 'My$_') + } + + It "Unquoted positional binding" { + $scriptDefinition = { + $myVarName = 'foo' + New-Variable $myVarName + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Information + $violations.Extent.Text | Should -Be {New-Variable $myVarName}.ToString() + $violations.Message | Should -Be ($ruleMessage -f '$myVarName') + } + + It "Quoted positional binding" { + $scriptDefinition = { + 'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + New-Variable "My$_" ($i++) + } + $MyTwo # returns 2 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations.Count | Should -Be 1 + $violations.Severity | Should -Be Information + $violations.Extent.Text | Should -Be {New-Variable "My$_" ($i++)}.ToString() + $violations.Message | Should -Be ($ruleMessage -f 'My$_') + } + } + + Context "Compliant" { + It "Common hash table population" { + $scriptDefinition = { + $My = @{} + 'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + $My[$_] = $i++ + } + $My.Two # returns 2 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Scoped hash table population" { + $scriptDefinition = { + New-Variable -Name My -Value @{} -Option ReadOnly -Scope Script + 'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + $Script:My[$_] = $i++ + } + $Script:My.Two # returns 2 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + + It "Verbatim (single quoted) name with dollar sign" { + $scriptDefinition = { + New-Variable -Name '$Sign1' + New-Variable -Name '$Sign2' -Value 'Dollar' + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Disabled" { + BeforeAll { + $Settings = @{ + IncludeRules = @($ruleName) + Rules = @{ $ruleName = @{ Enable = $false } } + } + } + + It "ConvertFrom-SecureString -AsPlainText" { + $scriptDefinition = { New-Variable -Name $Test }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } + + Context "Suppressed" { + It "Basic dynamic variable name" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidDynamicallyCreatingVariableNames', '$Test', Justification = 'Test')] + Param() + New-Variable -Name $Test + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + It "Common dynamic variable iteration" { + $scriptDefinition = { + [Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidDynamicallyCreatingVariableNames', 'My$_', Justification = 'Test')] + Param() + 'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + New-Variable -Name "My$_" -Value ($i++) + } + $MyTwo # returns 2 + }.ToString() + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $Settings + $violations | Should -BeNullOrEmpty + } + } +} \ No newline at end of file diff --git a/docs/Rules/AvoidDynamicallyCreatingVariableNames.md b/docs/Rules/AvoidDynamicallyCreatingVariableNames.md new file mode 100644 index 000000000..4d5035f3e --- /dev/null +++ b/docs/Rules/AvoidDynamicallyCreatingVariableNames.md @@ -0,0 +1,77 @@ +--- +description: Avoid dynamic variable names, instead use a hash table or similar dictionary type. +ms.date: 04/21/2026 +ms.topic: reference +title: AvoidDynamicallyCreatingVariableNames +--- +# AvoidDynamicallyCreatingVariableNames + +**Severity Level: Information** + +## Description + +Don't create variables with dynamic names. It also makes the code difficult to understand and can +lead to unexpected behavior if the variable names are not unique or if they collide with existing +variables. A dynamic name is a name constructed using string concatenation or interpolation. +This rule checks for the use of `New-Variable` with a dynamic name. + +> [!NOTE] +> This rule is not enabled by default. The user needs to enable it through settings. + +## How to Fix + +Use a hash table or similar dictionary type to store values with dynamic keys. When you require a +specific scope, option, or visibility, put the dictionary (hashtable) in that scope and apply the +appropriate option or visibility. + +## Example + +### Wrong + +```powershell +'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + New-Variable -Name "My$_" -Value ($i++) +} +$MyTwo # returns 2 +``` + +### Correct + +```powershell +$My = @{} +'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + $My[$_] = $i++ +} +$My.Two # returns 2 +``` + +In this example, you want the values to be read-only and available in the script scope. +Put the hashtable in the script scope and make it read-only. + +```powershell +New-Variable -Name My -Value @{} -Option ReadOnly -Scope Script +'One', 'Two', 'Three' | ForEach-Object -Begin { $i = 1 } -Process { + $Script:My[$_] = $i++ +} +$Script:My.Two # returns 2 +``` + +## Configuration + +```powershell +Rules = @{ + PSAvoidDynamicallyCreatingVariableNames = @{ + Enable = $true + } +} +``` + +### Parameters + +- `Enable`: **bool** (Default value is `$false`) + + Enable or disable the rule during ScriptAnalyzer invocation. + +## References +- [New-Variable](xref:Microsoft.PowerShell.Utility.New-Variable) + diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 06d9204b0..fac3c7d40 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -14,6 +14,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [AvoidAssignmentToAutomaticVariable](./AvoidAssignmentToAutomaticVariable.md) | Warning | Yes | | | [AvoidDefaultValueForMandatoryParameter](./AvoidDefaultValueForMandatoryParameter.md) | Warning | Yes | | | [AvoidDefaultValueSwitchParameter](./AvoidDefaultValueSwitchParameter.md) | Warning | Yes | | +| [AvoidDynamicallyCreatingVariableNames](./AvoidDynamicallyCreatingVariableNames.md) | Information | No | Yes | | [AvoidExclaimOperator](./AvoidExclaimOperator.md) | Warning | No | | | [AvoidGlobalAliases1](./AvoidGlobalAliases.md) | Warning | Yes | | | [AvoidGlobalFunctions](./AvoidGlobalFunctions.md) | Warning | Yes | |