diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 260214967..575181801 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1224,4 +1224,22 @@ AvoidUsingAllowUnencryptedAuthentication + + The alias '{0}' should be replaced with the fully qualified cmdlet name '{1}'. + + + The cmdlet '{0}' should be replaced with the fully qualified cmdlet name '{1}'. + + + Replace '{0}' with '{1}' + + + UseFullyQualifiedCmdletNames + + + Use Fully Qualified Cmdlet Names + + + Cmdlets should be called using their fully qualified names instead of aliases or abbreviated forms. + \ No newline at end of file diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs new file mode 100644 index 000000000..f9a949545 --- /dev/null +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -0,0 +1,251 @@ +//--------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. +// The MIT License (MIT) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +//--------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic; +#if !CORECLR +using System.ComponentModel.Composition; +#endif +using System.Globalization; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules +{ + /// + /// UseFullyQualifiedCmdletNames: Checks if cmdlet and function invocations use fully qualified module names. + /// +#if !CORECLR + [Export(typeof(IScriptRule))] +#endif + public class UseFullyQualifiedCmdletNames : ConfigurableRule + { + private ConcurrentDictionary resolutionCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + internal const string AnalyzerName = "Microsoft.Windows.PowerShell.ScriptAnalyzer"; + + /// + /// Modules to ignore when applying this rule. + /// Commands from these modules will not be expanded to their fully qualified names. + /// Default is empty array (no modules ignored - all cmdlets are processed). + /// + [ConfigurableRuleProperty(defaultValue: new string[] { })] + public string[] IgnoredModules { get; protected set; } + + /// + /// Analyzes the given ast to find cmdlet invocations that are not fully qualified. + /// + /// The script's ast + /// The script's file name + /// The diagnostic results of this rule + public override IEnumerable AnalyzeScript(Ast ast, string fileName) + { + if (ast == null) + { + throw new ArgumentNullException(nameof(ast)); + } + + var commandAsts = ast.FindAll(testAst => testAst is CommandAst, true).Cast(); + + foreach (var commandAst in commandAsts) + { + var commandName = commandAst.GetCommandName(); + if (string.IsNullOrWhiteSpace(commandName) || commandName.Contains("\\")) + { + continue; + } + + if (!resolutionCache.TryGetValue(commandName, out string fullyQualifiedName)) + { + var resolvedCommand = ResolveCommand(commandName); + if (resolvedCommand == null) + { + // Cache null results to avoid repeated lookups + resolutionCache[commandName] = null; + continue; + } + + if (resolvedCommand.CommandType != CommandTypes.Cmdlet && + resolvedCommand.CommandType != CommandTypes.Function && + resolvedCommand.CommandType != CommandTypes.Alias) + { + // Cache null results for non-cmdlet/function/alias commands + resolutionCache[commandName] = null; + continue; + } + + string moduleName = resolvedCommand.ModuleName; + string actualCmdletName = resolvedCommand.Name; + + if (resolvedCommand is AliasInfo aliasInfo) + { + if (aliasInfo.ResolvedCommand == null) + { + resolutionCache[commandName] = null; + continue; + } + + actualCmdletName = aliasInfo.ResolvedCommand.Name; + moduleName = aliasInfo.ResolvedCommand.ModuleName; + } + + if (string.IsNullOrEmpty(moduleName) || string.IsNullOrEmpty(actualCmdletName)) + { + resolutionCache[commandName] = null; + continue; + } + + // Check if the module is in the ignored list + if (IgnoredModules != null && IgnoredModules.Contains(moduleName, StringComparer.OrdinalIgnoreCase)) + { + // Cache null for ignored modules to avoid re-checking + resolutionCache[commandName] = null; + continue; + } + + fullyQualifiedName = $"{moduleName}\\{actualCmdletName}"; + resolutionCache[commandName] = fullyQualifiedName; + } + else + { + // If we have a cached result but it's null/empty, it means we should skip this command + if (string.IsNullOrEmpty(fullyQualifiedName)) + { + continue; + } + + // Re-check ignored modules for cached results (in case IgnoredModules was changed) + var moduleName = fullyQualifiedName.Split('\\')[0]; + if (IgnoredModules != null && IgnoredModules.Contains(moduleName, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + } + + var extent = commandAst.CommandElements[0].Extent; + + bool isAlias = commandName != fullyQualifiedName.Split('\\')[1]; + string message = string.Format( + CultureInfo.CurrentCulture, + isAlias ? Strings.UseFullyQualifiedCmdletNamesAliasError : Strings.UseFullyQualifiedCmdletNamesCommandError, + commandName, + fullyQualifiedName); + + string correctionDescription = string.Format( + CultureInfo.CurrentCulture, + Strings.UseFullyQualifiedCmdletNamesCorrection, + commandName, + fullyQualifiedName); + + var suggestedCorrections = new Collection + { + new CorrectionExtent( + extent.StartLineNumber, + extent.EndLineNumber, + extent.StartColumnNumber, + extent.EndColumnNumber, + fullyQualifiedName, + fileName, + correctionDescription) + }; + + yield return new DiagnosticRecord( + message, + extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + null, + suggestedCorrections); + } + } + + /// + /// Resolves the command info for a given name using the shared runspace. + /// + /// The command name to resolve. + /// The resolved CommandInfo or null if not found. + private CommandInfo ResolveCommand(string commandName) + { + return Helper.Instance.GetCommandInfo(commandName, CommandTypes.All); + } + + /// + /// Retrieves the localized name of this rule. + /// + /// The localized name of this rule + public override string GetName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.NameSpaceFormat, GetSourceName(), Strings.UseFullyQualifiedCmdletNamesName); + } + + /// + /// Retrieves the common name of this rule. + /// + /// The common name of this rule + public override string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesCommonName); + } + + /// + /// Retrieves the localized description of this rule. + /// + /// The localized description of this rule + public override string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesDescription); + } + + /// + /// Retrieves the source type of this rule. + /// + /// The source type of this rule + public override SourceType GetSourceType() + { + return SourceType.Builtin; + } + + /// + /// Retrieves the source name of this rule. + /// + /// The source name of this rule + public override string GetSourceName() + { + return "PS"; + } + + /// + /// Retrieves the severity of this rule. + /// + /// The severity of this rule + public override RuleSeverity GetSeverity() + { + return RuleSeverity.Warning; + } + } +} \ No newline at end of file diff --git a/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 b/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 new file mode 100644 index 000000000..0bd05a57a --- /dev/null +++ b/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 @@ -0,0 +1,555 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $violationName = "PSUseFullyQualifiedCmdletNames" + $testRootDirectory = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $testRootDirectory "PSScriptAnalyzerTestHelper.psm1") +} + +Describe "UseFullyQualifiedCmdletNames" { + Context "When there are violations" { + It "detects unqualified cmdlet calls" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Message | Should -Match "The cmdlet 'Get-Command' should be replaced with the fully qualified cmdlet name 'Microsoft.PowerShell.Core\\Get-Command'" + } + + It "detects unqualified alias usage" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'gci C:\temp' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Message | Should -Match "The alias 'gci' should be replaced with the fully qualified cmdlet name 'Microsoft.PowerShell.Management\\Get-ChildItem'" + } + + It "provides correct suggested corrections for cmdlets" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations[0].SuggestedCorrections.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Microsoft.PowerShell.Core\Get-Command' + $violations[0].SuggestedCorrections[0].Description | Should -Be "Replace 'Get-Command' with 'Microsoft.PowerShell.Core\Get-Command'" + } + + It "provides correct suggested corrections for aliases" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'gci' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations[0].SuggestedCorrections.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Microsoft.PowerShell.Management\Get-ChildItem' + $violations[0].SuggestedCorrections[0].Description | Should -Be "Replace 'gci' with 'Microsoft.PowerShell.Management\Get-ChildItem'" + } + + It "detects multiple violations in same script" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +Get-Command +Write-Host "test" +gci -Recurse +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 3 + $violations[0].Extent.Text | Should -Be "Get-Command" + $violations[1].Extent.Text | Should -Be "Write-Host" + $violations[2].Extent.Text | Should -Be "gci" + } + + It "detects violations in pipelines" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-Process | Where-Object { $_.Name -eq "notepad" }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 2 + $violations[0].Extent.Text | Should -Be "Get-Process" + $violations[1].Extent.Text | Should -Be "Where-Object" + } + + It "detects violations in script blocks" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Invoke-Command -ScriptBlock { Get-Process }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 2 + ($violations.Extent.Text -contains "Invoke-Command") | Should -Be $true + ($violations.Extent.Text -contains "Get-Process") | Should -Be $true + } + + It "detects violations with parameters" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-ChildItem -Path C:\temp -Recurse' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be "Get-ChildItem" + } + + It "detects violations with splatting" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +$params = @{ Name = "notepad" } +Get-Process @params +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be "Get-Process" + } + } + + Context "Configuration - Default Behavior" { + It "is disabled by default (no configuration)" { + $scriptDefinition = @' +Get-Process +Get-ChildItem +Start-Process notepad +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "processes all cmdlets when enabled with empty IgnoredModules" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @() + } + } + } + $scriptDefinition = @' +Get-Process +Get-ChildItem +Start-Process notepad +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 3 + ($violations.Extent.Text -contains "Get-Process") | Should -Be $true + ($violations.Extent.Text -contains "Get-ChildItem") | Should -Be $true + ($violations.Extent.Text -contains "Start-Process") | Should -Be $true + } + + It "processes all cmdlets from all modules when enabled" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +Write-Host "test" +ConvertTo-Json @{} +Out-String +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 3 + ($violations.Extent.Text -contains "Write-Host") | Should -Be $true + ($violations.Extent.Text -contains "ConvertTo-Json") | Should -Be $true + ($violations.Extent.Text -contains "Out-String") | Should -Be $true + } + + It "processes cmdlets from Core module when enabled" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be "Get-Command" + } + } + + Context "Configuration - Custom IgnoredModules" { + It "respects custom ignored modules configuration" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @('Microsoft.PowerShell.Core') + } + } + } + + $scriptDefinition = @' +Get-Command +Get-Process +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be "Get-Process" # Get-Command should be ignored + } + + It "handles empty IgnoredModules array (flags everything)" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @() + } + } + } + + $scriptDefinition = @' +Get-Process +Write-Host "test" +Get-Command +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 3 + ($violations.Extent.Text -contains "Get-Process") | Should -Be $true + ($violations.Extent.Text -contains "Write-Host") | Should -Be $true + ($violations.Extent.Text -contains "Get-Command") | Should -Be $true + } + + It "handles multiple custom ignored modules" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @( + 'Microsoft.PowerShell.Core', + 'Microsoft.PowerShell.Management', + 'Microsoft.PowerShell.Utility' + ) + } + } + } + + $scriptDefinition = @' +Get-Command +Get-Process +Write-Host "test" +ConvertTo-Json @{} +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "is case-insensitive for module names in IgnoredModules" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @('microsoft.powershell.core') # lowercase + } + } + } + + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + } + + Context "Configuration - Enable/Disable" { + It "can be disabled via configuration" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $false + } + } + } + + $scriptDefinition = @' +Get-Command +Get-Process +Write-Host "test" +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "is disabled by default when no Enable setting is specified" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + IgnoredModules = @() # Only specify IgnoredModules, not Enable + } + } + } + + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + } + + Context "Configuration - Mixed Scenarios" { + It "handles aliases from ignored modules" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @('Microsoft.PowerShell.Management') + } + } + } + + $scriptDefinition = 'gci C:\temp' # gci resolves to Get-ChildItem from Management module + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "handles mixed ignored and non-ignored modules" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @('Microsoft.PowerShell.Management') + } + } + } + + $scriptDefinition = @' +Get-Process +Get-Command +Write-Host "test" +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 2 + ($violations.Extent.Text -contains "Get-Command") | Should -Be $true + ($violations.Extent.Text -contains "Write-Host") | Should -Be $true + ($violations.Extent.Text -contains "Get-Process") | Should -Be $false # Should be ignored + } + + It "caches ignored module decisions correctly" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @('Microsoft.PowerShell.Management') + } + } + } + + $scriptDefinition = @' +Get-Process +Get-ChildItem +Get-Process +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 0 # All should be ignored due to caching + } + } + + Context "Violation Extent" { + It "should return only the cmdlet extent, not parameters" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-Command -Name Test' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations[0].Extent.Text | Should -Be "Get-Command" + } + + It "should return only the alias extent, not parameters" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'gci -Recurse' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations[0].Extent.Text | Should -Be "gci" + } + } + + Context "When there are no violations" { + It "ignores already qualified cmdlets" { + $scriptDefinition = @' +Microsoft.PowerShell.Core\Get-Command +Microsoft.PowerShell.Utility\Write-Host "test" +Microsoft.PowerShell.Management\Get-ChildItem -Path C:\temp +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "ignores native commands" { + $scriptDefinition = @' +where.exe notepad +cmd /c dir +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "ignores variables" { + $scriptDefinition = @' +$GetCommand = "test" +$variable = $true +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "ignores string literals containing cmdlet names" { + $scriptDefinition = @' +$command = "Get-Command" +"The Get-Command cmdlet is useful" +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "handles mixed qualified and unqualified cmdlets" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +Microsoft.PowerShell.Core\Get-Command +Get-Process +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be "Get-Process" + } + } + + Context "Different Module Contexts" { + It "handles cmdlets from different modules" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +Get-Content "file.txt" +ConvertTo-Json @{} +Test-Connection "server" +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 3 + + $getContentViolation = $violations | Where-Object { $_.Extent.Text -eq "Get-Content" } + $getContentViolation.SuggestedCorrections[0].Text | Should -Match "Get-Content$" + + $convertToJsonViolation = $violations | Where-Object { $_.Extent.Text -eq "ConvertTo-Json" } + $convertToJsonViolation.SuggestedCorrections[0].Text | Should -Match "ConvertTo-Json$" + } + + It "suggests different modules for different cmdlets" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +Get-Command +Write-Host "test" +Get-ChildItem +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 3 + + $getCmdViolation = $violations | Where-Object { $_.Extent.Text -eq "Get-Command" } + $getCmdViolation.SuggestedCorrections[0].Text | Should -Be 'Microsoft.PowerShell.Core\Get-Command' + + $writeHostViolation = $violations | Where-Object { $_.Extent.Text -eq "Write-Host" } + $writeHostViolation.SuggestedCorrections[0].Text | Should -Be 'Microsoft.PowerShell.Utility\Write-Host' + + $getChildItemViolation = $violations | Where-Object { $_.Extent.Text -eq "Get-ChildItem" } + $getChildItemViolation.SuggestedCorrections[0].Text | Should -Be 'Microsoft.PowerShell.Management\Get-ChildItem' + } + } + + Context "Severity and Rule Properties" { + It "has Warning severity" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations[0].Severity | Should -Be ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Warning) + } + + It "has correct rule name" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations[0].RuleName | Should -Be $violationName + } + } +} \ No newline at end of file diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 06f27d2da..e5d608bf4 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -69,6 +69,7 @@ The PSScriptAnalyzer contains the following rule definitions. | [UseConsistentIndentation](./UseConsistentIndentation.md) | Warning | No | Yes | | [UseConsistentWhitespace](./UseConsistentWhitespace.md) | Warning | No | Yes | | [UseCorrectCasing](./UseCorrectCasing.md) | Information | No | Yes | +| [UseFullyQualifiedCmdletNames](./UseFullyQualifiedCmdletNames.md) | Warning | Yes | | | [UseDeclaredVarsMoreThanAssignments](./UseDeclaredVarsMoreThanAssignments.md) | Warning | Yes | | | [UseLiteralInitializerForHashtable](./UseLiteralInitializerForHashtable.md) | Warning | Yes | | | [UseOutputTypeCorrectly](./UseOutputTypeCorrectly.md) | Information | Yes | | diff --git a/docs/Rules/UseFullyQualifiedCmdletNames.md b/docs/Rules/UseFullyQualifiedCmdletNames.md new file mode 100644 index 000000000..b87d00bb2 --- /dev/null +++ b/docs/Rules/UseFullyQualifiedCmdletNames.md @@ -0,0 +1,169 @@ +--- +description: Use fully qualified module names when calling cmdlets and functions. +ms.date: 08/20/2025 +ms.topic: reference +title: UseFullyQualifiedCmdletNames +--- +# UseFullyQualifiedCmdletNames + +**Severity Level: Warning** + +## Description + +PowerShell cmdlets and functions can be called with or without their module names. Using fully qualified names (with the module prefix) improves script clarity, reduces ambiguity, and helps ensure that the correct cmdlet is executed, especially in environments where multiple modules might contain cmdlets with the same name. + +This rule identifies cmdlet and function calls that are not fully qualified and suggests adding the appropriate module qualifier. + +# Configuration + +```powershell +Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @() + } +} +``` + + +## How to Fix + +Use the fully qualified cmdlet name in the format `ModuleName\CmdletName` instead of just `CmdletName`. + +## Configuration Examples + +### Enable the rule and enforce fully qualified names for all modules + +```powershell +Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @() + } +} +``` + +### Enable the rule but ignore specific modules + +```powershell +Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @('Microsoft.PowerShell.Management', 'Microsoft.PowerShell.Utility') + } +} +``` + +### Disable the rule + +```powershell +Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $false + } +} +``` + +### Parameters + +#### Enable: bool (Default value is `$false`) + +Enable or disable the rule during ScriptAnalyzer invocation. By default, this rule is disabled and must be explicitly enabled. + +#### IgnoredModules: string[] (Default value is `@()`) + +Modules to ignore when applying this rule. Commands from these modules will not be flagged for expansion to their fully qualified names. By default, this is empty so all modules are checked. + +## Examples + +### Wrong + +```powershell +# Unqualified cmdlet calls +Get-Command +Write-Host "Hello World" +Get-ChildItem -Path C:\temp + +# Unqualified alias usage +gci C:\temp +ls -Force +``` + +### Correct +```powershell +# Fully qualified cmdlet calls +Microsoft.PowerShell.Core\Get-Command +Microsoft.PowerShell.Utility\Write-Host "Hello World" +Microsoft.PowerShell.Management\Get-ChildItem -Path C:\temp + +# Fully qualified equivalents of aliases +Microsoft.PowerShell.Management\Get-ChildItem C:\temp +Microsoft.PowerShell.Management\Get-ChildItem -Force +``` + +## Benefits + +- **Clarity**: Makes it explicit which module provides each cmdlet +- **Reliability**: Ensures the intended cmdlet is called, even if name conflicts exist +- **Module Auto-Loading**: Triggers PowerShell's module auto-loading mechanism, automatically importing the required module if it's not already loaded +- **Reduced Alias Conflicts**: Eliminates ambiguity that can arise from aliases that might conflict with cmdlets from different modules +- **Maintenance**: Easier to understand dependencies and troubleshoot issues +- **Best Practice**: Follows PowerShell best practices for production scripts +- **Performance**: Can improve performance by avoiding the need for PowerShell to search through multiple modules to resolve cmdlet names + +## When to Use + +This rule is particularly valuable for: + +- Production scripts and modules +- Scripts shared across different environments +- Code that might run with varying module configurations +- Enterprise environments with custom or third-party modules + +## Module Auto-Loading and Alias Considerations + +### Auto-Loading Benefits + +When you use fully qualified cmdlet names, PowerShell's module auto-loading feature provides several advantages: + +- **Automatic Import**: If the specified module isn't already loaded, PowerShell will automatically import it when the cmdlet is called +- **Explicit Dependencies**: The script clearly declares which modules it depends on without requiring manual `Import-Module` calls +- **Version Control**: Helps ensure the correct module version is loaded, especially when multiple versions are installed + +### Avoiding Alias Conflicts + +Fully qualified names help prevent common issues with aliases: + +- **Conflicting Aliases**: Different modules may define aliases with the same name but different behaviors +- **Platform Differences**: Some aliases behave differently across PowerShell versions or operating systems +- **Custom Aliases**: User-defined or organizational aliases won't interfere with script execution +- **Predictable Behavior**: Scripts behave consistently regardless of the user's alias configuration + +### Example of Conflict Resolution + +```powershell +# Without qualification - could resolve to different cmdlets depending on loaded modules +Get-Item + +# With qualification - always resolves to the specific cmdlet +Microsoft.PowerShell.Management\Get-Item + +# Alias that might conflict with custom definitions +ls + +# Fully qualified equivalent that avoids conflicts +Microsoft.PowerShell.Management\Get-ChildItem +``` + +## Notes + +- The rule analyzes cmdlets, functions, and aliases that can be resolved to a module +- Native commands (like `cmd.exe`) and user-defined functions without modules are not flagged +- Already qualified cmdlet calls are not flagged +- Variables and string literals containing cmdlet names are not flagged +- By default, all modules are checked; use `IgnoredModules` to exclude specific modules + +## Related Rules + +- [AvoidUsingCmdletAliases](./AvoidUsingCmdletAliases.md) - Recommends using full cmdlet names instead of aliases +- [UseCorrectCasing](./UseCorrectCasing.md) - Ensures correct casing for cmdlet names