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