From b5a838eaae7febb86159dc68d7d69903d0c04b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Wed, 16 Sep 2026 00:36:11 +0200 Subject: [PATCH] Add PSUseFullyQualifiedCmdletNames rule --- Rules/Strings.resx | 21 + Rules/UseFullyQualifiedCmdletNames.cs | 335 ++++++++++ .../UseFullyQualifiedCmdletNames.Tests.ps1 | 592 ++++++++++++++++++ docs/Rules/README.md | 46 +- docs/Rules/UseFullyQualifiedCmdletNames.md | 90 +++ 5 files changed, 1062 insertions(+), 22 deletions(-) create mode 100644 Rules/UseFullyQualifiedCmdletNames.cs create mode 100644 Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 create mode 100644 docs/Rules/UseFullyQualifiedCmdletNames.md diff --git a/Rules/Strings.resx b/Rules/Strings.resx index 2f2b1e1a9..419aa2ed5 100644 --- a/Rules/Strings.resx +++ b/Rules/Strings.resx @@ -1302,6 +1302,27 @@ UseConsistentParameterSetName + + 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}'. + + + The function '{0}' should be replaced with the fully qualified name '{1}'. + + + Replace '{0}' with '{1}' + + + UseFullyQualifiedCmdletNames + + + Use Fully Qualified Cmdlet Names + + + Commands should be called using their fully qualified names, including the module name, instead of unqualified names or aliases. + Avoid reserved words as function names diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs new file mode 100644 index 000000000..c02962cde --- /dev/null +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -0,0 +1,335 @@ +//--------------------------------------------------------------------------------- +// 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 readonly 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 functionDefinitions = ast.FindAll(testAst => testAst is FunctionDefinitionAst, true).Cast().ToList(); + + 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; + } + + // Skip commands that resolve to a locally declared function, since qualifying them would change behavior. + if (IsShadowedByLocalFunction(commandAst, commandName, functionDefinitions)) + { + continue; + } + + var resolvedCommand = resolutionCache.GetOrAdd(commandName, ResolveCommand); + if (resolvedCommand.FullyQualifiedName == null) + { + continue; + } + + // Re-check ignored modules for cached results (in case IgnoredModules was changed). + if (IgnoredModules != null && IgnoredModules.Contains(resolvedCommand.ModuleName, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + var extent = commandAst.CommandElements[0].Extent; + + string message = string.Format( + CultureInfo.CurrentCulture, + GetErrorResource(resolvedCommand.CommandType), + commandName, + resolvedCommand.FullyQualifiedName); + + string correctionDescription = string.Format( + CultureInfo.CurrentCulture, + Strings.UseFullyQualifiedCmdletNamesCorrection, + commandName, + resolvedCommand.FullyQualifiedName); + + var suggestedCorrections = new Collection + { + new CorrectionExtent( + extent.StartLineNumber, + extent.EndLineNumber, + extent.StartColumnNumber, + extent.EndColumnNumber, + resolvedCommand.FullyQualifiedName, + fileName, + correctionDescription) + }; + + yield return new DiagnosticRecord( + message, + extent, + GetName(), + DiagnosticSeverity.Warning, + fileName, + null, + suggestedCorrections); + } + } + + /// + /// Checks whether a command name matches a function declared in a scope that is visible at the + /// command's location. + /// + private static bool IsShadowedByLocalFunction( + CommandAst commandAst, + string commandName, + IEnumerable functionDefinitions) + { + var commandScope = GetContainingScriptBlock(commandAst); + + foreach (var functionDefinition in functionDefinitions) + { + if (!functionDefinition.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var functionScope = GetContainingScriptBlock(functionDefinition); + if (functionScope != null && + (functionScope == commandScope || IsAncestorOf(functionScope, commandScope))) + { + return true; + } + } + + return false; + } + + /// + /// Returns the nearest enclosing script block, which represents the scope where a function is + /// declared or a command is invoked. + /// + private static ScriptBlockAst GetContainingScriptBlock(Ast node) + { + for (Ast current = node; current != null; current = current.Parent) + { + if (current is ScriptBlockAst scriptBlock) + { + return scriptBlock; + } + } + + return null; + } + + /// + /// Returns true if the first AST is an ancestor of the second. + /// + private static bool IsAncestorOf(Ast ancestor, Ast descendant) + { + for (Ast current = descendant.Parent; current != null; current = current.Parent) + { + if (current == ancestor) + { + return true; + } + } + + return false; + } + + /// + /// Returns the message resource appropriate for the resolved command type. + /// + private static string GetErrorResource(CommandTypes commandType) + { + switch (commandType) + { + case CommandTypes.Alias: + return Strings.UseFullyQualifiedCmdletNamesAliasError; + case CommandTypes.Function: + return Strings.UseFullyQualifiedCmdletNamesFunctionError; + default: + return Strings.UseFullyQualifiedCmdletNamesCommandError; + } + } + + /// + /// Resolves the command info for a given name using the shared runspace. + /// + /// The command name to resolve. + /// A cached result describing the resolved command. + private ResolvedCommand ResolveCommand(string commandName) + { + var commandInfo = Helper.Instance.GetCommandInfo(commandName, CommandTypes.All); + if (commandInfo == null) + { + return new ResolvedCommand(null, null, CommandTypes.Application); + } + + if (commandInfo.CommandType != CommandTypes.Cmdlet && + commandInfo.CommandType != CommandTypes.Function && + commandInfo.CommandType != CommandTypes.Alias) + { + return new ResolvedCommand(null, null, commandInfo.CommandType); + } + + var commandType = commandInfo.CommandType; + + string moduleName = commandInfo.ModuleName; + string resolvedName = commandInfo.Name; + + if (commandInfo is AliasInfo aliasInfo) + { + if (aliasInfo.ResolvedCommand == null) + { + return new ResolvedCommand(null, null, commandType); + } + + resolvedName = aliasInfo.ResolvedCommand.Name; + moduleName = aliasInfo.ResolvedCommand.ModuleName; + } + + if (string.IsNullOrEmpty(moduleName) || string.IsNullOrEmpty(resolvedName)) + { + return new ResolvedCommand(null, null, commandType); + } + + return new ResolvedCommand($"{moduleName}\\{resolvedName}", moduleName, commandType); + } + + /// + /// Holds the result of resolving a command name. + /// + private sealed class ResolvedCommand + { + public string FullyQualifiedName { get; } + + public string ModuleName { get; } + + public CommandTypes CommandType { get; } + + public ResolvedCommand(string fullyQualifiedName, string moduleName, CommandTypes commandType) + { + FullyQualifiedName = fullyQualifiedName; + ModuleName = moduleName; + CommandType = commandType; + } + } + + /// + /// 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..bdcb35709 --- /dev/null +++ b/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 @@ -0,0 +1,592 @@ +# 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" + } + + It "reports differently cased cmdlet names as cmdlets, not aliases" { + $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'" + } + } + + 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" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +where.exe notepad +cmd /c dir +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName + $violations.Count | Should -Be 0 + } + + It "ignores calls to locally declared functions" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } + $scriptDefinition = @' +function Get-ChildItem { "local" } +Get-ChildItem +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -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 c35d646d0..e08cf3072 100644 --- a/docs/Rules/README.md +++ b/docs/Rules/README.md @@ -87,17 +87,18 @@ title: List of PSScriptAnalyzer rules | [UseConstrainedLanguageMode][67] | Warning | Disabled | Yes | | [UseCorrectCasing][68] | Information | Disabled | Yes | | [UseDeclaredVarsMoreThanAssignments][69] | Warning | Always enabled | | -| [UseLiteralInitializerForHashtable][70] | Warning | Always enabled | | -| [UseOutputTypeCorrectly][71] | Information | Always enabled | | -| [UseProcessBlockForPipelineCommand][72] | Warning | Always enabled | | -| [UsePSCredentialType][73] | Warning | Always enabled | | -| [UseShouldProcessForStateChangingFunctions][74] | Warning | Always enabled | | -| [UseSingleValueFromPipelineParameter][75] | Warning | Disabled | Yes | -| [UseSingularNouns][76] | Warning | Enabled | Yes | -| [UseSupportsShouldProcess][77] | Warning | Always enabled | | -| [UseToExportFieldsInManifest][78] | Warning | Always enabled | | -| [UseUsingScopeModifierInNewRunspaces][79] | Warning | Always enabled | | -| [UseUTF8EncodingForHelpFile][80] | Warning | Always enabled | | +| [UseFullyQualifiedCmdletNames][70] | Warning | Disabled | Yes | +| [UseLiteralInitializerForHashtable][71] | Warning | Always enabled | | +| [UseOutputTypeCorrectly][72] | Information | Always enabled | | +| [UseProcessBlockForPipelineCommand][73] | Warning | Always enabled | | +| [UsePSCredentialType][74] | Warning | Always enabled | | +| [UseShouldProcessForStateChangingFunctions][75] | Warning | Always enabled | | +| [UseSingleValueFromPipelineParameter][76] | Warning | Disabled | Yes | +| [UseSingularNouns][77] | Warning | Enabled | Yes | +| [UseSupportsShouldProcess][78] | Warning | Always enabled | | +| [UseToExportFieldsInManifest][79] | Warning | Always enabled | | +| [UseUsingScopeModifierInNewRunspaces][80] | Warning | Always enabled | | +| [UseUTF8EncodingForHelpFile][81] | Warning | Always enabled | | [01]: ../using-scriptanalyzer.md#suppressing-rules @@ -169,14 +170,15 @@ title: List of PSScriptAnalyzer rules [67]: UseConstrainedLanguageMode.md [68]: UseCorrectCasing.md [69]: UseDeclaredVarsMoreThanAssignments.md -[70]: UseLiteralInitializerForHashtable.md -[71]: UseOutputTypeCorrectly.md -[72]: UseProcessBlockForPipelineCommand.md -[73]: UsePSCredentialType.md -[74]: UseShouldProcessForStateChangingFunctions.md -[75]: UseSingleValueFromPipelineParameter.md -[76]: UseSingularNouns.md -[77]: UseSupportsShouldProcess.md -[78]: UseToExportFieldsInManifest.md -[79]: UseUsingScopeModifierInNewRunspaces.md -[80]: UseUTF8EncodingForHelpFile.md +[70]: UseFullyQualifiedCmdletNames.md +[71]: UseLiteralInitializerForHashtable.md +[72]: UseOutputTypeCorrectly.md +[73]: UseProcessBlockForPipelineCommand.md +[74]: UsePSCredentialType.md +[75]: UseShouldProcessForStateChangingFunctions.md +[76]: UseSingleValueFromPipelineParameter.md +[77]: UseSingularNouns.md +[78]: UseSupportsShouldProcess.md +[79]: UseToExportFieldsInManifest.md +[80]: UseUsingScopeModifierInNewRunspaces.md +[81]: UseUTF8EncodingForHelpFile.md diff --git a/docs/Rules/UseFullyQualifiedCmdletNames.md b/docs/Rules/UseFullyQualifiedCmdletNames.md new file mode 100644 index 000000000..2fa328f57 --- /dev/null +++ b/docs/Rules/UseFullyQualifiedCmdletNames.md @@ -0,0 +1,90 @@ +--- +description: Use fully qualified module names when calling cmdlets and functions +ms.date: 09/22/2026 +ms.topic: reference +title: UseFullyQualifiedCmdletNames +--- +# UseFullyQualifiedCmdletNames + +**Severity Level: Warning** + +**Default state: Disabled** + +## Description + +PowerShell resolves a command name against the commands that are available in the session. A script +that calls `Get-Process` instead of `Microsoft.PowerShell.Management\Get-Process` binds to whichever +alias, function, or cmdlet currently owns that name. A module that exports the same name, or an +alias or function defined in the session, can therefore change which command the script runs. + +This rule detects calls to cmdlets, module functions, and aliases that aren't qualified with the +module that provides them, and suggests the fully qualified `ModuleName\CommandName` replacement. +Qualifying a call also tells PowerShell which module to load, so a script doesn't depend on the +order in which commands happen to be available. + +The rule doesn't flag native commands and external applications, functions declared in the analyzed +script, commands that don't resolve to a module, calls that already use the fully qualified form, or +variables and string literals that contain text that looks like a command name. + +## Example + +### Noncompliant + +```powershell +# Unqualified cmdlet calls +Get-Command +Write-Host 'Hello World' +Get-ChildItem -Path C:\temp + +# Aliases +gci C:\temp +ls -Force +``` + +### Compliant + +```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 + +# The cmdlets that the aliases resolve to +Microsoft.PowerShell.Management\Get-ChildItem C:\temp +Microsoft.PowerShell.Management\Get-ChildItem -Force +``` + +## Configure rule + +```powershell +Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + IgnoredModules = @( + 'Microsoft.PowerShell.Management' + 'Microsoft.PowerShell.Utility' + ) + } +} +``` + +## Parameters + +### Enable + +This parameter controls whether ScriptAnalyzer checks the code against this rule. It accepts a +boolean value. To enable this rule, set this parameter to `$true`. The default value is `$false`. + +### IgnoredModules + +This parameter specifies the modules whose commands the rule doesn't flag. It accepts an array of +module-name strings, which are matched without regard to case. The default value is `@()`. + +## See also + +- [about_Command_Precedence][01] +- [about_Modules][02] + + +[01]: /powershell/module/microsoft.powershell.core/about/about_command_precedence +[02]: /powershell/module/microsoft.powershell.core/about/about_modules