From 10fb8b596e48c2fc049459bde2165fd210f9aeec Mon Sep 17 00:00:00 2001 From: Liam Peters Date: Tue, 19 Aug 2025 22:03:43 +0100 Subject: [PATCH] Add UseConsistentParameterSetName Rule --- Rules/Strings.resx | 27 + Rules/UseConsistentParameterSetName.cs | 467 ++++++++++++++++++ Tests/Engine/GetScriptAnalyzerRule.tests.ps1 | 2 +- .../UseConsistentParameterSetName.tests.ps1 | 391 +++++++++++++++ docs/Rules/README.md | 1 + docs/Rules/UseConsistentParameterSetName.md | 116 +++++ 6 files changed, 1003 insertions(+), 1 deletion(-) 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 260214967..3059ceaf2 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1224,4 +1224,31 @@ 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 + \ No newline at end of file diff --git a/Rules/UseConsistentParameterSetName.cs b/Rules/UseConsistentParameterSetName.cs new file mode 100644 index 000000000..10723d550 --- /dev/null +++ b/Rules/UseConsistentParameterSetName.cs @@ -0,0 +1,467 @@ +// 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 : IScriptRule + { + + private const string AllParameterSetsName = "__AllParameterSets"; + + /// + /// AnalyzeScript: Check for parameter set name issues. + /// + public 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) + .Count() > 1 + ); + + 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"); + // if (attributeAst == null || attributeAst.NamedArguments == null) + // return null; + + // if (!string.Equals(attributeAst.TypeName?.Name, "Parameter", StringComparison.OrdinalIgnoreCase)) + // return null; + + // foreach (var namedArg in attributeAst.NamedArguments) + // { + // if (string.Equals(namedArg.ArgumentName, "ParameterSetName", StringComparison.OrdinalIgnoreCase)) + // { + // return namedArg.Argument?.Extent; + // } + // } + // return null; + } + + /// + /// 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 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 string GetCommonName() => string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameCommonName + ); + + /// + /// GetDescription: Retrieves the description of this rule. + /// + /// The description of this rule + public string GetDescription() => string.Format( + CultureInfo.CurrentCulture, + Strings.UseConsistentParameterSetNameDescription + ); + + /// + /// Method: Retrieves the type of the rule: builtin, managed or module. + /// + public SourceType GetSourceType() => SourceType.Builtin; + + /// + /// GetSeverity: Retrieves the severity of the rule: error, warning of information. + /// + /// + public RuleSeverity GetSeverity() => RuleSeverity.Warning; + + /// + /// Method: Retrieves the module/assembly name the rule is from. + /// + public string GetSourceName() => string.Format( + CultureInfo.CurrentCulture, Strings.SourceName + ); + } +} diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 index 8d61c1c7f..c3b744803 100644 --- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 +++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 @@ -63,7 +63,7 @@ Describe "Test Name parameters" { It "get Rules with no parameters supplied" { $defaultRules = Get-ScriptAnalyzerRule - $expectedNumRules = 70 + $expectedNumRules = 71 if ($PSVersionTable.PSVersion.Major -le 4) { # for PSv3 PSAvoidGlobalAliases is not shipped because diff --git a/Tests/Rules/UseConsistentParameterSetName.tests.ps1 b/Tests/Rules/UseConsistentParameterSetName.tests.ps1 new file mode 100644 index 000000000..539ca078a --- /dev/null +++ b/Tests/Rules/UseConsistentParameterSetName.tests.ps1 @@ -0,0 +1,391 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $ruleName = 'PSUseConsistentParameterSetName' +} + +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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 -IncludeRule @($ruleName) + $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 06f27d2da..aca398222 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -67,6 +67,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 | Yes | | | [UseConsistentWhitespace](./UseConsistentWhitespace.md) | Warning | No | Yes | | [UseCorrectCasing](./UseCorrectCasing.md) | Information | No | Yes | | [UseDeclaredVarsMoreThanAssignments](./UseDeclaredVarsMoreThanAssignments.md) | Warning | Yes | | diff --git a/docs/Rules/UseConsistentParameterSetName.md b/docs/Rules/UseConsistentParameterSetName.md new file mode 100644 index 000000000..66f0d3fce --- /dev/null +++ b/docs/Rules/UseConsistentParameterSetName.md @@ -0,0 +1,116 @@ +--- +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 + +## 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 + ) +} +``` + +## 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