From e73df6dcec5a33dada56255b8e928db9005417c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Tue, 19 Aug 2025 03:43:51 +0200 Subject: [PATCH 1/8] Added UseFullyQualifiedCmdletNames rule --- Rules/UseFullyQualifiedCmdletNames.cs | 213 ++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 Rules/UseFullyQualifiedCmdletNames.cs diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs new file mode 100644 index 000000000..51e302953 --- /dev/null +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -0,0 +1,213 @@ +//--------------------------------------------------------------------------------- +// 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.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 : IScriptRule + { + private Dictionary resolutionCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + internal const string AnalyzerName = "Microsoft.Windows.PowerShell.ScriptAnalyzer"; + + /// + /// 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 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) + { + continue; + } + + if (resolvedCommand.CommandType != CommandTypes.Cmdlet && + resolvedCommand.CommandType != CommandTypes.Function && + resolvedCommand.CommandType != CommandTypes.Alias) + { + continue; + } + + string moduleName = resolvedCommand.ModuleName; + string actualCmdletName = resolvedCommand.Name; + + if (resolvedCommand is AliasInfo aliasInfo) + { + if (aliasInfo.ResolvedCommand == null) + { + continue; + } + + actualCmdletName = aliasInfo.ResolvedCommand.Name; + moduleName = aliasInfo.ResolvedCommand.ModuleName; + } + + if (string.IsNullOrEmpty(moduleName) || string.IsNullOrEmpty(actualCmdletName)) + { + continue; + } + + fullyQualifiedName = $"{moduleName}\\{actualCmdletName}"; + resolutionCache[commandName] = fullyQualifiedName; + } + + 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)GetSeverity(), + 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 string GetName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesName); + } + + /// + /// Retrieves the common name of this rule. + /// + /// The common name of this rule + public string GetCommonName() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesCommonName); + } + + /// + /// Retrieves the localized description of this rule. + /// + /// The localized description of this rule + public string GetDescription() + { + return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesDescription); + } + + /// + /// Retrieves the source type of this rule. + /// + /// The source type of this rule + public SourceType GetSourceType() + { + return SourceType.Builtin; + } + + /// + /// Retrieves the source name of this rule. + /// + /// The source name of this rule + public string GetSourceName() + { + return AnalyzerName; + } + + /// + /// Retrieves the severity of this rule. + /// + /// The severity of this rule + public RuleSeverity GetSeverity() + { + return RuleSeverity.Error; + } + } +} \ No newline at end of file From dc90c532e29488ebdbdc868fad9e1f112f0cc2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Tue, 19 Aug 2025 04:35:06 +0200 Subject: [PATCH 2/8] Missing Strings.resx --- Rules/Strings.resx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 From 71cbf21b93e78ff1d21d2fd81b79f75082e05275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Tue, 19 Aug 2025 05:51:53 +0200 Subject: [PATCH 3/8] Added UseFullyQualifiedCmdletNames rule --- Rules/UseFullyQualifiedCmdletNames.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs index 51e302953..a4ef0d4ce 100644 --- a/Rules/UseFullyQualifiedCmdletNames.cs +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -162,7 +162,7 @@ private CommandInfo ResolveCommand(string commandName) /// The localized name of this rule public string GetName() { - return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesName); + return string.Format(CultureInfo.CurrentCulture, Strings.NameSpaceFormat, GetSourceName(), Strings.UseFullyQualifiedCmdletNamesName); } /// @@ -198,7 +198,7 @@ public SourceType GetSourceType() /// The source name of this rule public string GetSourceName() { - return AnalyzerName; + return "PS"; } /// From 63e9bf518918e432af457179773cfded1adbc170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Tue, 19 Aug 2025 06:20:08 +0200 Subject: [PATCH 4/8] Update Rules/UseFullyQualifiedCmdletNames.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Rules/UseFullyQualifiedCmdletNames.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs index a4ef0d4ce..6520e19ca 100644 --- a/Rules/UseFullyQualifiedCmdletNames.cs +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -207,7 +207,7 @@ public string GetSourceName() /// The severity of this rule public RuleSeverity GetSeverity() { - return RuleSeverity.Error; + return RuleSeverity.Warning; } } } \ No newline at end of file From 26dfa4ee363828a22c19e6827082cc42d271276a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Tue, 19 Aug 2025 06:20:56 +0200 Subject: [PATCH 5/8] Update Rules/UseFullyQualifiedCmdletNames.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Rules/UseFullyQualifiedCmdletNames.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs index 6520e19ca..b594ec056 100644 --- a/Rules/UseFullyQualifiedCmdletNames.cs +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -43,7 +43,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules #endif public class UseFullyQualifiedCmdletNames : IScriptRule { - private Dictionary resolutionCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + private ConcurrentDictionary resolutionCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); internal const string AnalyzerName = "Microsoft.Windows.PowerShell.ScriptAnalyzer"; From 69a2690518f6df3ce4309791e0ee1a8d1e90daf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Tue, 19 Aug 2025 06:25:37 +0200 Subject: [PATCH 6/8] Added UseFullyQualifiedCmdletNames rule --- Rules/UseFullyQualifiedCmdletNames.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs index b594ec056..edb4ab59c 100644 --- a/Rules/UseFullyQualifiedCmdletNames.cs +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -23,6 +23,7 @@ using System; using System.Collections.Generic; +using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Linq; using System.Management.Automation; From 971f02bb1ee6546eedce761570e75630619e5263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Wed, 20 Aug 2025 00:21:44 +0200 Subject: [PATCH 7/8] Added Docs and Test for UseFullyQualifiedCmdletNames rule --- .../UseFullyQualifiedCmdletNames.Tests.ps1 | 202 ++++++++++++++++++ docs/Rules/README.md | 1 + docs/Rules/UseFullyQualifiedCmdletNames.md | 113 ++++++++++ 3 files changed, 316 insertions(+) create mode 100644 Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 create mode 100644 docs/Rules/UseFullyQualifiedCmdletNames.md diff --git a/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 b/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 new file mode 100644 index 000000000..67fd0e0c2 --- /dev/null +++ b/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 @@ -0,0 +1,202 @@ +# 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" { + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = 'gci C:\temp' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = 'gci' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = @' +Get-Command +Write-Host "test" +gci -Recurse +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = 'Get-Process | Where-Object { $_.Name -eq "notepad" }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = 'Invoke-Command -ScriptBlock { Get-Process }' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = 'Get-ChildItem -Path C:\temp -Recurse' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be "Get-ChildItem" + } + + It "detects violations with splatting" { + $scriptDefinition = @' +$params = @{ Name = "notepad" } +Get-Process @params +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be "Get-Process" + } + } + + Context "Violation Extent" { + It "should return only the cmdlet extent, not parameters" { + $scriptDefinition = 'Get-Command -Name Test' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations[0].Extent.Text | Should -Be "Get-Command" + } + + It "should return only the alias extent, not parameters" { + $scriptDefinition = 'gci -Recurse' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = @' +Microsoft.PowerShell.Core\Get-Command +Get-Process +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = @' +Get-Content "file.txt" +ConvertTo-Json @{} +Test-Connection "server" +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = @' +Get-Command +Write-Host "test" +Get-ChildItem +'@ + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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" { + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $violations[0].Severity | Should -Be ([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Warning) + } + + It "has correct rule name" { + $scriptDefinition = 'Get-Command' + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -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..d59998c38 --- /dev/null +++ b/docs/Rules/UseFullyQualifiedCmdletNames.md @@ -0,0 +1,113 @@ +--- +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. + +## How to Fix + +Use the fully qualified cmdlet name in the format `ModuleName\CmdletName` instead of just `CmdletName`. + +## 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 + +## Related Rules + +- [AvoidUsingCmdletAliases](./AvoidUsingCmdletAliases.md) - Recommends using full cmdlet names instead of aliases +- [UseCorrectCasing](./UseCorrectCasing.md) - Ensures correct casing for cmdlet names From 33fad88e6c2b897e016457ac652e9ea6bf0540b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Vaessen?= Date: Thu, 21 Aug 2025 20:33:26 +0200 Subject: [PATCH 8/8] Also added 'IgnoredModules' parameter and updated tests and docs accordingly --- Rules/UseFullyQualifiedCmdletNames.cs | 55 ++- .../UseFullyQualifiedCmdletNames.Tests.ps1 | 389 +++++++++++++++++- docs/Rules/UseFullyQualifiedCmdletNames.md | 58 ++- 3 files changed, 474 insertions(+), 28 deletions(-) diff --git a/Rules/UseFullyQualifiedCmdletNames.cs b/Rules/UseFullyQualifiedCmdletNames.cs index edb4ab59c..f9a949545 100644 --- a/Rules/UseFullyQualifiedCmdletNames.cs +++ b/Rules/UseFullyQualifiedCmdletNames.cs @@ -42,19 +42,27 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules #if !CORECLR [Export(typeof(IScriptRule))] #endif - public class UseFullyQualifiedCmdletNames : IScriptRule + 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 IEnumerable AnalyzeScript(Ast ast, string fileName) + public override IEnumerable AnalyzeScript(Ast ast, string fileName) { if (ast == null) { @@ -76,6 +84,8 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) var resolvedCommand = ResolveCommand(commandName); if (resolvedCommand == null) { + // Cache null results to avoid repeated lookups + resolutionCache[commandName] = null; continue; } @@ -83,6 +93,8 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) resolvedCommand.CommandType != CommandTypes.Function && resolvedCommand.CommandType != CommandTypes.Alias) { + // Cache null results for non-cmdlet/function/alias commands + resolutionCache[commandName] = null; continue; } @@ -93,6 +105,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) { if (aliasInfo.ResolvedCommand == null) { + resolutionCache[commandName] = null; continue; } @@ -102,12 +115,36 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) 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; @@ -140,7 +177,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) message, extent, GetName(), - (DiagnosticSeverity)GetSeverity(), + DiagnosticSeverity.Warning, fileName, null, suggestedCorrections); @@ -161,7 +198,7 @@ private CommandInfo ResolveCommand(string commandName) /// Retrieves the localized name of this rule. /// /// The localized name of this rule - public string GetName() + public override string GetName() { return string.Format(CultureInfo.CurrentCulture, Strings.NameSpaceFormat, GetSourceName(), Strings.UseFullyQualifiedCmdletNamesName); } @@ -170,7 +207,7 @@ public string GetName() /// Retrieves the common name of this rule. /// /// The common name of this rule - public string GetCommonName() + public override string GetCommonName() { return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesCommonName); } @@ -179,7 +216,7 @@ public string GetCommonName() /// Retrieves the localized description of this rule. /// /// The localized description of this rule - public string GetDescription() + public override string GetDescription() { return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesDescription); } @@ -188,7 +225,7 @@ public string GetDescription() /// Retrieves the source type of this rule. /// /// The source type of this rule - public SourceType GetSourceType() + public override SourceType GetSourceType() { return SourceType.Builtin; } @@ -197,7 +234,7 @@ public SourceType GetSourceType() /// Retrieves the source name of this rule. /// /// The source name of this rule - public string GetSourceName() + public override string GetSourceName() { return "PS"; } @@ -206,7 +243,7 @@ public string GetSourceName() /// Retrieves the severity of this rule. /// /// The severity of this rule - public RuleSeverity GetSeverity() + public override RuleSeverity GetSeverity() { return RuleSeverity.Warning; } diff --git a/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 b/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 index 67fd0e0c2..0bd05a57a 100644 --- a/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 +++ b/Tests/Rules/UseFullyQualifiedCmdletNames.Tests.ps1 @@ -10,42 +10,77 @@ BeforeAll { 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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $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" @@ -53,49 +88,332 @@ gci -Recurse } It "detects violations in pipelines" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } $scriptDefinition = 'Get-Process | Where-Object { $_.Name -eq "notepad" }' - $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName $violations[0].Extent.Text | Should -Be "gci" } } @@ -139,11 +457,18 @@ $command = "Get-Command" } 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 -IncludeRule $violationName + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName $violations.Count | Should -Be 1 $violations[0].Extent.Text | Should -Be "Get-Process" } @@ -151,28 +476,42 @@ 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 -IncludeRule $violationName + $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 -IncludeRule $violationName + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName $violations.Count | Should -Be 3 $getCmdViolation = $violations | Where-Object { $_.Extent.Text -eq "Get-Command" } @@ -188,14 +527,28 @@ Get-ChildItem Context "Severity and Rule Properties" { It "has Warning severity" { + $settings = @{ + Rules = @{ + PSUseFullyQualifiedCmdletNames = @{ + Enable = $true + } + } + } $scriptDefinition = 'Get-Command' - $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName + $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 -IncludeRule $violationName + $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -IncludeRule $violationName $violations[0].RuleName | Should -Be $violationName } } diff --git a/docs/Rules/UseFullyQualifiedCmdletNames.md b/docs/Rules/UseFullyQualifiedCmdletNames.md index d59998c38..b87d00bb2 100644 --- a/docs/Rules/UseFullyQualifiedCmdletNames.md +++ b/docs/Rules/UseFullyQualifiedCmdletNames.md @@ -14,10 +14,66 @@ PowerShell cmdlets and functions can be called with or without their module 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 @@ -34,7 +90,6 @@ ls -Force ``` ### Correct - ```powershell # Fully qualified cmdlet calls Microsoft.PowerShell.Core\Get-Command @@ -106,6 +161,7 @@ Microsoft.PowerShell.Management\Get-ChildItem - 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