diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 000000000..cc17c531c
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+FROM mcr.microsoft.com/dotnet/sdk:8.0
+
+RUN pwsh --command Install-Module platyPS,Pester -Force
\ No newline at end of file
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..dbbc9c7ee
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,16 @@
+// For format details, see https://aka.ms/vscode-remote/devcontainer.json
+{
+ "name": "C# (.NET 8.0)",
+ "dockerFile": "Dockerfile",
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "terminal.integrated.defaultProfile.linux": "pwsh"
+ },
+ "extensions": [
+ "ms-dotnettools.csharp",
+ "ms-vscode.powershell"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 983234361..8de3ce904 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,6 +1,6 @@
# Default owners
-* @andyleejordan @bergmeister
+* @PowerShell/extension @bergmeister
# Version bumps and documentation updates
-Directory.Build.props @sdwheeler @michaeltlombardi
-/docs/ @sdwheeler @michaeltlombardi
+Directory.Build.props @PowerShell/extension @sdwheeler @michaeltlombardi
+/docs/ @PowerShell/extension @sdwheeler @michaeltlombardi
diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml
index d681191c8..0a48b30fb 100644
--- a/.github/workflows/ci-test.yml
+++ b/.github/workflows/ci-test.yml
@@ -1,6 +1,7 @@
name: CI Tests
on:
+ workflow_dispatch: # to allow contributors to trigger CI manually in their fork
push:
branches: [ main ]
pull_request:
@@ -31,24 +32,43 @@ jobs:
shell: pwsh
- name: Build
- run: ./build.ps1 -Configuration Release -All
+ run: ./build.ps1 -Configuration Release -All -Verbose
shell: pwsh
- name: Package
- run: ./build.ps1 -BuildNupkg
+ run: ./build.ps1 -BuildNupkg -Verbose
shell: pwsh
- name: Test
- run: ./build.ps1 -Test
+ run: ./build.ps1 -Test -Verbose
shell: pwsh
- name: Test Windows PowerShell
+ if: matrix.os == 'windows-latest'
run: |
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck
- ./build.ps1 -Test
- if: matrix.os == 'windows-latest'
+ ./build.ps1 -Test -Verbose
shell: powershell
+ - name: Download PowerShell install script
+ uses: actions/checkout@v4
+ with:
+ repository: PowerShell/PowerShell
+ path: pwsh
+ sparse-checkout: tools/install-powershell.ps1
+ sparse-checkout-cone-mode: false
+
+ - name: Install preview
+ continue-on-error: true
+ run: ./pwsh/tools/install-powershell.ps1 -Preview -Destination ./preview
+ shell: pwsh
+
+ - name: Test preview
+ run: |
+ $PwshPreview = if ($isWindows) { "./preview/pwsh.exe" } else { "./preview/pwsh" }
+ ./build.ps1 -Test -WithPowerShell:$PwshPreview -Verbose
+ shell: pwsh
+
- name: Upload build artifacts
uses: actions/upload-artifact@v4
if: always()
diff --git a/.pipelines/PSScriptAnalyzer-Official.yml b/.pipelines/PSScriptAnalyzer-Official.yml
index 971cdc351..abea9ab3c 100644
--- a/.pipelines/PSScriptAnalyzer-Official.yml
+++ b/.pipelines/PSScriptAnalyzer-Official.yml
@@ -80,10 +80,7 @@ extends:
inputs:
packageType: sdk
useGlobalJson: true
- - pwsh: |
- Register-PSRepository -Name CFS -SourceLocation "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell/nuget/v2" -InstallationPolicy Trusted
- Install-Module -Repository CFS -Name Microsoft.PowerShell.PSResourceGet
- ./tools/installPSResources.ps1 -PSRepository CFS
+ - pwsh: ./tools/installPSResources.ps1 -PSRepository CFS
displayName: Install PSResources
- pwsh: ./build.ps1 -Configuration Release -All
displayName: Build
@@ -141,7 +138,7 @@ extends:
target: main
assets: $(Pipeline.Workspace)/PSScriptAnalyzer.$(version).nupkg
tagSource: userSpecifiedTag
- tag: v$(version)
+ tag: $(version)
isDraft: true
addChangeLog: false
releaseNotesSource: inline
diff --git a/CHANGELOG.MD b/CHANGELOG.MD
index 6afc5be8e..16dc799c1 100644
--- a/CHANGELOG.MD
+++ b/CHANGELOG.MD
@@ -1,16 +1,146 @@
# CHANGELOG
+## [1.25.0](https://github.com/PowerShell/PSScriptAnalyzer/releases/tag/1.25.0)
+
+### What's Changed
+* Add configuration instructions for UseCorrectCasing (again) by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/2090
+* Fix for PSUseConsistantWhiteSpace when using statement is present by @AlexandraDorey in https://github.com/PowerShell/PSScriptAnalyzer/pull/2091
+* Exclude PSNativeCommandArgumentPassing variable by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2093
+* Update version check in PSScriptAnalyzer.psm1 to align with SMA version for PowerShell 7 by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2107
+* Change CommandInfo lookup for commands in the form `module\cmdletName` by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2125
+* PSAvoidDefaultValueForMandatoryParameter: Fix param block and parameter set handling by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2121
+* Add AvoidReservedWordsAsFunctionNames Rule by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2128
+* Update .NET SDK, PowerShell SDK, Newtonsoft.Json and codeowners by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2143
+* Preserve braced member access in `UseConsistentWhitespace` by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2140
+* Update docs and diagnostic message for UseCorrectCasing by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2138
+* Fix SuppressMessage CustomRule by @HeyItsGilbert in https://github.com/PowerShell/PSScriptAnalyzer/pull/2142
+* Sync rules docs from docs repo by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/2144
+* Allow contributors to run CI manually by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2153
+* Remove Conditional Compilation and Runtime Checks for v3 or v4 by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2150
+* Add UseConsistentParametersKind rule by @Haimasker in https://github.com/PowerShell/PSScriptAnalyzer/pull/2149
+* Fix typos in resource strings and associated C# references by @casuffitsharp in https://github.com/PowerShell/PSScriptAnalyzer/pull/2163
+* Optimise LINQ queries by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2160
+* AlignAssignmentStatement overhaul to fix issues and include handing of Enums. by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2132
+* Dynamically count expected rules in GetScriptAnalyzerRule test by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2167
+* Add UseConsistentParameterSetName Rule by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2124
+* feat(tests): added closing bracket test for formatting presets by @o-l-a-v in https://github.com/PowerShell/PSScriptAnalyzer/pull/2161
+* Add UseSingleValueFromPipelineParameter Rule by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2119
+* Added user-friendly installation instructions by @admercs in https://github.com/PowerShell/PSScriptAnalyzer/pull/2141
+* Update .NET SDK, PowerShell SDK dependencies by @Copilot in https://github.com/PowerShell/PSScriptAnalyzer/pull/2168
+* Add Optional PSUseConstrainedLanguageMode rule by @joshcorr in https://github.com/PowerShell/PSScriptAnalyzer/pull/2165
+
+### New Contributors
+* @AlexandraDorey made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2091
+* @HeyItsGilbert made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2142
+* @Haimasker made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2149
+* @casuffitsharp made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2163
+* @o-l-a-v made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2161
+* @admercs made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2141
+* @Copilot made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2168
+* @joshcorr made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2165
+
+**Full Changelog**: https://github.com/PowerShell/PSScriptAnalyzer/compare/1.24.0...1.25.0
+
+## [1.24.0](https://github.com/PowerShell/PSScriptAnalyzer/releases/tag/1.24.0)
+
+### What's Changed
+#### Breaking Changes
+
+Minimum required PowerShell version raised from 3 to 5.1
+* Drop v3 and v4 support from build by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2081
+
+#### New Features
+
+* Add new options (enabled by default) to formatting rule `UseCorrectCasing` to also correct operators, keywords and commands - Add UseConsistentCasing by @Jaykul in https://github.com/PowerShell/PSScriptAnalyzer/pull/1704
+
+#### Enhancements
+
+* PSAlignAssignmentStatement: Ignore hashtables with a single key-value pair by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/1986
+* Use `RequiredResource` hashtable to specify PowerShell module versions by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2053
+* Set exit code of `Invoke-ScriptAnalyzer -EnableExit` to total number of diagnostics (#2054) by @MatejKafka in https://github.com/PowerShell/PSScriptAnalyzer/pull/2055
+* PSAvoidAssignmentToAutomaticVariable: Ignore when a Parameter has an Attribute that contains a Variable expression by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/1988
+* Trim unnecessary trailing spaces from string resources in Strings.resx by @XPlantefeve in https://github.com/PowerShell/PSScriptAnalyzer/pull/1972
+* Do not print summary repeatedly for each logger by @MatejKafka in https://github.com/PowerShell/PSScriptAnalyzer/pull/2058
+* Make Settings type detection more robust by @Tadas in https://github.com/PowerShell/PSScriptAnalyzer/pull/1967
+* Add foreach Assignment to AvoidAssignmentToAutomaticVariable by @PoshAJ in https://github.com/PowerShell/PSScriptAnalyzer/pull/2021
+* Invoke-ScriptAnalyzer: Stream diagnostics instead of batching by @MatejKafka in https://github.com/PowerShell/PSScriptAnalyzer/pull/2062
+* Invoke-ScriptAnalyzer: Print summary only once per invocation by @MatejKafka in https://github.com/PowerShell/PSScriptAnalyzer/pull/2063
+* Invoke-ScriptAnalyzer: Include parse errors in reported error count by @MatejKafka in https://github.com/PowerShell/PSScriptAnalyzer/pull/2069
+* Add exception message for missing rules by @Tadas in https://github.com/PowerShell/PSScriptAnalyzer/pull/1968
+
+#### Bug Fixes
+
+* Update links in module manifest by @martincostello in https://github.com/PowerShell/PSScriptAnalyzer/pull/2034
+* Fix incorrect `-ReportSummary` Pester test grouping by @MatejKafka in https://github.com/PowerShell/PSScriptAnalyzer/pull/2057
+* Fixed erroneous PSUseDeclaredVarsMoreThanAssignments for some globals variables by @John-Leitch in https://github.com/PowerShell/PSScriptAnalyzer/pull/2013
+* PSReservedParams: Make severity Error instead of Warning by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/1989
+* PSUseConsistentIndentation: Check indentation of lines where first token is a LParen not followed by comment or new line by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/1995
+* PSUseConsistentWhitespace: Correctly fix whitespace between command parameters when parameter value spans multiple lines by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2064
+* PSAvoidTrailingWhitespace: Rule not applied when using formatter + single character lines with trailing whitespace are truncated by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/1993
+* PSUseConsistentWhitespace: Ignore whitespace between separator and comment by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2065
+* PSReviewUnusedParameter false positive for ValueFromPipeline by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2072
+* Change severity of UseCorrectCasing to be Information by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2082
+
+#### Process Changes
+
+* Copy more files to module root by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2037
+* Upgrade to .NET 8 since .NET 6 is past EOL by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2073
+* Use -NoProfile when invoking pwsh in Pester tests by @MatejKafka in https://github.com/PowerShell/PSScriptAnalyzer/pull/2061
+* Add GitHub Actions Ubuntu's dotnet path by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2080
+* Update README.md with recent upgrade to .NET 8 by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2076
+* Update CHANGELOG.MD with 1.23.0 release notes by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2078
+* Bring back Codespaces by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2077
+* Update SMA version to 7.4.7 by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2075
+* Test PowerShell Preview in CI by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2070
+* Backport MSDocs changes by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2085
+* Document new optional parameters added to UseCorrectCasing by @bergmeister in https://github.com/PowerShell/PSScriptAnalyzer/pull/2086
+
+### New Contributors
+* @martincostello made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2034
+* @MatejKafka made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2055
+* @XPlantefeve made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/1972
+* @John-Leitch made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2013
+* @Tadas made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/1967
+* @PoshAJ made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/2021
+* @Jaykul made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/1704
+
+**Full Changelog**: https://github.com/PowerShell/PSScriptAnalyzer/compare/1.23.0...1.24.0
+
+## [1.23.0](https://github.com/PowerShell/PSScriptAnalyzer/tree/1.23.0) - 2024-10-09
+
+### What's Changed
+* Adding OneBranch pipeline YAML config file for OSS_Microsoft_PSSA-Official by @adityapatwardhan in https://github.com/PowerShell/PSScriptAnalyzer/pull/1981
+* Update format and grammar of AvoidUsingAllowUnencryptedAuthentication by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/1974
+* Move to OneBranch Signing and SBOM generation by @TravisEz13 in https://github.com/PowerShell/PSScriptAnalyzer/pull/1982
+* Sync rule docs changes by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/1985
+* Sync docs changes from MicrosoftDocs/PowerShell-Docs-Modules#213 by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/1987
+* Update CHANGELOG for 1.22.0 release by @sdwheeler in https://github.com/PowerShell/PSScriptAnalyzer/pull/1990
+* Update Code of Conduct by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2002
+* Update default type definition of `RuleInfo` by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2011
+* PSUseConsistentWhitespace: Handle redirect operators which are not in stream order by @liamjpeters in https://github.com/PowerShell/PSScriptAnalyzer/pull/2001
+* Setup GitHub Actions CI by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2018
+* Setup new OneBranch pipeline by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2027
+* Bump SMA version by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2028
+* Package updates by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2030
+* v1.23.0: Update version for new release by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2032
+* Migrate release pipeline to DeployBox by @andyleejordan in https://github.com/PowerShell/PSScriptAnalyzer/pull/2033
+
+### New Contributors
+* @adityapatwardhan made their first contribution in https://github.com/PowerShell/PSScriptAnalyzer/pull/1981
+
+**Full Changelog**: https://github.com/PowerShell/PSScriptAnalyzer/compare/1.22.0...1.23.0
+
## [1.22.0](https://github.com/PowerShell/PSScriptAnalyzer/tree/1.22.0) - 2024-03-05
Minimum required version when using PowerShell 7 is now `7.2.11`.
-## New Rule
+### New Rule
- Add AvoidUsingAllowUnencryptedAuthentication by @MJVL in (#1857)
- Add the AvoidExclaimOperator rule to warn about the use of the ! negation operator. Fixes (#1826) by
@liamjpeters in (#1922)
-## Enhancements
+### Enhancements
- Enable suppression of PSAvoidAssignmentToAutomaticVariable for specific variable or parameter by
@fflaten in (#1896)
@@ -24,11 +154,11 @@ Minimum required version when using PowerShell 7 is now `7.2.11`.
CommandAllowList by @bergmeister in (#1850)
- PSReviewUnusedParameter: Add CommandsToTraverse option by @FriedrichWeinmann in (#1921)
-## Fixes
+### Fixes
- Prevent NullReferenceException for null analysis type. by @hubuk in (#1949)
-## Build & Test, Documentation and Maintenance
+### Build & Test, Documentation and Maintenance
- UseApprovedVerbs.md: Backport minor change of PR 104 in PowerShell-Docs-Modules by @bergmeister in
(#1849)
@@ -67,7 +197,7 @@ Minimum required version when using PowerShell 7 is now `7.2.11`.
- Remove Appveyor badge from main README by @bergmeister in (#1962)
- Do not hard code common parameters in module help test any more by @bergmeister in (#1963)
-## New Contributors
+### New Contributors
- @fflaten made their first contribution in (#1897)
- @ALiwoto made their first contribution in (#1902)
diff --git a/Directory.Build.props b/Directory.Build.props
index d2db04cd1..a9ac6cc61 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,7 +1,7 @@
- 1.23.0
+ 1.25.0true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e43bd7dfd..bd8565ee8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,15 +3,14 @@
-
-
-
+
-
-
+
+
+
diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs
index 71c37d83c..aa9d725f3 100644
--- a/Engine/CommandInfoCache.cs
+++ b/Engine/CommandInfoCache.cs
@@ -80,16 +80,31 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes
/// Returns null if command does not exists
private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? commandType)
{
+ string moduleName = null;
+ string actualCmdName = cmdName;
+
+ // Check if cmdName is in the format "moduleName\CmdletName" (exactly one backslash)
+ int backslashIndex = cmdName.IndexOf('\\');
+ if (
+ backslashIndex > 0 &&
+ backslashIndex == cmdName.LastIndexOf('\\') &&
+ backslashIndex != cmdName.Length - 1 &&
+ backslashIndex != 0
+ )
+ {
+ moduleName = cmdName.Substring(0, backslashIndex);
+ actualCmdName = cmdName.Substring(backslashIndex + 1);
+ }
// 'Get-Command ?' would return % for example due to PowerShell interpreting is a single-character-wildcard search and not just the ? alias.
// For more details see https://github.com/PowerShell/PowerShell/issues/9308
- cmdName = WildcardPattern.Escape(cmdName);
+ actualCmdName = WildcardPattern.Escape(actualCmdName);
using (var ps = System.Management.Automation.PowerShell.Create())
{
ps.RunspacePool = _runspacePool;
ps.AddCommand("Get-Command")
- .AddParameter("Name", cmdName)
+ .AddParameter("Name", actualCmdName)
.AddParameter("ErrorAction", "SilentlyContinue");
if (commandType != null)
@@ -97,6 +112,11 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command
ps.AddParameter("CommandType", commandType);
}
+ if (!string.IsNullOrEmpty(moduleName))
+ {
+ ps.AddParameter("Module", moduleName);
+ }
+
return ps.Invoke()
.FirstOrDefault();
}
diff --git a/Engine/Commands/InvokeScriptAnalyzerCommand.cs b/Engine/Commands/InvokeScriptAnalyzerCommand.cs
index 3be9cd7fc..bf1e740c3 100644
--- a/Engine/Commands/InvokeScriptAnalyzerCommand.cs
+++ b/Engine/Commands/InvokeScriptAnalyzerCommand.cs
@@ -34,6 +34,9 @@ public class InvokeScriptAnalyzerCommand : PSCmdlet, IOutputWriter
#region Private variables
List processedPaths;
+ // initialize to zero for all severity enum values
+ private Dictionary diagnosticCounts =
+ Enum.GetValues(typeof(DiagnosticSeverity)).Cast().ToDictionary(s => s, _ => 0);
#endregion // Private variables
#region Parameters
@@ -224,7 +227,6 @@ public object Settings
private bool stopProcessing;
-#if !PSV3
///
/// Resolve DSC resource dependency
///
@@ -235,7 +237,6 @@ public SwitchParameter SaveDscDependency
set { saveDscDependency = value; }
}
private bool saveDscDependency;
-#endif // !PSV3
#if DEBUG
///
@@ -384,7 +385,6 @@ protected override void ProcessRecord()
ProcessPath();
}
-#if !PSV3
// TODO Support dependency resolution for analyzing script definitions
if (saveDscDependency)
{
@@ -404,7 +404,6 @@ protected override void ProcessRecord()
}
return;
}
-#endif
ProcessInput();
}
@@ -412,6 +411,37 @@ protected override void EndProcessing()
{
ScriptAnalyzer.Instance.CleanUp();
base.EndProcessing();
+
+ var diagnosticCount = diagnosticCounts.Values.Sum();
+
+ if (ReportSummary.IsPresent)
+ {
+ if (diagnosticCount == 0)
+ {
+ Host.UI.WriteLine("0 rule violations found.");
+ }
+ else
+ {
+ var infoCount = diagnosticCounts[DiagnosticSeverity.Information];
+ var warningCount = diagnosticCounts[DiagnosticSeverity.Warning];
+ var errorCount = diagnosticCounts[DiagnosticSeverity.Error] + diagnosticCounts[DiagnosticSeverity.ParseError];
+ var severeDiagnosticCount = diagnosticCount - infoCount;
+
+ var colorPropertyPrefix = severeDiagnosticCount == 0 ? "Warning" : "Error";
+ var pluralS = diagnosticCount > 1 ? "s" : string.Empty;
+ ConsoleHostHelper.DisplayMessageUsingSystemProperties(
+ Host, colorPropertyPrefix + "ForegroundColor", colorPropertyPrefix + "BackgroundColor",
+ $"{diagnosticCount} rule violation{pluralS} found. Severity distribution: " +
+ $"{DiagnosticSeverity.Error} = {errorCount}, " +
+ $"{DiagnosticSeverity.Warning} = {warningCount}, " +
+ $"{DiagnosticSeverity.Information} = {infoCount}");
+ }
+ }
+
+ if (EnableExit)
+ {
+ this.Host.SetShouldExit(diagnosticCount);
+ }
}
protected override void StopProcessing()
@@ -426,92 +456,50 @@ protected override void StopProcessing()
private void ProcessInput()
{
- WriteToOutput(RunAnalysis());
+ foreach (var diagnostic in RunAnalysis())
+ {
+ diagnosticCounts[diagnostic.Severity]++;
+
+ foreach (var logger in ScriptAnalyzer.Instance.Loggers)
+ {
+ logger.LogObject(diagnostic, this);
+ }
+ }
}
private IEnumerable RunAnalysis()
{
if (!IsFileParameterSet())
{
- return ScriptAnalyzer.Instance.AnalyzeScriptDefinition(scriptDefinition, out _, out _);
- }
-
- var diagnostics = new List();
- foreach (string path in this.processedPaths)
- {
- if (fix)
- {
- ShouldProcess(path, $"Analyzing and fixing path with Recurse={this.recurse}");
- diagnostics.AddRange(ScriptAnalyzer.Instance.AnalyzeAndFixPath(path, this.ShouldProcess, this.recurse));
- }
- else
+ foreach (var record in ScriptAnalyzer.Instance.AnalyzeScriptDefinition(scriptDefinition, out _, out _))
{
- ShouldProcess(path, $"Analyzing path with Recurse={this.recurse}");
- diagnostics.AddRange(ScriptAnalyzer.Instance.AnalyzePath(path, this.ShouldProcess, this.recurse));
+ yield return record;
}
+ yield break;
}
- return diagnostics;
- }
-
- private void WriteToOutput(IEnumerable diagnosticRecords)
- {
- foreach (ILogger logger in ScriptAnalyzer.Instance.Loggers)
+ foreach (var path in this.processedPaths)
{
- var errorCount = 0;
- var warningCount = 0;
- var infoCount = 0;
- var parseErrorCount = 0;
-
- foreach (DiagnosticRecord diagnostic in diagnosticRecords)
+ if (!ShouldProcess(path, $"Analyzing path with Fix={this.fix} and Recurse={this.recurse}"))
{
- logger.LogObject(diagnostic, this);
- switch (diagnostic.Severity)
- {
- case DiagnosticSeverity.Information:
- infoCount++;
- break;
- case DiagnosticSeverity.Warning:
- warningCount++;
- break;
- case DiagnosticSeverity.Error:
- errorCount++;
- break;
- case DiagnosticSeverity.ParseError:
- parseErrorCount++;
- break;
- default:
- throw new ArgumentOutOfRangeException(nameof(diagnostic.Severity), $"Severity '{diagnostic.Severity}' is unknown");
- }
+ continue;
}
- if (ReportSummary.IsPresent)
+ if (fix)
{
- var numberOfRuleViolations = infoCount + warningCount + errorCount;
- if (numberOfRuleViolations == 0)
+ foreach (var record in ScriptAnalyzer.Instance.AnalyzeAndFixPath(path, this.ShouldProcess, this.recurse))
{
- Host.UI.WriteLine("0 rule violations found.");
+ yield return record;
}
- else
+ }
+ else
+ {
+ foreach (var record in ScriptAnalyzer.Instance.AnalyzePath(path, this.ShouldProcess, this.recurse))
{
- var pluralS = numberOfRuleViolations > 1 ? "s" : string.Empty;
- var message = $"{numberOfRuleViolations} rule violation{pluralS} found. Severity distribution: {DiagnosticSeverity.Error} = {errorCount}, {DiagnosticSeverity.Warning} = {warningCount}, {DiagnosticSeverity.Information} = {infoCount}";
- if (warningCount + errorCount == 0)
- {
- ConsoleHostHelper.DisplayMessageUsingSystemProperties(Host, "WarningForegroundColor", "WarningBackgroundColor", message);
- }
- else
- {
- ConsoleHostHelper.DisplayMessageUsingSystemProperties(Host, "ErrorForegroundColor", "ErrorBackgroundColor", message);
- }
+ yield return record;
}
}
}
-
- if (EnableExit.IsPresent)
- {
- this.Host.SetShouldExit(diagnosticRecords.Count());
- }
}
private void ProcessPath()
@@ -535,4 +523,4 @@ private bool OverrideSwitchParam(bool paramValue, string paramName)
#endregion // Private Methods
}
-}
+}
\ No newline at end of file
diff --git a/Engine/Engine.csproj b/Engine/Engine.csproj
index 3025c9a08..63b9a1b9c 100644
--- a/Engine/Engine.csproj
+++ b/Engine/Engine.csproj
@@ -2,7 +2,7 @@
$(ModuleVersion)
- net6;net462
+ net8;net462Microsoft.Windows.PowerShell.ScriptAnalyzer$(ModuleVersion)Engine
@@ -18,11 +18,11 @@
portable
-
+ $(DefineConstants);CORECLR
-
+
@@ -69,39 +69,15 @@
-
+
-
+ $(DefineConstants);PSV7;CORECLR
-
-
-
-
-
-
-
-
-
+
-
-
- $(DefineConstants);PSV3
-
-
-
- $(DefineConstants);PSV3;PSV4
-
-
-
- $(DefineConstants);PSV3
-
-
-
- $(DefineConstants);PSV3;PSV4
-
diff --git a/Engine/FindAstPositionVisitor.cs b/Engine/FindAstPositionVisitor.cs
index 05532c456..c281cfef2 100644
--- a/Engine/FindAstPositionVisitor.cs
+++ b/Engine/FindAstPositionVisitor.cs
@@ -8,11 +8,7 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer
///
/// Provides an efficient way to find the position in the AST corresponding to a given script position.
///
-#if !(PSV3 || PSV4)
internal class FindAstPositionVisitor : AstVisitor2
-#else
- internal class FindAstPositionVisitor : AstVisitor
-#endif
{
private IScriptPosition searchPosition;
@@ -300,7 +296,6 @@ public override AstVisitAction VisitWhileStatement(WhileStatementAst whileStatem
return Visit(whileStatementAst);
}
-#if !(PSV3 || PSV4)
public override AstVisitAction VisitBaseCtorInvokeMemberExpression(BaseCtorInvokeMemberExpressionAst baseCtorInvokeMemberExpressionAst)
{
return Visit(baseCtorInvokeMemberExpressionAst);
@@ -333,9 +328,8 @@ public override AstVisitAction VisitTypeDefinition(TypeDefinitionAst typeDefinit
public override AstVisitAction VisitUsingStatement(UsingStatementAst usingStatementAst)
{
- return Visit(usingStatementAst);
+ return AstVisitAction.Continue;
}
-#endif
#if !(NET462 || PSV7) // net462 includes V3,4,5
public override AstVisitAction VisitPipelineChain(PipelineChainAst pipelineChainAst)
diff --git a/Engine/Formatter.cs b/Engine/Formatter.cs
index 5a93854c5..a6a25f0fb 100644
--- a/Engine/Formatter.cs
+++ b/Engine/Formatter.cs
@@ -47,6 +47,7 @@ public static string Format(
"PSAvoidUsingDoubleQuotesForConstantString",
"PSAvoidSemicolonsAsLineTerminators",
"PSAvoidExclaimOperator",
+ "PSAvoidTrailingWhitespace",
};
var text = new EditableText(scriptDefinition);
diff --git a/Engine/Generic/DiagnosticRecord.cs b/Engine/Generic/DiagnosticRecord.cs
index 0673e1391..41eb86a05 100644
--- a/Engine/Generic/DiagnosticRecord.cs
+++ b/Engine/Generic/DiagnosticRecord.cs
@@ -74,7 +74,7 @@ public string ScriptPath
}
///
- /// Returns the rule id for this record
+ /// Returns the rule suppression id for this record
///
public string RuleSuppressionID
{
@@ -88,7 +88,7 @@ public string RuleSuppressionID
///
public IEnumerable SuggestedCorrections
{
- get { return suggestedCorrections; }
+ get { return suggestedCorrections; }
set { suggestedCorrections = value; }
}
@@ -100,7 +100,7 @@ public IEnumerable SuggestedCorrections
public DiagnosticRecord()
{
}
-
+
///
/// DiagnosticRecord: The constructor for DiagnosticRecord class that takes in suggestedCorrection
///
@@ -108,6 +108,7 @@ public DiagnosticRecord()
/// The place in the script this diagnostic refers to
/// The name of the rule that created this diagnostic
/// The severity of this diagnostic
+ /// The rule suppression ID of this diagnostic
/// The full path of the script file being analyzed
/// The correction suggested by the rule to replace the extent text
public DiagnosticRecord(
diff --git a/Engine/Generic/IDSCResourceRule.cs b/Engine/Generic/IDSCResourceRule.cs
index 3ef20cde7..5f4c2da64 100644
--- a/Engine/Generic/IDSCResourceRule.cs
+++ b/Engine/Generic/IDSCResourceRule.cs
@@ -19,8 +19,6 @@ public interface IDSCResourceRule : IRule
/// The results of the analysis
IEnumerable AnalyzeDSCResource(Ast ast, string fileName);
- #if !PSV3
-
///
/// Analyze dsc classes (if any) in the file
///
@@ -29,7 +27,5 @@ public interface IDSCResourceRule : IRule
///
IEnumerable AnalyzeDSCClass(Ast ast, string fileName);
- #endif
-
}
}
\ No newline at end of file
diff --git a/Engine/Generic/ModuleDependencyHandler.cs b/Engine/Generic/ModuleDependencyHandler.cs
index 31a43d6ca..91e557d64 100644
--- a/Engine/Generic/ModuleDependencyHandler.cs
+++ b/Engine/Generic/ModuleDependencyHandler.cs
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-#if !PSV3
using System;
using System.Collections.Generic;
using System.IO;
@@ -519,5 +518,4 @@ public void Dispose()
#endregion Public Methods
}
-}
-#endif // !PSV3
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/Engine/Generic/RuleSuppression.cs b/Engine/Generic/RuleSuppression.cs
index d912eee0c..d16b356fb 100644
--- a/Engine/Generic/RuleSuppression.cs
+++ b/Engine/Generic/RuleSuppression.cs
@@ -193,12 +193,12 @@ public RuleSuppression(AttributeAst attrAst, int start, int end)
}
else if (argumentName.Equals("rulesuppressionid", StringComparison.OrdinalIgnoreCase))
{
- if (!String.IsNullOrWhiteSpace(RuleName))
+ if (!String.IsNullOrWhiteSpace(RuleSuppressionID))
{
Error = String.Format(Strings.NamedAndPositionalArgumentsConflictError, name);
}
- RuleName = (name.Argument as StringConstantExpressionAst).Value;
+ RuleSuppressionID = (name.Argument as StringConstantExpressionAst).Value;
}
else if (argumentName.Equals("scope", StringComparison.OrdinalIgnoreCase))
{
@@ -333,16 +333,14 @@ public static List GetSuppressions(IEnumerable at
{
targetAsts = scopeAst.FindAll(ast => ast is FunctionDefinitionAst && reg.IsMatch((ast as FunctionDefinitionAst).Name), true);
}
- #if !(PSV3 || PSV4)
else if (scope.Equals("class", StringComparison.OrdinalIgnoreCase))
{
targetAsts = scopeAst.FindAll(ast => ast is TypeDefinitionAst && reg.IsMatch((ast as TypeDefinitionAst).Name), true);
}
- #endif
if (targetAsts != null)
{
- if (targetAsts.Count() == 0)
+ if (!targetAsts.Any())
{
if (String.IsNullOrWhiteSpace(scopeAst.Extent.File))
{
diff --git a/Engine/Helper.cs b/Engine/Helper.cs
index ded37b011..a162bfbcf 100644
--- a/Engine/Helper.cs
+++ b/Engine/Helper.cs
@@ -512,8 +512,6 @@ public bool IsDscResourceClassBased(ScriptBlockAst ast)
return false;
}
- #if !(PSV3||PSV4)
-
List dscResourceFunctionNames = new List(new string[] { "Test", "Get", "Set" });
IEnumerable dscClasses = ast.FindAll(item =>
@@ -528,8 +526,6 @@ item is TypeDefinitionAst
return true;
}
- #endif
-
return false;
}
@@ -761,8 +757,15 @@ public IScriptExtent GetScriptExtentForFunctionName(FunctionDefinitionAst functi
token =>
ContainsExtent(functionDefinitionAst.Extent, token.Extent)
&& token.Text.Equals(functionDefinitionAst.Name));
- var funcNameToken = funcNameTokens.FirstOrDefault();
- return funcNameToken == null ? null : funcNameToken.Extent;
+
+ // If the functions name is 'function' then the first token in the
+ // list is the function keyword itself, so we need to skip it
+ if (functionDefinitionAst.Name.Equals("function", StringComparison.OrdinalIgnoreCase))
+ {
+ var funcNameToken = funcNameTokens.Skip(1).FirstOrDefault() ?? funcNameTokens.FirstOrDefault();
+ return funcNameToken?.Extent;
+ }
+ return funcNameTokens.FirstOrDefault()?.Extent;
}
///
@@ -870,19 +873,13 @@ public bool IsUninitialized(VariableExpressionAst varAst, Ast ast)
}
///
- /// Returns true if varaible is either a global variable or an environment variable
+ /// Returns true if variable is either a global variable or an environment variable
///
///
- ///
///
- public bool IsVariableGlobalOrEnvironment(VariableExpressionAst varAst, Ast ast)
+ public bool IsVariableGlobalOrEnvironment(VariableExpressionAst varAst)
{
- if (!VariableAnalysisDictionary.ContainsKey(ast) || VariableAnalysisDictionary[ast] == null)
- {
- return false;
- }
-
- return VariableAnalysisDictionary[ast].IsGlobalOrEnvironment(varAst);
+ return VariableAnalysis.IsGlobalOrEnvironment(varAst);
}
@@ -952,15 +949,7 @@ internal VariableAnalysis InitializeVariableAnalysisHelper(Ast ast, VariableAnal
///
///
-#if (PSV3||PSV4)
-
- public string GetTypeFromReturnStatementAst(Ast funcAst, ReturnStatementAst ret)
-
-#else
-
public string GetTypeFromReturnStatementAst(Ast funcAst, ReturnStatementAst ret, IEnumerable classes)
-
-#endif
{
if (ret == null || funcAst == null)
{
@@ -991,15 +980,7 @@ public string GetTypeFromReturnStatementAst(Ast funcAst, ReturnStatementAst ret,
}
else if (cmAst.Expression is MemberExpressionAst)
{
-#if PSV3
-
- result = GetTypeFromMemberExpressionAst(cmAst.Expression as MemberExpressionAst, funcAst);
-
-#else
-
result = GetTypeFromMemberExpressionAst(cmAst.Expression as MemberExpressionAst, funcAst, classes);
-
-#endif
}
}
}
@@ -1023,15 +1004,7 @@ public string GetTypeFromReturnStatementAst(Ast funcAst, ReturnStatementAst ret,
///
///
-#if (PSV3||PSV4)
-
- public string GetTypeFromMemberExpressionAst(MemberExpressionAst memberAst, Ast scopeAst)
-
-#else
-
public string GetTypeFromMemberExpressionAst(MemberExpressionAst memberAst, Ast scopeAst, IEnumerable classes)
-
-#endif
{
if (memberAst == null)
{
@@ -1040,38 +1013,22 @@ public string GetTypeFromMemberExpressionAst(MemberExpressionAst memberAst, Ast
VariableAnalysisDetails details = null;
-#if !(PSV3||PSV4)
-
TypeDefinitionAst psClass = null;
-#endif
-
if (memberAst.Expression is VariableExpressionAst && VariableAnalysisDictionary.ContainsKey(scopeAst))
{
VariableAnalysis VarTypeAnalysis = VariableAnalysisDictionary[scopeAst];
// Get the analysis detail for the variable
details = VarTypeAnalysis.GetVariableAnalysis(memberAst.Expression as VariableExpressionAst);
-#if !PSV3
-
if (details != null && classes != null)
{
// Get the class that corresponds to the name of the type (if possible, the type is not available in the case of a static Singleton)
psClass = classes.FirstOrDefault(item => String.Equals(item.Name, details.Type?.FullName, StringComparison.OrdinalIgnoreCase));
}
-
-#endif
}
-#if PSV3
-
- return GetTypeFromMemberExpressionAstHelper(memberAst, details);
-
-#else
-
- return GetTypeFromMemberExpressionAstHelper(memberAst, psClass, details);
-
-#endif
+ return GetTypeFromMemberExpressionAstHelper(memberAst, psClass, details);
}
///
@@ -1083,28 +1040,17 @@ public string GetTypeFromMemberExpressionAst(MemberExpressionAst memberAst, Ast
///
///
-#if (PSV3||PSV4)
-
- internal string GetTypeFromMemberExpressionAstHelper(MemberExpressionAst memberAst, VariableAnalysisDetails analysisDetails)
-
-#else
-
internal string GetTypeFromMemberExpressionAstHelper(MemberExpressionAst memberAst, TypeDefinitionAst psClass, VariableAnalysisDetails analysisDetails)
-
-#endif
{
//Try to get the type without using psClass first
Type result = AssignmentTarget.GetTypeFromMemberExpressionAst(memberAst);
-#if !(PSV3||PSV4)
-
//If we can't get the type, then it may be that the type of the object being invoked on is a powershell class
if (result == null && psClass != null && analysisDetails != null)
{
result = AssignmentTarget.GetTypeFromMemberExpressionAst(memberAst, analysisDetails, psClass);
}
-#endif
if (result != null)
{
@@ -1204,7 +1150,6 @@ public Dictionary> GetRuleSuppression(Ast ast)
ruleSuppressionList.AddRange(GetSuppressionsFunction(funcAst));
}
-#if !(PSV3||PSV4)
// Get rule suppression from classes
IEnumerable typeAsts = ast.FindAll(item => item is TypeDefinitionAst, true).Cast();
@@ -1220,7 +1165,6 @@ public Dictionary> GetRuleSuppression(Ast ast)
{
ruleSuppressionList.AddRange(GetSuppressionsConfiguration(configDefAst));
}
-#endif // !PSV3
ruleSuppressionList.Sort((item, item2) => item.StartOffset.CompareTo(item2.StartOffset));
@@ -1256,7 +1200,6 @@ internal List GetSuppressionsFunction(FunctionDefinitionAst fun
return result;
}
-#if !(PSV3||PSV4)
///
/// Returns a list of rule suppression from the class
///
@@ -1312,8 +1255,6 @@ internal List GetSuppressionsConfiguration(ConfigurationDefinit
return result;
}
-#endif // !PSV3
-
///
/// Suppress the rules from the diagnostic records list.
/// Returns a list of suppressed records as well as the ones that are not suppressed
@@ -2115,15 +2056,8 @@ public object VisitScriptBlock(ScriptBlockAst scriptBlockAst)
// We already run variable analysis if the parent is a function so skip these.
// Otherwise, we have to do variable analysis using the outer scope variables.
-#if PSV3
-
- if (!(scriptBlockAst.Parent is FunctionDefinitionAst))
-
-#else
if (!(scriptBlockAst.Parent is FunctionDefinitionAst) && !(scriptBlockAst.Parent is FunctionMemberAst))
-
-#endif
{
OuterAnalysis = Helper.Instance.InitializeVariableAnalysisHelper(scriptBlockAst, OuterAnalysis);
}
@@ -2151,15 +2085,7 @@ public object VisitScriptBlock(ScriptBlockAst scriptBlockAst)
VariableAnalysis innerAnalysis = OuterAnalysis;
OuterAnalysis = previousOuter;
-#if PSV3
-
- if (!(scriptBlockAst.Parent is FunctionDefinitionAst))
-
-#else
-
if (!(scriptBlockAst.Parent is FunctionDefinitionAst) && !(scriptBlockAst.Parent is FunctionMemberAst))
-
-#endif
{
// Update the variable analysis of the outer script block
VariableAnalysis.UpdateOuterAnalysis(OuterAnalysis, innerAnalysis);
@@ -2180,12 +2106,6 @@ private object VisitStatementHelper(StatementAst statementAst)
return null;
}
-#if (PSV3||PSV4)
-
- statementAst.Visit(this);
-
-#else
-
TypeDefinitionAst typeAst = statementAst as TypeDefinitionAst;
if (typeAst == null)
@@ -2211,14 +2131,9 @@ private object VisitStatementHelper(StatementAst statementAst)
OuterAnalysis = previousOuter;
}
}
-
-#endif
-
return null;
}
-#if !PSV3
-
///
/// Do nothing
///
@@ -2229,8 +2144,6 @@ public object VisitUsingStatement(UsingStatementAst usingStatement)
return null;
}
-#endif
-
///
/// Do nothing
///
@@ -2896,12 +2809,8 @@ public class FindPipelineOutput : ICustomAstVisitor
{
List> outputTypes;
-#if !(PSV3||PSV4)
-
IEnumerable classes;
-#endif
-
FunctionDefinitionAst myFunction;
///
/// These binary operators will always return boolean value
@@ -2938,24 +2847,12 @@ static FindPipelineOutput()
///
///
-#if (PSV3||PSV4)
-
- public FindPipelineOutput(FunctionDefinitionAst ast)
-
-#else
-
public FindPipelineOutput(FunctionDefinitionAst ast, IEnumerable classes)
-
-#endif
{
outputTypes = new List>();
-#if !PSV3
-
this.classes = classes;
-#endif
-
myFunction = ast;
if (myFunction != null)
@@ -2969,21 +2866,11 @@ public FindPipelineOutput(FunctionDefinitionAst ast, IEnumerable
///
-#if (PSV3||PSV4)
-
- public static List> OutputTypes(FunctionDefinitionAst funcAst)
- {
- return (new FindPipelineOutput(funcAst)).outputTypes;
- }
-
-#else
public static List> OutputTypes(FunctionDefinitionAst funcAst, IEnumerable classes)
{
return (new FindPipelineOutput(funcAst, classes)).outputTypes;
}
-#endif
-
///
/// Ignore assignment statement
///
@@ -3435,15 +3322,7 @@ public object VisitCommandExpression(CommandExpressionAst commandAst)
///
public object VisitReturnStatement(ReturnStatementAst returnStatementAst)
{
-#if PSV3
-
- return Helper.Instance.GetTypeFromReturnStatementAst(myFunction, returnStatementAst);
-
-#else
-
return Helper.Instance.GetTypeFromReturnStatementAst(myFunction, returnStatementAst, classes);
-
-#endif
}
///
@@ -3453,15 +3332,7 @@ public object VisitReturnStatement(ReturnStatementAst returnStatementAst)
///
public object VisitMemberExpression(MemberExpressionAst memAst)
{
-#if PSV3
-
- return Helper.Instance.GetTypeFromMemberExpressionAst(memAst, myFunction);
-
-#else
-
return Helper.Instance.GetTypeFromMemberExpressionAst(memAst, myFunction, classes);
-
-#endif
}
///
@@ -3471,15 +3342,7 @@ public object VisitMemberExpression(MemberExpressionAst memAst)
///
public object VisitInvokeMemberExpression(InvokeMemberExpressionAst invokeAst)
{
-#if PSV3
-
- return Helper.Instance.GetTypeFromMemberExpressionAst(invokeAst, myFunction);
-
-#else
-
return Helper.Instance.GetTypeFromMemberExpressionAst(invokeAst, myFunction, classes);
-
-#endif
}
///
diff --git a/Engine/PSScriptAnalyzer.psd1 b/Engine/PSScriptAnalyzer.psd1
index c7289e890..993677254 100644
--- a/Engine/PSScriptAnalyzer.psd1
+++ b/Engine/PSScriptAnalyzer.psd1
@@ -20,13 +20,13 @@ GUID = 'd6245802-193d-4068-a631-8863a4342a18'
CompanyName = 'Microsoft Corporation'
# Copyright statement for this module
-Copyright = '(c) Microsoft Corporation 2016. All rights reserved.'
+Copyright = '(c) Microsoft Corporation 2026. All rights reserved.'
# Description of the functionality provided by this module
Description = 'PSScriptAnalyzer provides script analysis and checks for potential code defects in the scripts by applying a group of built-in or customized rules on the scripts being analyzed.'
# Minimum version of the Windows PowerShell engine required by this module
-PowerShellVersion = '3.0'
+PowerShellVersion = '5.1'
# Name of the Windows PowerShell host required by this module
# PowerShellHostName = ''
diff --git a/Engine/PSScriptAnalyzer.psm1 b/Engine/PSScriptAnalyzer.psm1
index 166e48e61..7e2ca8f31 100644
--- a/Engine/PSScriptAnalyzer.psm1
+++ b/Engine/PSScriptAnalyzer.psm1
@@ -9,7 +9,8 @@ $PSModuleRoot = $PSModule.ModuleBase
# Import the appropriate nested binary module based on the current PowerShell version
$binaryModuleRoot = $PSModuleRoot
-[Version] $minimumPowerShellCoreVersion = '7.2.11'
+# This should be the equivalent of 7.4.x i.e. latest major/minor supported but lowest patch.
+[Version] $minimumPowerShellCoreVersion = '7.4.6'
if ($PSVersionTable.PSVersion.Major -ge 6) {
$binaryModuleRoot = Join-Path -Path $PSModuleRoot -ChildPath "PSv$($PSVersionTable.PSVersion.Major)"
# Minimum PowerShell Core version given by PowerShell Core support itself and
@@ -19,16 +20,10 @@ if ($PSVersionTable.PSVersion.Major -ge 6) {
if ($PSVersionTable.PSVersion -lt $minimumPowerShellCoreVersion) {
throw "Minimum supported version of PSScriptAnalyzer for PowerShell Core is $minimumPowerShellCoreVersion but current version is '$($PSVersionTable.PSVersion)'. Please update PowerShell Core."
}
-}
-elseif ($PSVersionTable.PSVersion.Major -eq 5) {
+} else {
# Without this, PSSA tries to load this from $PSHome
Add-Type -Path "$PSScriptRoot/Newtonsoft.Json.dll"
}
-elseif ($PSVersionTable.PSVersion.Major -le 4) {
- $binaryModuleRoot = Join-Path -Path $PSModuleRoot -ChildPath "PSv$($PSVersionTable.PSVersion.Major)"
- # Without this, PSSA tries to load this from $PSHome
- Add-Type -Path "$binaryModuleRoot/Newtonsoft.Json.dll"
-}
$binaryModulePath = Join-Path -Path $binaryModuleRoot -ChildPath 'Microsoft.Windows.PowerShell.ScriptAnalyzer.dll'
$binaryModule = Import-Module -Name $binaryModulePath -PassThru
diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs
index 1a885eabe..46e267fc6 100644
--- a/Engine/ScriptAnalyzer.cs
+++ b/Engine/ScriptAnalyzer.cs
@@ -50,9 +50,7 @@ public sealed class ScriptAnalyzer
List includeRegexList;
List excludeRegexList;
private SuppressionPreference _suppressionPreference;
-#if !PSV3
ModuleDependencyHandler moduleHandler;
-#endif
#endregion
#region Singleton
@@ -98,7 +96,6 @@ public static ScriptAnalyzer Instance
internal List ExternalRules { get; set; }
-#if !PSV3
public ModuleDependencyHandler ModuleHandler {
get
{
@@ -110,7 +107,6 @@ internal set
moduleHandler = value;
}
}
-#endif
#endregion
@@ -271,9 +267,9 @@ internal bool ParseProfile(object profileObject, PathIntrinsics path, IOutputWri
return false;
}
- this.severity = (severityList.Count() == 0) ? null : severityList.ToArray();
- this.includeRule = (includeRuleList.Count() == 0) ? null : includeRuleList.ToArray();
- this.excludeRule = (excludeRuleList.Count() == 0) ? null : excludeRuleList.ToArray();
+ this.severity = (severityList.Count == 0) ? null : severityList.ToArray();
+ this.includeRule = (includeRuleList.Count == 0) ? null : includeRuleList.ToArray();
+ this.excludeRule = (excludeRuleList.Count == 0) ? null : excludeRuleList.ToArray();
if (settings != null
&& settings.ContainsKey("Rules"))
{
@@ -613,7 +609,7 @@ private bool ParseProfileString(string profile, PathIntrinsics path, IOutputWrit
IEnumerable hashTableAsts = profileAst.FindAll(item => item is HashtableAst, false);
// no hashtable, raise warning
- if (hashTableAsts.Count() == 0)
+ if (!hashTableAsts.Any())
{
writer.WriteError(new ErrorRecord(new ArgumentException(string.Format(CultureInfo.CurrentCulture, Strings.InvalidProfile, profile)),
Strings.ConfigurationFileHasNoHashTable, ErrorCategory.ResourceUnavailable, profile));
@@ -622,36 +618,6 @@ private bool ParseProfileString(string profile, PathIntrinsics path, IOutputWrit
else
{
HashtableAst hashTableAst = hashTableAsts.First() as HashtableAst;
-#if PSV3
- settings = GetDictionaryFromHashTableAst(
- hashTableAst,
- writer,
- profile,
- out hasError);
- foreach (var key in settings.Keys)
- {
- var rhsList = settings[key] as List;
- if (rhsList == null)
- {
- continue;
- }
- if (!AddProfileItem(key, rhsList, severityList, includeRuleList, excludeRuleList))
- {
- writer.WriteError(
- new ErrorRecord(
- new InvalidDataException(
- string.Format(
- CultureInfo.CurrentCulture,
- Strings.WrongKey,
- key,
- profile)),
- Strings.WrongConfigurationKey,
- ErrorCategory.InvalidData,
- profile));
- hasError = true;
- }
- }
-#else
try
{
@@ -668,7 +634,6 @@ private bool ParseProfileString(string profile, PathIntrinsics path, IOutputWrit
profile));
hasError = true;
}
-#endif // PSV3
}
}
@@ -691,10 +656,7 @@ private void Initialize(
{
throw new ArgumentNullException("outputWriter");
}
-#if !PSV3
this.moduleHandler = null;
-#endif
-
this.outputWriter = outputWriter;
#region Verifies rule extensions
@@ -822,13 +784,13 @@ private void Initialize(
// Ensure that rules were actually loaded
if (rules == null || rules.Any() == false)
{
+ string errorMessage = string.Format(CultureInfo.CurrentCulture, Strings.RulesNotFound);
+
this.outputWriter.ThrowTerminatingError(
new ErrorRecord(
- new Exception(),
- string.Format(
- CultureInfo.CurrentCulture,
- Strings.RulesNotFound),
- ErrorCategory.ResourceExists,
+ new Exception(errorMessage),
+ errorMessage,
+ ErrorCategory.ObjectNotFound,
this));
}
@@ -1488,7 +1450,7 @@ public IEnumerable AnalyzeAndFixPath(string path, FuncParsed tokens of
/// Whether variable analysis can be skipped (applicable if rules do not use variable analysis APIs).
///
- public IEnumerable AnalyzeScriptDefinition(string scriptDefinition, out ScriptBlockAst scriptAst, out Token[] scriptTokens, bool skipVariableAnalysis = false)
+ public List AnalyzeScriptDefinition(string scriptDefinition, out ScriptBlockAst scriptAst, out Token[] scriptTokens, bool skipVariableAnalysis = false)
{
scriptAst = null;
scriptTokens = null;
@@ -1503,7 +1465,7 @@ public IEnumerable AnalyzeScriptDefinition(string scriptDefini
catch (Exception e)
{
this.outputWriter.WriteWarning(e.ToString());
- return null;
+ return new();
}
var relevantParseErrors = RemoveTypeNotFoundParseErrors(errors, out List diagnosticRecords);
@@ -1528,7 +1490,8 @@ public IEnumerable AnalyzeScriptDefinition(string scriptDefini
}
// now, analyze the script definition
- return diagnosticRecords.Concat(this.AnalyzeSyntaxTree(scriptAst, scriptTokens, String.Empty, skipVariableAnalysis));
+ diagnosticRecords.AddRange(this.AnalyzeSyntaxTree(scriptAst, scriptTokens, null, skipVariableAnalysis));
+ return diagnosticRecords;
}
///
@@ -1842,7 +1805,6 @@ private void BuildScriptPathList(
}
}
-#if !PSV3
private bool TrySaveModules(ParseError[] errors, ScriptBlockAst scriptAst)
{
bool modulesSaved = false;
@@ -1876,7 +1838,6 @@ private bool TrySaveModules(ParseError[] errors, ScriptBlockAst scriptAst)
}
return modulesSaved;
}
-#endif // !PSV3
private IEnumerable AnalyzeFile(string filePath)
{
@@ -1914,13 +1875,13 @@ private IEnumerable AnalyzeFile(string filePath)
this.outputWriter.WriteWarning(e.ToString());
return null;
}
-#if !PSV3
+
//try parsing again
if (TrySaveModules(errors, scriptAst))
{
scriptAst = Parser.ParseFile(filePath, out scriptTokens, out errors);
}
-#endif //!PSV3
+
IEnumerable relevantParseErrors = RemoveTypeNotFoundParseErrors(errors, out diagnosticRecords);
// First, add all parse errors if they've been requested
@@ -2253,17 +2214,8 @@ public IEnumerable AnalyzeSyntaxTree(
// We want the Engine to continue functioning even if one or more Rules throws an exception
try
{
-#if PSV3
- var errRecs = new List();
- var records = Helper.Instance.SuppressRule(
- dscResourceRule.GetName(),
- ruleSuppressions,
- null,
- out errRecs);
-#else
var ruleRecords = dscResourceRule.AnalyzeDSCClass(scriptAst, filePath).ToList();
var records = SuppressRule(dscResourceRule.GetName(), ruleSuppressions, ruleRecords);
-#endif
foreach (var record in records.Item2)
{
diagnostics.Add(record);
diff --git a/Engine/Settings.cs b/Engine/Settings.cs
index a4931978c..0d37a3a78 100644
--- a/Engine/Settings.cs
+++ b/Engine/Settings.cs
@@ -453,7 +453,7 @@ private void parseSettingsFile(string settingsFilePath)
IEnumerable hashTableAsts = profileAst.FindAll(item => item is HashtableAst, false);
// no hashtable, raise warning
- if (hashTableAsts.Count() == 0)
+ if (!hashTableAsts.Any())
{
throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Strings.InvalidProfile, settingsFilePath));
}
@@ -462,9 +462,7 @@ private void parseSettingsFile(string settingsFilePath)
Hashtable hashtable;
try
{
- // ideally we should use HashtableAst.SafeGetValue() but since
- // it is not available on PSv3, we resort to our own narrow implementation.
- hashtable = Helper.GetSafeValueFromHashtableAst(hashTableAst);
+ hashtable = (Hashtable) hashTableAst.SafeGetValue();
}
catch (InvalidOperationException e)
{
@@ -497,6 +495,13 @@ private static bool IsBuiltinSettingPreset(object settingPreset)
internal static SettingsMode FindSettingsMode(object settings, string path, out object settingsFound)
{
var settingsMode = SettingsMode.None;
+
+ // if the provided settings argument is wrapped in an expressions then PowerShell resolves it but it will be of type PSObject and we have to operate then on the BaseObject
+ if (settings is PSObject settingsFoundPSObject)
+ {
+ settings = settingsFoundPSObject.BaseObject;
+ }
+
settingsFound = settings;
if (settingsFound == null)
{
@@ -532,11 +537,6 @@ internal static SettingsMode FindSettingsMode(object settings, string path, out
{
settingsMode = SettingsMode.Hashtable;
}
- // if the provided argument is wrapped in an expressions then PowerShell resolves it but it will be of type PSObject and we have to operate then on the BaseObject
- else if (settingsFound is PSObject settingsFoundPSObject)
- {
- TryResolveSettingForStringType(settingsFoundPSObject.BaseObject, ref settingsMode, ref settingsFound);
- }
}
}
diff --git a/Engine/SpecialVars.cs b/Engine/SpecialVars.cs
index a8be18b92..3416a5ad2 100644
--- a/Engine/SpecialVars.cs
+++ b/Engine/SpecialVars.cs
@@ -160,6 +160,7 @@ internal enum PreferenceVariable
internal const string PSEmailServer = "PSEmailServer";
internal const string PSDefaultParameterValues = "PSDefaultParameterValues";
internal const string PSModuleAutoLoadingPreference = "PSModuleAutoLoadingPreference";
+ internal const string PSNativeCommandArgumentPassing = "PSNativeCommandArgumentPassing";
internal const string pwd = "PWD";
internal const string Null = "null";
internal const string True = "true";
@@ -182,6 +183,7 @@ internal enum PreferenceVariable
PSEmailServer,
PSDefaultParameterValues,
PSModuleAutoLoadingPreference,
+ PSNativeCommandArgumentPassing,
pwd,
Null,
True,
diff --git a/Engine/TokenOperations.cs b/Engine/TokenOperations.cs
index fa9a1978a..4845ab8c4 100644
--- a/Engine/TokenOperations.cs
+++ b/Engine/TokenOperations.cs
@@ -245,5 +245,165 @@ public Ast GetAstPosition(Token token)
return findAstVisitor.AstPosition;
}
+ ///
+ /// Returns a list of non-overlapping ranges (startOffset,endOffset) representing the start
+ /// and end of braced member access expressions. These are member accesses where the name is
+ /// enclosed in braces. The contents of such braces are treated literally as a member name.
+ /// Altering the contents of these braces by formatting is likely to break code.
+ ///
+ public List> GetBracedMemberAccessRanges()
+ {
+ // A list of (startOffset, endOffset) pairs representing the start
+ // and end braces of braced member access expressions.
+ var ranges = new List>();
+
+ var node = tokensLL.Value.First;
+ while (node != null)
+ {
+ switch (node.Value.Kind)
+ {
+#if CORECLR
+ // TokenKind added in PS7
+ case TokenKind.QuestionDot:
+#endif
+ case TokenKind.Dot:
+ break;
+ default:
+ node = node.Next;
+ continue;
+ }
+
+ // Note: We don't check if the dot is part of an existing range. When we find
+ // a valid range, we skip all tokens inside it - so we won't ever evaluate a token
+ // which already part of a previously found range.
+
+ // Backward scan:
+ // Determine if this 'dot' is part of a member access.
+ // Walk left over contiguous comment tokens that are 'touching'.
+ // After skipping comments, the preceding non-comment token must also be 'touching'
+ // and one of the expected TokenKinds.
+ var leftToken = node.Previous;
+ var rightToken = node;
+ while (leftToken != null && leftToken.Value.Kind == TokenKind.Comment)
+ {
+ if (leftToken.Value.Extent.EndOffset != rightToken.Value.Extent.StartOffset)
+ {
+ leftToken = null;
+ break;
+ }
+ rightToken = leftToken;
+ leftToken = leftToken.Previous;
+ }
+ if (leftToken == null)
+ {
+ // We ran out of tokens before finding a non-comment token to the left or there
+ // was intervening whitespace.
+ node = node.Next;
+ continue;
+ }
+
+ if (leftToken.Value.Extent.EndOffset != rightToken.Value.Extent.StartOffset)
+ {
+ // There's whitespace between the two tokens
+ node = node.Next;
+ continue;
+ }
+
+ // Limit to valid token kinds that can precede a 'dot' in a member access.
+ switch (leftToken.Value.Kind)
+ {
+ // Note: TokenKind.Number isn't in the list as 5.{Prop} is a syntax error
+ // (Unexpected token). Numbers also have no properties - only methods.
+ case TokenKind.Variable:
+ case TokenKind.Identifier:
+ case TokenKind.StringLiteral:
+ case TokenKind.StringExpandable:
+ case TokenKind.HereStringLiteral:
+ case TokenKind.HereStringExpandable:
+ case TokenKind.RParen:
+ case TokenKind.RCurly:
+ case TokenKind.RBracket:
+ // allowed
+ break;
+ default:
+ // not allowed
+ node = node.Next;
+ continue;
+ }
+
+ // Forward Scan:
+ // Check that the next significant token is an LCurly
+ // Starting from the token after the 'dot', walk right skipping trivia tokens:
+ // - Comment
+ // - NewLine
+ // - LineContinuation (`)
+ // These may be multi-line and need not be 'touching' the dot.
+ // The first non-trivia token encountered must be an opening curly brace (LCurly) for
+ // this dot to begin a braced member access. If it is not LCurly or we run out
+ // of tokens, this dot is ignored.
+ var scan = node.Next;
+ while (scan != null)
+ {
+ if (
+ scan.Value.Kind == TokenKind.Comment ||
+ scan.Value.Kind == TokenKind.NewLine ||
+ scan.Value.Kind == TokenKind.LineContinuation
+ )
+ {
+ scan = scan.Next;
+ continue;
+ }
+ break;
+ }
+
+ // If we reached the end without finding a significant token, or if the found token
+ // is not LCurly, continue.
+ if (scan == null || scan.Value.Kind != TokenKind.LCurly)
+ {
+ node = node.Next;
+ continue;
+ }
+
+ // We have a valid token, followed by a dot, followed by an LCurly.
+ // Find the matching RCurly and create the range.
+ var lCurlyNode = scan;
+
+ // Depth count braces to find the RCurly which closes the LCurly.
+ int depth = 0;
+ LinkedListNode rcurlyNode = null;
+ while (scan != null)
+ {
+ if (scan.Value.Kind == TokenKind.LCurly) depth++;
+ else if (scan.Value.Kind == TokenKind.RCurly)
+ {
+ depth--;
+ if (depth == 0)
+ {
+ rcurlyNode = scan;
+ break;
+ }
+ }
+ scan = scan.Next;
+ }
+
+ // If we didn't find a matching RCurly, something has gone wrong.
+ // Should an unmatched pair be caught by the parser as a parse error?
+ if (rcurlyNode == null)
+ {
+ node = node.Next;
+ continue;
+ }
+
+ ranges.Add(new Tuple(
+ lCurlyNode.Value.Extent.StartOffset,
+ rcurlyNode.Value.Extent.EndOffset
+ ));
+
+ // Skip all tokens inside the excluded range.
+ node = rcurlyNode.Next;
+ }
+
+ return ranges;
+ }
}
}
diff --git a/Engine/VariableAnalysis.cs b/Engine/VariableAnalysis.cs
index fd66ea2c4..2bb8068d4 100644
--- a/Engine/VariableAnalysis.cs
+++ b/Engine/VariableAnalysis.cs
@@ -134,15 +134,7 @@ private void ProcessParameters(IEnumerable parameters)
public void AnalyzeImpl(Ast ast, VariableAnalysis outerAnalysis)
{
- #if PSV3
-
- if (!(ast is ScriptBlockAst || ast is FunctionDefinitionAst))
-
- #else
-
if (!(ast is ScriptBlockAst || ast is FunctionMemberAst || ast is FunctionDefinitionAst))
-
- #endif
{
return;
}
@@ -151,15 +143,7 @@ public void AnalyzeImpl(Ast ast, VariableAnalysis outerAnalysis)
Init();
- #if PSV3
-
- if (ast is FunctionDefinitionAst)
-
- #else
-
if (ast is FunctionMemberAst || ast is FunctionDefinitionAst)
-
- #endif
{
IEnumerable parameters = FindParameters(ast, ast.GetType());
if (parameters != null)
@@ -176,20 +160,11 @@ public void AnalyzeImpl(Ast ast, VariableAnalysis outerAnalysis)
}
}
- #if PSV3
-
- if (ast is FunctionDefinitionAst)
-
- #else
-
if (ast is FunctionMemberAst)
{
(ast as FunctionMemberAst).Body.Visit(this.Decorator);
}
else if (ast is FunctionDefinitionAst)
-
- #endif
-
{
(ast as FunctionDefinitionAst).Body.Visit(this.Decorator);
}
@@ -205,14 +180,10 @@ public void AnalyzeImpl(Ast ast, VariableAnalysis outerAnalysis)
parent = parent.Parent;
}
- #if !(PSV3||PSV4)
-
List classes = parent.FindAll(item =>
item is TypeDefinitionAst && (item as TypeDefinitionAst).IsClass, true)
.Cast().ToList();
- #endif
-
if (outerAnalysis != null)
{
// Initialize the variables from outside
@@ -250,15 +221,8 @@ public void AnalyzeImpl(Ast ast, VariableAnalysis outerAnalysis)
}
}
- #if PSV3
-
- var dictionaries = Block.SparseSimpleConstants(_variables, Entry);
-
- #else
-
var dictionaries = Block.SparseSimpleConstants(_variables, Entry, classes);
- #endif
VariablesDictionary = dictionaries.Item1;
InternalVariablesDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -375,7 +339,7 @@ public bool IsUninitialized(VariableExpressionAst varTarget)
///
///
///
- public bool IsGlobalOrEnvironment(VariableExpressionAst varTarget)
+ public static bool IsGlobalOrEnvironment(VariableExpressionAst varTarget)
{
if (varTarget != null)
{
diff --git a/Engine/VariableAnalysisBase.cs b/Engine/VariableAnalysisBase.cs
index 77c421f3c..218bdc8ea 100644
--- a/Engine/VariableAnalysisBase.cs
+++ b/Engine/VariableAnalysisBase.cs
@@ -97,16 +97,7 @@ public class FindAllVariablesVisitor : AstVisitor
///
public static Dictionary Visit(Ast ast)
{
- #if PSV3
-
- if (!(ast is ScriptBlockAst || ast is FunctionDefinitionAst))
-
- #else
-
if (!(ast is ScriptBlockAst || ast is FunctionMemberAst || ast is FunctionDefinitionAst))
-
- #endif
-
{
return null;
}
@@ -122,34 +113,20 @@ public static Dictionary Visit(Ast ast)
{
(ast as ScriptBlockAst).Visit(visitor);
}
-
- #if !PSV3
-
else if (ast is FunctionMemberAst)
{
(ast as FunctionMemberAst).Body.Visit(visitor);
}
-
- #endif
-
else if (ast is FunctionDefinitionAst)
{
(ast as FunctionDefinitionAst).Body.Visit(visitor);
}
- #if PSV3
-
- if (ast is FunctionDefinitionAst && (ast as FunctionDefinitionAst).Parameters != null)
-
- #else
-
if (ast is FunctionMemberAst && (ast as FunctionMemberAst).Parameters != null)
{
visitor.VisitParameters((ast as FunctionMemberAst).Parameters);
}
else if (ast is FunctionDefinitionAst && (ast as FunctionDefinitionAst).Parameters != null)
-
- #endif
{
visitor.VisitParameters((ast as FunctionDefinitionAst).Parameters);
}
@@ -165,8 +142,6 @@ internal void InitializeVariables(Ast ast)
_variables.Add("true", new VariableAnalysisDetails { Name = "true", RealName = "true", Type = typeof(bool) });
_variables.Add("false", new VariableAnalysisDetails { Name = "false", RealName = "true", Type = typeof(bool) });
- #if !(PSV3||PSV4)
-
if (ast is FunctionMemberAst)
{
TypeDefinitionAst psClass = AssignmentTarget.FindClassAncestor(ast);
@@ -175,9 +150,6 @@ internal void InitializeVariables(Ast ast)
_variables.Add("this", new VariableAnalysisDetails { Name = "this", RealName = "this", Constant = SpecialVars.ThisVariable });
}
}
-
- #endif
-
}
internal void VisitParameters(ReadOnlyCollection parameters)
@@ -808,16 +780,8 @@ internal static void InitializeSSA(Dictionary V
///
///
///
- #if (PSV3||PSV4)
-
internal static Tuple, Dictionary> SparseSimpleConstants(
- Dictionary Variables, Block Entry)
-
- #else
- internal static Tuple, Dictionary> SparseSimpleConstants(
- Dictionary Variables, Block Entry, List Classes)
-
- #endif
+ Dictionary Variables, Block Entry, List Classes)
{
List blocks = GenerateReverseDepthFirstOrder(Entry);
@@ -989,17 +953,9 @@ internal static Tuple, Dictionary String.Equals(item.Name, analysis.Type?.FullName, StringComparison.OrdinalIgnoreCase));
Type possibleType = AssignmentTarget.GetTypeFromMemberExpressionAst(memAst, analysis, psClass);
- #endif
-
if (possibleType != null && possibleType != assigned.Type)
{
assigned.Type = possibleType;
@@ -1370,24 +1326,13 @@ public AssignmentTarget(string variableName, Type type)
///
///
///
-
- #if (PSV3||PSV4)
-
- internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memAst, VariableAnalysisDetails analysis)
-
- #else
-
internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memAst, VariableAnalysisDetails analysis, TypeDefinitionAst psClass)
-
- #endif
{
if (memAst != null && memAst.Expression is VariableExpressionAst && memAst.Member is StringConstantExpressionAst
&& !String.Equals((memAst.Expression as VariableExpressionAst).VariablePath.UserPath, "this", StringComparison.OrdinalIgnoreCase))
{
string fieldName = (memAst.Member as StringConstantExpressionAst).Value;
- #if !PSV3
-
if (psClass == null && analysis.Constant == SpecialVars.ThisVariable)
{
psClass = AssignmentTarget.FindClassAncestor(memAst);
@@ -1404,8 +1349,6 @@ internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memAst,
}
}
- #endif
-
// If the type is not a ps class or there are some types of the same name.
if (analysis != null && analysis.Type != null && analysis.Type != typeof(object)
&& analysis.Type != typeof(Unreached) && analysis.Type != typeof(Undetermined))
@@ -1460,7 +1403,6 @@ internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memberAs
// isStatic is true
result = GetTypeFromInvokeMemberAst(type, imeAst, methodName, true);
}
- #if !(PSV3||PSV4)
else
{
// Check for classes
@@ -1478,7 +1420,6 @@ internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memberAs
}
}
}
- #endif
}
#endregion
@@ -1498,7 +1439,6 @@ internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memberAs
{
result = GetPropertyOrFieldTypeFromMemberExpressionAst(expressionType, fieldName);
}
- #if !(PSV3||PSV4)
else
{
// check for class type
@@ -1514,7 +1454,6 @@ internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memberAs
}
}
}
- #endif
}
#endregion
@@ -1531,15 +1470,11 @@ internal static Type GetTypeFromMemberExpressionAst(MemberExpressionAst memberAs
if (memberAst.Expression is VariableExpressionAst
&& String.Equals((memberAst.Expression as VariableExpressionAst).VariablePath.UserPath, "this", StringComparison.OrdinalIgnoreCase))
{
- #if !(PSV3||PSV4)
-
// Check that we are in a class
TypeDefinitionAst psClass = FindClassAncestor(memberAst);
// Is static is false for this case
result = GetTypeFromClass(psClass, memberAst);
-
- #endif
}
return result;
@@ -1598,7 +1533,6 @@ internal static Type GetPropertyOrFieldTypeFromMemberExpressionAst(Type type, st
return result;
}
-#if !(PSV3||PSV4)
///
/// Checks whether a class with the name name exists in the script that contains ast
///
@@ -1686,8 +1620,6 @@ internal static Type GetTypeFromClass(TypeDefinitionAst psClass, MemberExpressio
return result;
}
-#endif // !PSV3
-
private void SetVariableName()
{
ExpressionAst lhs = (_targetAst is ConvertExpressionAst) ? (_targetAst as ConvertExpressionAst).Child : _targetAst;
diff --git a/PSCompatibilityCollector/Microsoft.PowerShell.CrossCompatibility/Microsoft.PowerShell.CrossCompatibility.csproj b/PSCompatibilityCollector/Microsoft.PowerShell.CrossCompatibility/Microsoft.PowerShell.CrossCompatibility.csproj
index da987fb69..c4667a950 100644
--- a/PSCompatibilityCollector/Microsoft.PowerShell.CrossCompatibility/Microsoft.PowerShell.CrossCompatibility.csproj
+++ b/PSCompatibilityCollector/Microsoft.PowerShell.CrossCompatibility/Microsoft.PowerShell.CrossCompatibility.csproj
@@ -19,7 +19,7 @@
-
+
diff --git a/README.md b/README.md
index 716224c7c..24f2704ff 100644
--- a/README.md
+++ b/README.md
@@ -66,18 +66,22 @@ documentation for the latest release is published on on learn.microsoft.com.
## Installation
To install **PSScriptAnalyzer** from the PowerShell Gallery, see
-[Installing PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview#installing-psscriptanalyzer).
+[Installing PSScriptAnalyzer](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/overview#installing-psscriptanalyzer) or simply open PowerShell in a Terminal and run:
+
+```powershell
+Install-Module -Name PSScriptAnalyzer
+```
To install **PSScriptAnalyzer** from source code:
### Requirements
-- [Latest .NET 6.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/6.0)
-* If building for Windows PowerShell versions, then the .NET Framework 4.6.2 [targeting pack](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net462) (also referred to as developer/targeting pack) need to be installed. This is only possible on Windows.
-* Optionally but recommended for development: [Visual Studio 2017/2019](https://www.visualstudio.com/downloads)
+- [Latest .NET 8.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
+- If building for Windows PowerShell versions, then the .NET Framework 4.6.2 [targeting pack](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net462) (also referred to as developer/targeting pack) need to be installed. This is only possible on Windows.
+- Optional but recommended for development: [Visual Studio 2022](https://www.visualstudio.com/downloads)
+- Or [Visual Studio Code](https://code.visualstudio.com/download)
- [Pester v5 PowerShell module, available on PowerShell Gallery](https://github.com/pester/Pester)
- [PlatyPS PowerShell module, available on PowerShell Gallery](https://github.com/PowerShell/platyPS/releases)
-- Optionally but recommended for development: [Visual Studio](https://www.visualstudio.com/downloads)
### Steps
@@ -110,18 +114,6 @@ To install **PSScriptAnalyzer** from source code:
.\build.ps1 -PSVersion 5
```
- - Windows PowerShell version 4.0
-
- ```powershell
- .\build.ps1 -PSVersion 4
- ```
-
- - Windows PowerShell version 3.0
-
- ```powershell
- .\build.ps1 -PSVersion 3
- ```
-
- PowerShell 7
```powershell
@@ -134,7 +126,7 @@ To install **PSScriptAnalyzer** from source code:
.\build.ps1 -Documentation
```
-- Build all versions (PowerShell v3, v4, v5, and v6) and documentation
+- Build all versions (PowerShell v5 and v7) and documentation
```powershell
.\build.ps1 -All
diff --git a/Rules/AlignAssignmentStatement.cs b/Rules/AlignAssignmentStatement.cs
index d8b1623d6..5b941924a 100644
--- a/Rules/AlignAssignmentStatement.cs
+++ b/Rules/AlignAssignmentStatement.cs
@@ -21,34 +21,45 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
#endif
public class AlignAssignmentStatement : ConfigurableRule
{
- // We keep this switch even though the rule has only one switch (this) as of now, because we want
- // to let the rule be expandable in the future to allow formatting assignments even
- // in variable assignments. But for now we will stick to only one option.
+
///
- /// Check if key value pairs in a hashtable are aligned or not.
+ /// Check the key value pairs of a hashtable, including DSC configurations.
///
- ///
[ConfigurableRuleProperty(defaultValue: true)]
public bool CheckHashtable { get; set; }
- private readonly char whitespaceChar = ' ';
+ ///
+ /// Whether to include hashtable key-value pairs where there is a comment
+ /// between the key and the equals sign in alignment.
+ ///
+ [ConfigurableRuleProperty(defaultValue: true)]
+ public bool AlignHashtableKvpWithInterveningComment { get; set; }
- private List>> violationFinders
- = new List>>();
+ ///
+ /// Check the members of an enum.
+ ///
+ [ConfigurableRuleProperty(defaultValue: true)]
+ public bool CheckEnums { get; set; }
///
- /// Sets the configurable properties of this rule.
+ /// Include enum members without explicit values in the width calculation.
///
- /// A dictionary that maps parameter name to it value. Must be non-null
- public override void ConfigureRule(IDictionary paramValueMap)
- {
- base.ConfigureRule(paramValueMap);
- if (CheckHashtable)
- {
- violationFinders.Add(FindHashtableViolations);
- }
- }
+ [ConfigurableRuleProperty(defaultValue: true)]
+ public bool IncludeValuelessEnumMembers { get; set; }
+
+ ///
+ /// Whether to include enum members where there is a comment
+ /// between the name and the equals sign in alignment.
+ ///
+ [ConfigurableRuleProperty(defaultValue: true)]
+ public bool AlignEnumMemberWithInterveningComment { get; set; }
+ ///
+ /// A mapping of line numbers to the indices of assignment operator
+ /// tokens on those lines.
+ ///
+ private readonly Dictionary> assignmentOperatorIndicesByLine =
+ new Dictionary>();
///
/// Analyzes the given ast to find if consecutive assignment statements are aligned.
@@ -60,274 +71,640 @@ public override IEnumerable AnalyzeScript(Ast ast, string file
{
if (ast == null)
{
- throw new ArgumentNullException("ast");
+ throw new ArgumentNullException(nameof(ast));
}
- // only handles one line assignments
- // if the rule encounters assignment statements that are multi-line, the rule will ignore that block
- var tokenOps = new TokenOperations(Helper.Instance.Tokens, ast);
- foreach (var violationFinder in violationFinders)
+ // The high-level approach of the rule is to find all of the
+ // Key-Value pairs in a hashtable, or the members of an enum.
+ // For all of these assignments, we want to locate where both the
+ // left-hand-side (LHS) ends and where the equals sign is.
+ // Looking at all of these assignments for a particular structure,
+ // we can then decide where the equals sign _should_ be. It should
+ // be in the column after the longest LHS.
+ //
+ // Looking at where it _is_ vs where it _should_ be, we can then
+ // generate diagnostics and corrections.
+
+ // As an optimisation, we first build a dictionary of all of the
+ // assignment operators in the script, keyed by line number. We do
+ // this by doing a single scan of the tokens. This makes it trvially
+ // fast to find the `Equals` token for a given assignment.
+
+ // Note: In instances where there is a parse error, we do not have
+ // access to the tokens, so we can't build this dictionary.
+ // This is relevant for the DSC configuration parsing.
+ LocateAssignmentOperators();
+
+ if (CheckHashtable)
{
- foreach (var diagnosticRecord in violationFinder(tokenOps))
+ // Find all hashtables
+ var hashtableAsts = ast.FindAll(
+ a => a is HashtableAst, true
+ ).Cast();
+ foreach (var hashtableAst in hashtableAsts)
{
- yield return diagnosticRecord;
+ // For each hashtable find all assignment sites that meet
+ // our criteria for alignment checking
+ var hashtableAssignmentSites = ParseHashtable(hashtableAst);
+
+ // Check alignment of the assignment sites and emit a
+ // diagnostic for each misalignment found.
+ foreach (var diag in CheckAlignment(hashtableAssignmentSites))
+ {
+ yield return diag;
+ }
+ }
+
+ // DSC does design time checking of available resource nodes.
+ // If a resource is not available at design time, the parser
+ // will error. A DSC Resource definition for a resource which is
+ // not found will not successfully be parsed and appear in the
+ // AST as a hashtable. The below is a best-effort attempt to
+ // find these assignment statements and consistently align them.
+
+ // Find all ConfigurationDefinitionAsts
+ var dscConfigDefAsts = ast.FindAll(
+ a => a is ConfigurationDefinitionAst, true
+ ).Cast();
+ foreach (var dscConfigDefAst in dscConfigDefAsts)
+ {
+ // Within each ConfigurationDefinitionAst, there can be many
+ // nested NamedBlocks, each of which can contain many nested
+ // CommandAsts. The CommandAsts which have 3 command
+ // elements, with the middle one being an equals sign, are
+ // the ones we're interested in. `ParseDscConfigDef` will
+ // emit parsed lists of these CommandAsts that share the
+ // same parent (and so should be aligned with one another).
+ foreach (var group in ParseDscConfigDef(dscConfigDefAst, ast))
+ {
+ // Check alignment of the assignment sites and emit a
+ // diagnostic for each misalignment found.
+ foreach (var diag in CheckAlignment(group))
+ {
+ yield return diag;
+ }
+ }
}
}
- }
- ///
- /// Retrieves the common name of this rule.
- ///
- public override string GetCommonName()
- {
- return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementCommonName);
- }
+ if (CheckEnums)
+ {
+ // Find all enum TypeDefinitionAsts
+ var EnumTypeDefAsts = ast.FindAll(
+ a => a is TypeDefinitionAst t && t.IsEnum, true
+ ).Cast();
+ foreach (var enumTypeDefAst in EnumTypeDefAsts)
+ {
+ // For each enum TypeDef find all assignment sites that meet
+ // our criteria for alignment checking
+ var enumAssignmentSites = ParseEnums(enumTypeDefAst);
- ///
- /// Retrieves the description of this rule.
- ///
- public override string GetDescription()
- {
- return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementDescription);
+ // Check alignment of the assignment sites and emit a
+ // diagnostic for each misalignment found.
+ foreach (var diag in CheckAlignment(enumAssignmentSites))
+ {
+ yield return diag;
+ }
+ }
+ }
}
///
- /// Retrieves the name of this rule.
+ /// Locate all the assignment tokens in the script and store their
+ /// indices in the assignmentOperatorIndicesByLine dictionary.
///
- public override string GetName()
+ private void LocateAssignmentOperators()
{
- return string.Format(
- CultureInfo.CurrentCulture,
- Strings.NameSpaceFormat,
- GetSourceName(),
- Strings.AlignAssignmentStatementName);
- }
+ // Clear any existing entries
+ assignmentOperatorIndicesByLine.Clear();
- ///
- /// Retrieves the severity of the rule: error, warning or information.
- ///
- public override RuleSeverity GetSeverity()
- {
- return RuleSeverity.Warning;
+ var tokens = Helper.Instance.Tokens;
+ // Iterate through all tokens, looking for Equals tokens
+ for (int i = 0; i < tokens.Length; i++)
+ {
+ if (tokens[i].Kind == TokenKind.Equals)
+ {
+ // When an equals token is found, check if the dictionary
+ // has an entry for this line number, and if not create one.
+ int lineNumber = tokens[i].Extent.StartLineNumber;
+ if (!assignmentOperatorIndicesByLine.ContainsKey(lineNumber))
+ {
+ assignmentOperatorIndicesByLine[lineNumber] = new List();
+ }
+ // Add the index of this token to the list for this line
+ assignmentOperatorIndicesByLine[lineNumber].Add(i);
+ }
+ }
}
///
- /// Gets the severity of the returned diagnostic record: error, warning, or information.
+ /// Parse a hashtable's key-value pairs into a list of tuples which are
+ /// later used to verify and correct alignment of assignment operators.
///
- ///
- public DiagnosticSeverity GetDiagnosticSeverity()
+ /// The hashtable AST to parse.
+ ///
+ /// A list of tuples, where each tuple is a (lhsTokenExtent, equalsExtent)
+ /// pair representing the extent of the token immediately before the '='
+ /// (effectively the key/rightmost key token) and the extent of the '=' itself.
+ /// Only includes pairs where an '=' token is found on the same line as the key.
+ /// Implicitly skips line continuations.
+ ///
+ private List> ParseHashtable(HashtableAst hashtableAst)
{
- return DiagnosticSeverity.Warning;
- }
+ var assignmentSites = new List>();
- ///
- /// Retrieves the name of the module/assembly the rule is from.
- ///
- public override string GetSourceName()
- {
- return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
+ if (hashtableAst == null) { return assignmentSites; }
+
+ // Enumerate the KeyValuePairs of this hashtable
+ // Each KVP is a Tuple
+ foreach (var kvp in hashtableAst.KeyValuePairs)
+ {
+ // If the assignmentOperator dictionary has no entry for the
+ // line that the key ends on, skip this KVP
+ if (!assignmentOperatorIndicesByLine.ContainsKey(kvp.Item1.Extent.EndLineNumber))
+ {
+ continue;
+ }
+
+ // Next we need to find the location of the equals sign for this
+ // Key-Value pair. We know the line it should be on. We can
+ // search all of the equals signs on that line for the one that
+ // lives between the end of the key and the start of the value.
+
+ int equalsTokenIndex = -1;
+ foreach (var index in assignmentOperatorIndicesByLine[kvp.Item1.Extent.EndLineNumber])
+ {
+ if (Helper.Instance.Tokens[index].Extent.StartOffset >= kvp.Item1.Extent.EndOffset &&
+ Helper.Instance.Tokens[index].Extent.EndOffset <= kvp.Item2.Extent.StartOffset
+ )
+ {
+ equalsTokenIndex = index;
+ break;
+ }
+ }
+
+ // If we didn't find the equals sign - skip this KVP
+ if (equalsTokenIndex == -1)
+ {
+ continue;
+ }
+
+ // Normally a Key-Value pair looks like:
+ //
+ // Key = Value
+ //
+ // But the below is also valid:
+ //
+ // Key <#Inline Comment#> = Value
+ //
+ // We can still use this KVP for alignment - we simply treat
+ // the end of the token before the equals sign as the Left-Hand
+ // Side (LHS) of the assignment. We expose a user setting for
+ // this.
+ // If the user has not chosen to align such KVPs and the token
+ // before the equals sign does not end at the same offset as
+ // the key, we skip this KVP.
+ if (!AlignHashtableKvpWithInterveningComment &&
+ Helper.Instance.Tokens[equalsTokenIndex - 1].Extent.EndOffset != kvp.Item1.Extent.EndOffset
+ )
+ {
+ continue;
+ }
+
+ assignmentSites.Add(new Tuple(
+ Helper.Instance.Tokens[equalsTokenIndex - 1].Extent,
+ Helper.Instance.Tokens[equalsTokenIndex].Extent
+ ));
+ }
+
+ return assignmentSites;
}
///
- /// Retrieves the type of the rule, Builtin, Managed or Module.
+ /// Parse a DSC configuration definition's resource/property blocks into
+ /// a list of tuples which are later used to verify and correct alignment of
+ /// assignment operators.
///
- public override SourceType GetSourceType()
+ /// The ConfigurationDefinitionAst to parse.
+ ///
+ /// An enumeration of lists of tuples, where each tuple is a (lhsTokenExtent, equalsExtent)
+ /// pair representing the extent of the token immediately before the '='
+ /// (effectively the key/rightmost key token) and the extent of the '=' itself.
+ /// Only includes pairs where an '=' token is found on the same line as the key.
+ /// Implicitly skips line continuations.
+ ///
+ private IEnumerable>> ParseDscConfigDef(
+ ConfigurationDefinitionAst configDefAst,
+ Ast ast
+ )
{
- return SourceType.Builtin;
- }
- private IEnumerable FindHashtableViolations(TokenOperations tokenOps)
- {
- var hashtableAsts = tokenOps.Ast.FindAll(ast => ast is HashtableAst, true);
- var groups = new List>>();
- if (hashtableAsts != null)
+
+ if (configDefAst == null) { yield break; }
+
+ // Find command asts shaped like: =
+ var commandAsts = configDefAst.FindAll(
+ a =>
+ a is CommandAst c &&
+ c.CommandElements.Count == 3 &&
+ c.CommandElements[1].Extent?.Text == "=",
+ true
+ ).Cast();
+
+ // Group by grandparent NamedBlock (commandAst.Parent is PipelineAst)
+ var grouped = commandAsts.GroupBy(
+ c => c.Parent?.Parent
+ );
+
+ foreach (var group in grouped)
{
- foreach (var astItem in hashtableAsts)
+ var assignmentSites = new List>();
+
+ foreach (var cmd in group)
{
- groups.Add(GetExtents(tokenOps, (HashtableAst)astItem));
- }
- }
+ var lhs = cmd.CommandElements[0].Extent;
+ var eq = cmd.CommandElements[1].Extent;
-#if !PSV3
- var configAsts = tokenOps.Ast.FindAll(ast => ast is ConfigurationDefinitionAst, true);
- if (configAsts != null)
- {
- // There are probably parse errors caused by an "Undefined DSC resource"
- // which prevents the parser from detecting the property value pairs as
- // hashtable. Hence, this is a workaround to format configurations which
- // have "Undefined DSC resource" parse errors.
-
- // find all commandAsts of the form "prop" "=" "val" that have the same parent
- // and format those pairs.
- foreach (var configAst in configAsts)
+ if (lhs.EndLineNumber != eq.StartLineNumber)
+ {
+ // Skip if the key and equals sign are not on the same
+ // line
+ continue;
+ }
+
+ // Note: We can't use the token dictionary here like we do
+ // for hashtables/enums, as we get here typically
+ // because there's a parse error. i.e.
+ // ModuleNotFoundDuringParse and ResourceNotDefined
+ // Helper.Instance.Tokens is unavailable when there's
+ // a parse error so we can only use the ast.
+
+ // In lieu of being able to check tokens, we check the
+ // source text between the end of the lhs and the start of
+ // the equals sign for non-whitespace characters.
+ //
+ // key <#comment#> = value
+ // ^ ^
+ // | |
+ // -------------
+ // |
+ // We check for non-whitespace characters here
+ //
+ // If there are any, we extend the lhs extent to include
+ // them, so that the alignment is to the end of the
+ // rightmost non-whitespace characters.
+
+ // We get the text between between lhs and eq, trim it from
+ // the end (so we keep the right-most non-whitespace
+ // characters). It's length is how much we need to extend
+ // the lhs extent by.
+ var nonWhitespaceLength =
+ ast.Extent.Text.Substring(
+ lhs.EndOffset,
+ eq.StartOffset - lhs.EndOffset
+ ).TrimEnd().Length;
+
+ // If there's any non-whitespace characters between the
+ // key and the equals sign, and the user has chosen to
+ // ignore such cases, skip this KVP.
+ if (nonWhitespaceLength > 0 && !AlignHashtableKvpWithInterveningComment)
+ {
+ continue;
+ }
+
+ IScriptExtent leftExtent = null;
+ if (nonWhitespaceLength == 0)
+ {
+ // When there is no intervening comment, we use the
+ // key's extent as the LHS extent.
+ leftExtent = lhs;
+ }
+ else
+ {
+ // When there is an intervening comment, we extend
+ // the key's extent to include it.
+ leftExtent = new ScriptExtent(
+ new ScriptPosition(
+ lhs.File,
+ lhs.StartLineNumber,
+ lhs.StartColumnNumber,
+ null
+ ),
+ new ScriptPosition(
+ lhs.File,
+ lhs.EndLineNumber,
+ lhs.EndColumnNumber + nonWhitespaceLength,
+ null
+ )
+ );
+ }
+
+ assignmentSites.Add(new Tuple(
+ leftExtent,
+ eq
+ ));
+ }
+ if (assignmentSites.Count > 0)
{
- groups.AddRange(GetCommandElementExtentGroups(configAst));
+ yield return assignmentSites;
}
}
-#endif
+ }
- // it is probably much easier have a hashtable writer that formats the hashtable and writes it
- // but it makes handling comments hard. So we need to use this approach.
-
- // This is how the algorithm actually works:
- // if each key value pair are on a separate line
- // find all the assignment operators
- // if all the assignment operators are aligned (check the column number of each assignment operator)
- // skip
- // else
- // find the distance between the assignment operators and their corresponding LHS
- // find the longest left expression
- // make sure all the assignment operators are in the same column as that of the longest left hand.
- foreach (var extentTuples in groups)
+ ///
+ /// Parse an enum's members into a list of tuples which are later used to
+ /// verify and correct alignment of assignment operators.
+ ///
+ /// The enum TypeDefinitionAst to parse.
+ ///
+ /// A list of tuples, where each tuple is a (lhsTokenExtent, equalsExtent)
+ /// pair representing the extent of the token immediately before the '='
+ /// (effectively the member name) and the extent of the '=' itself.
+ /// Implicitly skips line continuations.
+ ///
+ private List> ParseEnums(
+ TypeDefinitionAst enumTypeDefAst
+ )
+ {
+ var assignmentSites = new List>();
+ if (enumTypeDefAst == null) { return assignmentSites; }
+
+ // Ensure we're only processing enums
+ if (!enumTypeDefAst.IsEnum) { return assignmentSites; }
+
+ // Enumerate Enum Members that are PropertyMemberAst
+ foreach (
+ var member in enumTypeDefAst.Members.Where(
+ m => m is PropertyMemberAst
+ ).Cast()
+ )
{
- if (!HasPropertiesOnSeparateLines(extentTuples))
+
+ // Enums can have members with or without explicit values.
+
+ // If InitialValue is null, this member has no explicit
+ // value and so should have no equals sign.
+ if (member.InitialValue == null)
{
+ if (!IncludeValuelessEnumMembers)
+ {
+ continue;
+ }
+
+ if (member.Extent.StartLineNumber != member.Extent.EndLineNumber)
+ {
+ // This member spans multiple lines - skip it
+ continue;
+ }
+
+ // We include this member in the alignment check, but
+ // with a null equalsExtent. This will be ignored in
+ // CheckAlignment, but will ensure that this member
+ // is included in the calculation of the target column.
+ assignmentSites.Add(new Tuple(
+ member.Extent,
+ null
+ ));
continue;
}
- if (extentTuples == null
- || extentTuples.Count == 0
- || !extentTuples.All(t => t.Item1.StartLineNumber == t.Item2.EndLineNumber))
+ // If the assignmentOperator dictionary has no entry for the
+ // line of the member name - skip this member; it should
+ // have an explicit value, so must have an equals sign.
+ // It's possible that the equals sign is on a different
+ // line thanks to line continuations (`). We skip such
+ // members.
+ if (!assignmentOperatorIndicesByLine.ContainsKey(member.Extent.StartLineNumber))
{
continue;
}
- var expectedStartColumnNumber = extentTuples.Max(x => x.Item1.EndColumnNumber) + 1;
- foreach (var extentTuple in extentTuples)
+ // Next we need to find the location of the equals sign for this
+ // member. We know the line it should be on. We can
+ // search all of the equals signs on that line.
+ //
+ // Unlike hashtables, we don't have an extent for the LHS and
+ // RHS of the member. We have the extent of the entire
+ // member, the name of the member, and the extent of the
+ // InitialValue (RHS). We can use these to find the equals
+ // sign. We know the equals sign must be after the
+ // member name, and before the InitialValue.
+
+ int equalsTokenIndex = -1;
+ foreach (var index in assignmentOperatorIndicesByLine[member.Extent.StartLineNumber])
{
- if (extentTuple.Item2.StartColumnNumber != expectedStartColumnNumber)
+ if (Helper.Instance.Tokens[index].Extent.StartOffset >= (member.Extent.StartColumnNumber + member.Name.Length) &&
+ Helper.Instance.Tokens[index].Extent.EndOffset < member.InitialValue.Extent.StartOffset
+ )
{
- yield return new DiagnosticRecord(
- GetError(),
- extentTuple.Item2,
- GetName(),
- GetDiagnosticSeverity(),
- extentTuple.Item1.File,
- null,
- GetHashtableCorrections(extentTuple, expectedStartColumnNumber).ToList());
+ equalsTokenIndex = index;
+ break;
}
}
- }
- }
- private List>> GetCommandElementExtentGroups(Ast configAst)
- {
- var result = new List>>();
- var commandAstGroups = GetCommandElementGroups(configAst);
- foreach (var commandAstGroup in commandAstGroups)
- {
- var list = new List>();
- foreach (var commandAst in commandAstGroup)
+ // If we didn't find the equals sign - skip, it's likely on a
+ // different line due to line continuations.
+ if (equalsTokenIndex == -1)
{
- var elems = commandAst.CommandElements;
- list.Add(new Tuple(elems[0].Extent, elems[1].Extent));
+ continue;
}
- result.Add(list);
- }
+ // Normally a member with a value looks like:
+ //
+ // Name = Value
+ //
+ // But the below is also valid:
+ //
+ // Name <#Inline Comment#> = Value
+ //
+ // We can still use this member for alignment - we simply treat
+ // the end of the token before the equals sign as the Left-Hand
+ // Side (LHS) of the assignment. We expose a user setting for
+ // this.
+ // If the user has not chosen to align such members and the
+ // token before the equals sign is a comment, we skip this
+ // member.
+ if (!AlignEnumMemberWithInterveningComment &&
+ Helper.Instance.Tokens[equalsTokenIndex - 1].Kind == TokenKind.Comment
+ )
+ {
+ continue;
+ }
- return result;
+ assignmentSites.Add(new Tuple(
+ Helper.Instance.Tokens[equalsTokenIndex - 1].Extent,
+ Helper.Instance.Tokens[equalsTokenIndex].Extent
+ ));
+ }
+ return assignmentSites;
}
- private List> GetCommandElementGroups(Ast configAst)
+ ///
+ /// Check alignment of assignment operators in the provided list of
+ /// (lhsTokenExtent, equalsExtent) tuples, and return diagnostics for
+ /// any misalignments found.
+ ///
+ /// From the lhsTokenExtent, we can determine the target column for
+ /// alignment (the column after the longest key). We then compare the
+ /// equalsExtent's start column to the target column, and if they
+ /// differ, we have a misalignment and return a diagnostic.
+ ///
+ ///
+ /// A list of tuples, where each tuple is a (lhsTokenExtent, equalsExtent)
+ /// pair representing the extent of the token immediately before the '='
+ /// and the extent of the '=' itself.
+ /// Only includes pairs where an '=' token is found on the same line as
+ /// the key.
+ ///
+ ///
+ /// An enumerable of DiagnosticRecords, one for each misaligned
+ /// assignment operator found.
+ ///
+ private IEnumerable CheckAlignment(
+ List> assignmentSites
+ )
{
- var result = new List>();
- var astsFound = configAst.FindAll(ast => IsPropertyValueCommandAst(ast), true);
- if (astsFound == null)
+ if (assignmentSites == null || assignmentSites.Count == 0)
{
- return result;
+ yield break;
}
- var parentChildrenGroup = from ast in astsFound
- select (CommandAst)ast into commandAst
- group commandAst by commandAst.Parent.Parent; // parent is pipeline and pipeline's parent is namedblockast
- foreach (var group in parentChildrenGroup)
+ // Filter out everything from assignmentSites that is not on
+ // it's own line. Do this by grouping by the start line number
+ // of the lhsTokenExtent, and only keeping groups with a count
+ // of 1.
+ assignmentSites = assignmentSites
+ .GroupBy(t => t.Item1.StartLineNumber)
+ .Where(g => g.Count() == 1)
+ .Select(g => g.First())
+ .ToList();
+
+ // If, after filtering, we have no assignment sites, exit
+ if (assignmentSites == null || assignmentSites.Count == 0)
{
- result.Add(group.ToList());
+ yield break;
}
- return result;
+ // The target column for this hashtable is longest key plus one
+ // space.
+ var targetColumn = assignmentSites
+ .Max(t => t.Item1.EndColumnNumber) + 1;
+
+ // Check each element of the hashtable to see if it's aligned
+ foreach (var site in assignmentSites)
+ {
+ // If the equalsExtent is null, this is a member without
+ // an explicit value. We include such members in the
+ // calculation of the target column, but we don't
+ // generate diagnostics for them.
+ if (site.Item2 == null)
+ {
+ continue;
+ }
+
+ // If the equals sign is already at the target column,
+ // no diagnostic is needed.
+ if (site.Item2.StartColumnNumber == targetColumn)
+ {
+ continue;
+ }
+
+ yield return new DiagnosticRecord(
+ string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementError),
+ site.Item2,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ site.Item1.File,
+ null,
+ GetCorrectionExtent(
+ site.Item1,
+ site.Item2,
+ targetColumn
+ )
+ );
+ }
}
- private bool IsPropertyValueCommandAst(Ast ast)
+ ///
+ /// Generate the correction extent to align the assignment operator
+ /// to the target column.
+ ///
+ /// The extent of the token immediately before the '='
+ /// The extent of the '=' token
+ /// The target column to align to
+ /// An enumerable of CorrectionExtents, one for each correction
+ private List GetCorrectionExtent(
+ IScriptExtent lhsExtent,
+ IScriptExtent equalsExtent,
+ int targetColumn
+ )
{
- var commandAst = ast as CommandAst;
- return commandAst != null
- && commandAst.CommandElements.Count() == 3
- && commandAst.CommandElements[1].Extent.Text.Equals("=");
+ // We generate a correction extent which replaces the text between
+ // the end of the lhs and the start of the equals sign with the
+ // appropriate number of spaces to align the equals sign to the
+ // target column.
+ return new List
+ {
+ new CorrectionExtent(
+ lhsExtent.EndLineNumber,
+ equalsExtent.StartLineNumber,
+ lhsExtent.EndColumnNumber,
+ equalsExtent.StartColumnNumber,
+ new string(' ', targetColumn - lhsExtent.EndColumnNumber),
+ string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementError)
+ )
+ };
}
- private IEnumerable GetHashtableCorrections(
- Tuple extentTuple,
- int expectedStartColumnNumber)
+ ///
+ /// Retrieves the common name of this rule.
+ ///
+ public override string GetCommonName()
{
- var equalExtent = extentTuple.Item2;
- var lhsExtent = extentTuple.Item1;
- var columnDiff = expectedStartColumnNumber - equalExtent.StartColumnNumber;
- yield return new CorrectionExtent(
- lhsExtent.EndLineNumber,
- equalExtent.StartLineNumber,
- lhsExtent.EndColumnNumber,
- equalExtent.StartColumnNumber,
- new String(whitespaceChar, expectedStartColumnNumber - lhsExtent.EndColumnNumber),
- GetError());
+ return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementCommonName);
}
- private string GetError()
+ ///
+ /// Retrieves the description of this rule.
+ ///
+ public override string GetDescription()
{
- return String.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementError);
+ return string.Format(CultureInfo.CurrentCulture, Strings.AlignAssignmentStatementDescription);
}
- private static List> GetExtents(
- TokenOperations tokenOps,
- HashtableAst hashtableAst)
+ ///
+ /// Retrieves the name of this rule.
+ ///
+ public override string GetName()
{
- var nodeTuples = new List>();
- foreach (var kvp in hashtableAst.KeyValuePairs)
- {
- var keyStartOffset = kvp.Item1.Extent.StartOffset;
- bool keyStartOffSetReached = false;
- var keyTokenNode = tokenOps.GetTokenNodes(
- token =>
- {
- if (keyStartOffSetReached)
- {
- return token.Kind == TokenKind.Equals;
- }
- if (token.Extent.StartOffset == keyStartOffset)
- {
- keyStartOffSetReached = true;
- }
- return false;
- }).FirstOrDefault();
- if (keyTokenNode == null || keyTokenNode.Value == null)
- {
- continue;
- }
- var assignmentToken = keyTokenNode.Value.Extent;
-
- nodeTuples.Add(new Tuple(
- kvp.Item1.Extent, assignmentToken));
- }
+ return string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.NameSpaceFormat,
+ GetSourceName(),
+ Strings.AlignAssignmentStatementName);
+ }
- return nodeTuples;
+ ///
+ /// Retrieves the severity of the rule: error, warning or information.
+ ///
+ public override RuleSeverity GetSeverity()
+ {
+ return RuleSeverity.Warning;
}
- private bool HasPropertiesOnSeparateLines(IEnumerable> tuples)
+ ///
+ /// Retrieves the name of the module/assembly the rule is from.
+ ///
+ public override string GetSourceName()
{
- var lines = new HashSet();
- foreach (var kvp in tuples)
- {
- if (lines.Contains(kvp.Item1.StartLineNumber))
- {
- return false;
- }
- else
- {
- lines.Add(kvp.Item1.StartLineNumber);
- }
- }
+ return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
+ }
- return true;
+ ///
+ /// Retrieves the type of the rule, Builtin, Managed or Module.
+ ///
+ public override SourceType GetSourceType()
+ {
+ return SourceType.Builtin;
}
}
}
diff --git a/Rules/AvoidAssignmentToAutomaticVariable.cs b/Rules/AvoidAssignmentToAutomaticVariable.cs
index c188da341..c1ce88462 100644
--- a/Rules/AvoidAssignmentToAutomaticVariable.cs
+++ b/Rules/AvoidAssignmentToAutomaticVariable.cs
@@ -79,6 +79,31 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
}
}
+ IEnumerable forEachStatementAsts = ast.FindAll(testAst => testAst is ForEachStatementAst, searchNestedScriptBlocks: true);
+ foreach (ForEachStatementAst forEachStatementAst in forEachStatementAsts)
+ {
+ var variableExpressionAst = forEachStatementAst.Variable;
+ var variableName = variableExpressionAst.VariablePath.UserPath;
+ if (_readOnlyAutomaticVariables.Contains(variableName, StringComparer.OrdinalIgnoreCase))
+ {
+ yield return new DiagnosticRecord(DiagnosticRecordHelper.FormatError(Strings.AvoidAssignmentToReadOnlyAutomaticVariableError, variableName),
+ variableExpressionAst.Extent, GetName(), DiagnosticSeverity.Error, fileName, variableName);
+ }
+
+ if (_readOnlyAutomaticVariablesIntroducedInVersion6_0.Contains(variableName, StringComparer.OrdinalIgnoreCase))
+ {
+ var severity = IsPowerShellVersion6OrGreater() ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning;
+ yield return new DiagnosticRecord(DiagnosticRecordHelper.FormatError(Strings.AvoidAssignmentToReadOnlyAutomaticVariableIntroducedInPowerShell6_0Error, variableName),
+ variableExpressionAst.Extent, GetName(), severity, fileName, variableName);
+ }
+
+ if (_writableAutomaticVariables.Contains(variableName, StringComparer.OrdinalIgnoreCase))
+ {
+ yield return new DiagnosticRecord(DiagnosticRecordHelper.FormatError(Strings.AvoidAssignmentToWritableAutomaticVariableError, variableName),
+ variableExpressionAst.Extent, GetName(), DiagnosticSeverity.Warning, fileName, variableName);
+ }
+ }
+
IEnumerable parameterAsts = ast.FindAll(testAst => testAst is ParameterAst, searchNestedScriptBlocks: true);
foreach (ParameterAst parameterAst in parameterAsts)
{
@@ -89,7 +114,12 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
{
continue;
}
-
+ // also check the parent to exclude variableExpressions that appear within attributes,
+ // such as '[ValidateSet($True,$False)]' where the read-only variables $true,$false appear.
+ if (variableExpressionAst.Parent is AttributeAst)
+ {
+ continue;
+ }
if (_readOnlyAutomaticVariables.Contains(variableName, StringComparer.OrdinalIgnoreCase))
{
yield return new DiagnosticRecord(DiagnosticRecordHelper.FormatError(Strings.AvoidAssignmentToReadOnlyAutomaticVariableError, variableName),
diff --git a/Rules/AvoidDefaultValueForMandatoryParameter.cs b/Rules/AvoidDefaultValueForMandatoryParameter.cs
index f3d66d973..17925ae97 100644
--- a/Rules/AvoidDefaultValueForMandatoryParameter.cs
+++ b/Rules/AvoidDefaultValueForMandatoryParameter.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Management.Automation.Language;
#if !CORECLR
using System.ComponentModel.Composition;
@@ -27,59 +28,73 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
{
if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage);
- // Finds all functionAst
- IEnumerable functionAsts = ast.FindAll(testAst => testAst is FunctionDefinitionAst, true);
-
- foreach (FunctionDefinitionAst funcAst in functionAsts)
+ // Find all ParameterAst which are children of a ParamBlockAst. This
+ // doesn't pick up where they appear as children of a
+ // FunctionDefinitionAst. i.e.
+ //
+ // function foo ($a,$b){} -> $a and $b are `ParameterAst`
+ //
+ // Include only parameters which have a default value (as without
+ // one this rule would never alert)
+ // Include only parameters where ALL parameter attributes have the
+ // mandatory named argument set to true (implicitly or explicitly)
+
+ var mandatoryParametersWithDefaultValues =
+ ast.FindAll(testAst => testAst is ParamBlockAst, true)
+ .Cast()
+ .Where(pb => pb.Parameters?.Count > 0)
+ .SelectMany(pb => pb.Parameters)
+ .Where(paramAst =>
+ paramAst.DefaultValue != null &&
+ HasMandatoryInAllParameterAttributes(paramAst)
+ );
+
+ // Report diagnostics for each parameter that violates the rule
+ foreach (var parameter in mandatoryParametersWithDefaultValues)
{
- if (funcAst.Body != null && funcAst.Body.ParamBlock != null
- && funcAst.Body.ParamBlock.Attributes != null && funcAst.Body.ParamBlock.Parameters != null)
- {
- foreach (var paramAst in funcAst.Body.ParamBlock.Parameters)
- {
- bool mandatory = false;
-
- // check that param is mandatory
- foreach (var paramAstAttribute in paramAst.Attributes)
- {
- if (paramAstAttribute is AttributeAst)
- {
- var namedArguments = (paramAstAttribute as AttributeAst).NamedArguments;
- if (namedArguments != null)
- {
- foreach (NamedAttributeArgumentAst namedArgument in namedArguments)
- {
- if (String.Equals(namedArgument.ArgumentName, "mandatory", StringComparison.OrdinalIgnoreCase))
- {
- // 3 cases: [Parameter(Mandatory)], [Parameter(Mandatory=$true)] and [Parameter(Mandatory=value)] where value is not equal to 0.
- if (namedArgument.ExpressionOmitted
- || (String.Equals(namedArgument.Argument.Extent.Text, "$true", StringComparison.OrdinalIgnoreCase))
- || (int.TryParse(namedArgument.Argument.Extent.Text, out int mandatoryValue) && mandatoryValue != 0))
- {
- mandatory = true;
- break;
- }
- }
- }
- }
- }
- }
-
- if (!mandatory)
- {
- break;
- }
-
- if (paramAst.DefaultValue != null)
- {
- yield return new DiagnosticRecord(string.Format(CultureInfo.CurrentCulture, Strings.AvoidDefaultValueForMandatoryParameterError, paramAst.Name.VariablePath.UserPath),
- paramAst.Name.Extent, GetName(), DiagnosticSeverity.Warning, fileName, paramAst.Name.VariablePath.UserPath);
- }
- }
- }
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.AvoidDefaultValueForMandatoryParameterError,
+ parameter.Name.VariablePath.UserPath
+ ),
+ parameter.Name.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName,
+ parameter.Name.VariablePath.UserPath
+ );
}
}
+ ///
+ /// Determines if a parameter is mandatory in all of its Parameter attributes.
+ /// A parameter may have multiple [Parameter] attributes for different parameter sets.
+ /// This method returns true only if ALL [Parameter] attributes have Mandatory=true.
+ ///
+ /// The parameter AST to examine
+ /// String comparer for case-insensitive attribute name matching
+ ///
+ /// True if the parameter has at least one [Parameter] attribute and ALL of them
+ /// have the Mandatory named argument set to true (explicitly or implicitly).
+ /// False if the parameter has no [Parameter] attributes or if any [Parameter]
+ /// attribute does not have Mandatory=true.
+ ///
+ private static bool HasMandatoryInAllParameterAttributes(ParameterAst paramAst)
+ {
+ var parameterAttributes = paramAst.Attributes.OfType()
+ .Where(attr => string.Equals(attr.TypeName?.Name, "parameter", StringComparison.OrdinalIgnoreCase));
+
+ return parameterAttributes.Any() &&
+ parameterAttributes.All(attr =>
+ attr.NamedArguments.OfType()
+ .Any(namedArg =>
+ string.Equals(namedArg.ArgumentName, "mandatory", StringComparison.OrdinalIgnoreCase) &&
+ Helper.Instance.GetNamedArgumentAttributeValue(namedArg)
+ )
+ );
+ }
+
///
/// GetName: Retrieves the name of this rule.
///
@@ -134,6 +149,3 @@ public string GetSourceName()
}
}
-
-
-
diff --git a/Rules/AvoidGlobalAliases.cs b/Rules/AvoidGlobalAliases.cs
index afe7978e6..8697ad1ca 100644
--- a/Rules/AvoidGlobalAliases.cs
+++ b/Rules/AvoidGlobalAliases.cs
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-#if !PSV3
using System;
using System.Collections.Generic;
#if !CORECLR
@@ -136,6 +135,4 @@ public SourceType GetSourceType()
return SourceType.Builtin;
}
}
-}
-
-#endif // !PSV3
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/Rules/AvoidMultipleTypeAttributes.cs b/Rules/AvoidMultipleTypeAttributes.cs
index 77f63de21..590a058d9 100644
--- a/Rules/AvoidMultipleTypeAttributes.cs
+++ b/Rules/AvoidMultipleTypeAttributes.cs
@@ -37,7 +37,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
// Iterates all ParamAsts and check the number of its types.
foreach (ParameterAst paramAst in paramAsts)
{
- if (paramAst.Attributes.Where(typeAst => typeAst is TypeConstraintAst).Count() > 1)
+ if (paramAst.Attributes.OfType().Skip(1).Any())
{
yield return new DiagnosticRecord(
String.Format(CultureInfo.CurrentCulture, Strings.AvoidMultipleTypeAttributesError, paramAst.Name),
diff --git a/Rules/AvoidReservedParams.cs b/Rules/AvoidReservedParams.cs
index 7582d4571..4035a9c89 100644
--- a/Rules/AvoidReservedParams.cs
+++ b/Rules/AvoidReservedParams.cs
@@ -60,7 +60,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) {
if (commonParamNames.Contains(paramName, StringComparer.OrdinalIgnoreCase))
{
yield return new DiagnosticRecord(string.Format(CultureInfo.CurrentCulture, Strings.ReservedParamsError, funcAst.Name, paramName),
- paramAst.Extent, GetName(), DiagnosticSeverity.Warning, fileName);
+ paramAst.Extent, GetName(), GetDiagnosticSeverity(), fileName);
}
}
}
@@ -107,7 +107,16 @@ public SourceType GetSourceType()
///
public RuleSeverity GetSeverity()
{
- return RuleSeverity.Warning;
+ return RuleSeverity.Error;
+ }
+
+ ///
+ /// Gets the severity of the returned diagnostic record: error, warning, or information.
+ ///
+ ///
+ public DiagnosticSeverity GetDiagnosticSeverity()
+ {
+ return DiagnosticSeverity.Error;
}
///
diff --git a/Rules/AvoidReservedWordsAsFunctionNames.cs b/Rules/AvoidReservedWordsAsFunctionNames.cs
new file mode 100644
index 000000000..921909704
--- /dev/null
+++ b/Rules/AvoidReservedWordsAsFunctionNames.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Management.Automation.Language;
+using System.Linq;
+
+#if !CORECLR
+using System.ComponentModel.Composition;
+#endif
+
+namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
+{
+#if !CORECLR
+ [Export(typeof(IScriptRule))]
+#endif
+
+ ///
+ /// Rule that warns when reserved words are used as function names
+ ///
+ public class AvoidReservedWordsAsFunctionNames : IScriptRule
+ {
+
+ // The list of PowerShell reserved words.
+ // https://learn.microsoft.com/en-gb/powershell/module/microsoft.powershell.core/about/about_reserved_words
+ //
+ // The Below are omitted as they don't pose an issue being a function
+ // name:
+ // assembly, base, command, hidden, in, inlinescript, interface, module,
+ // namespace, private, public, static
+ static readonly HashSet reservedWords = new HashSet(
+ new[] {
+ "begin", "break", "catch", "class", "configuration",
+ "continue", "data", "define", "do",
+ "dynamicparam", "else", "elseif", "end",
+ "enum", "exit", "filter", "finally",
+ "for", "foreach", "from", "function",
+ "if", "parallel", "param", "process",
+ "return", "sequence", "switch",
+ "throw", "trap", "try", "type",
+ "until", "using","var", "while", "workflow"
+ },
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ ///
+ /// Analyzes the PowerShell AST for uses of reserved words as function names.
+ ///
+ /// The PowerShell Abstract Syntax Tree to analyze.
+ /// The name of the file being analyzed (for diagnostic reporting).
+ /// A collection of diagnostic records for each violation.
+ public IEnumerable AnalyzeScript(Ast ast, string fileName)
+ {
+ if (ast == null)
+ {
+ throw new ArgumentNullException(Strings.NullAstErrorMessage);
+ }
+
+ // Find all FunctionDefinitionAst in the Ast
+ var functionDefinitions = ast.FindAll(
+ astNode => astNode is FunctionDefinitionAst,
+ true
+ ).Cast();
+
+ foreach (var function in functionDefinitions)
+ {
+ string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name);
+ if (reservedWords.Contains(functionName))
+ {
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.AvoidReservedWordsAsFunctionNamesError,
+ functionName),
+ Helper.Instance.GetScriptExtentForFunctionName(function) ?? function.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName
+ );
+ }
+ }
+ }
+
+ public string GetCommonName() => Strings.AvoidReservedWordsAsFunctionNamesCommonName;
+
+ public string GetDescription() => Strings.AvoidReservedWordsAsFunctionNamesDescription;
+
+ public string GetName() => string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.NameSpaceFormat,
+ GetSourceName(),
+ Strings.AvoidReservedWordsAsFunctionNamesName);
+
+ public RuleSeverity GetSeverity() => RuleSeverity.Warning;
+
+ public string GetSourceName() => Strings.SourceName;
+
+ public SourceType GetSourceType() => SourceType.Builtin;
+ }
+}
\ No newline at end of file
diff --git a/Rules/AvoidTrailingWhitespace.cs b/Rules/AvoidTrailingWhitespace.cs
index 47f576d5b..a7567d6e6 100644
--- a/Rules/AvoidTrailingWhitespace.cs
+++ b/Rules/AvoidTrailingWhitespace.cs
@@ -54,7 +54,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
}
int startColumnOfTrailingWhitespace = 1;
- for (int i = line.Length - 2; i > 0; i--)
+ for (int i = line.Length - 2; i >= 0; i--)
{
if (line[i] != ' ' && line[i] != '\t')
{
diff --git a/Rules/CompatibilityRules/UseCompatibleSyntax.cs b/Rules/CompatibilityRules/UseCompatibleSyntax.cs
index 3af6cdd1c..c7d10c19d 100644
--- a/Rules/CompatibilityRules/UseCompatibleSyntax.cs
+++ b/Rules/CompatibilityRules/UseCompatibleSyntax.cs
@@ -149,11 +149,7 @@ private static HashSet GetTargetedVersions(string[] versionSettings)
return targetVersions;
}
-#if !(PSV3 || PSV4)
private class SyntaxCompatibilityVisitor : AstVisitor2
-#else
- private class SyntaxCompatibilityVisitor : AstVisitor
-#endif
{
private readonly UseCompatibleSyntax _rule;
@@ -260,7 +256,6 @@ public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst fun
return AstVisitAction.Continue;
}
-#if !(PSV3 || PSV4)
public override AstVisitAction VisitUsingStatement(UsingStatementAst usingStatementAst)
{
// Look for 'using ...;' at the top of scripts
@@ -306,7 +301,6 @@ public override AstVisitAction VisitTypeDefinition(TypeDefinitionAst typeDefinit
return AstVisitAction.Continue;
}
-#endif
#if PSV7
public override AstVisitAction VisitMemberExpression(MemberExpressionAst memberExpressionAst)
diff --git a/Rules/DscExamplesPresent.cs b/Rules/DscExamplesPresent.cs
index 6d0a01a3b..17cca2a1c 100644
--- a/Rules/DscExamplesPresent.cs
+++ b/Rules/DscExamplesPresent.cs
@@ -65,8 +65,6 @@ public IEnumerable AnalyzeDSCResource(Ast ast, string fileName
}
}
- #if !(PSV3||PSV4)
-
///
/// AnalyzeDSCClass: Analyzes given DSC class
///
@@ -116,8 +114,6 @@ item is TypeDefinitionAst
}
}
- #endif
-
///
/// GetName: Retrieves the name of this rule.
///
diff --git a/Rules/DscTestsPresent.cs b/Rules/DscTestsPresent.cs
index 5c09ede8a..3a19902d9 100644
--- a/Rules/DscTestsPresent.cs
+++ b/Rules/DscTestsPresent.cs
@@ -65,8 +65,6 @@ public IEnumerable AnalyzeDSCResource(Ast ast, string fileName
}
}
- #if !(PSV3||PSV4)
-
///
/// AnalyzeDSCClass: Analyzes given DSC class
///
@@ -116,8 +114,6 @@ item is TypeDefinitionAst
}
}
- #endif
-
///
/// GetName: Retrieves the name of this rule.
///
diff --git a/Rules/PossibleIncorrectComparisonWithNull.cs b/Rules/PossibleIncorrectComparisonWithNull.cs
index 74db4f523..692f49f13 100644
--- a/Rules/PossibleIncorrectComparisonWithNull.cs
+++ b/Rules/PossibleIncorrectComparisonWithNull.cs
@@ -44,16 +44,8 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) {
}
}
- #if PSV3
-
- IEnumerable funcAsts = ast.FindAll(item => item is FunctionDefinitionAst, true);
-
- #else
-
IEnumerable funcAsts = ast.FindAll(item => item is FunctionDefinitionAst, true).Union(ast.FindAll(item => item is FunctionMemberAst, true));
- #endif
-
foreach (Ast funcAst in funcAsts)
{
IEnumerable binAsts = funcAst.FindAll(item => item is BinaryExpressionAst, true);
@@ -112,7 +104,7 @@ private IEnumerable GetCorrectionExtent(BinaryExpressionAst bi
binaryExpressionAst.Extent.EndColumnNumber,
$"{binaryExpressionAst.Right.Extent.Text} {binaryExpressionAst.ErrorPosition.Text} {binaryExpressionAst.Left.Extent.Text}",
binaryExpressionAst.Extent.File,
- Strings.PossibleIncorrectComparisonWithNullSuggesteCorrectionDescription
+ Strings.PossibleIncorrectComparisonWithNullSuggestedCorrectionDescription
);
yield return correction;
diff --git a/Rules/ReturnCorrectTypesForDSCFunctions.cs b/Rules/ReturnCorrectTypesForDSCFunctions.cs
index 08eb59df0..0c750571c 100644
--- a/Rules/ReturnCorrectTypesForDSCFunctions.cs
+++ b/Rules/ReturnCorrectTypesForDSCFunctions.cs
@@ -35,28 +35,16 @@ public IEnumerable AnalyzeDSCResource(Ast ast, string fileName
IEnumerable functionDefinitionAsts = Helper.Instance.DscResourceFunctions(ast);
- #if !(PSV3||PSV4)
-
IEnumerable classes = ast.FindAll(item =>
item is TypeDefinitionAst
&& ((item as TypeDefinitionAst).IsClass), true).Cast();
- #endif
foreach (FunctionDefinitionAst func in functionDefinitionAsts)
{
- #if PSV3 || PSV4
-
- List> outputTypes = FindPipelineOutput.OutputTypes(func);
-
- #else
-
List> outputTypes = FindPipelineOutput.OutputTypes(func, classes);
- #endif
-
-
if (String.Equals(func.Name, "Set-TargetResource", StringComparison.OrdinalIgnoreCase))
{
foreach (Tuple outputType in outputTypes)
@@ -93,8 +81,6 @@ item is TypeDefinitionAst
}
}
- #if !(PSV3||PSV4)
-
///
/// AnalyzeDSCClass: Analyzes given DSC Resource
///
@@ -184,9 +170,6 @@ item is TypeDefinitionAst
}
}
- #endif
-
-
///
/// GetName: Retrieves the name of this rule.
///
diff --git a/Rules/ReviewUnusedParameter.cs b/Rules/ReviewUnusedParameter.cs
index f13584fed..9b727e7fc 100644
--- a/Rules/ReviewUnusedParameter.cs
+++ b/Rules/ReviewUnusedParameter.cs
@@ -6,6 +6,7 @@
using System.Linq;
using System.Management.Automation.Language;
using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
+using Microsoft.Windows.PowerShell.ScriptAnalyzer.Extensions;
#if !CORECLR
using System.ComponentModel.Composition;
#endif
@@ -97,11 +98,40 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
// find all declared parameters
IEnumerable parameterAsts = scriptBlockAst.FindAll(oneAst => oneAst is ParameterAst, false);
+ // does the scriptblock have a process block where either $PSItem or $_ is referenced
+ bool hasProcessBlockWithPSItemOrUnderscore = false;
+ if (scriptBlockAst.ProcessBlock != null)
+ {
+ IDictionary processBlockVariableCount = GetVariableCount(scriptBlockAst.ProcessBlock);
+ processBlockVariableCount.TryGetValue("_", out int underscoreVariableCount);
+ processBlockVariableCount.TryGetValue("psitem", out int psitemVariableCount);
+ if (underscoreVariableCount > 0 || psitemVariableCount > 0)
+ {
+ hasProcessBlockWithPSItemOrUnderscore = true;
+ }
+ }
+
// list all variables
IDictionary variableCount = GetVariableCount(scriptBlockAst);
foreach (ParameterAst parameterAst in parameterAsts)
{
+ // Check if the parameter has the ValueFromPipeline attribute
+ NamedAttributeArgumentAst valueFromPipeline = (NamedAttributeArgumentAst)parameterAst.Find(
+ valFromPipelineAst => valFromPipelineAst is NamedAttributeArgumentAst namedAttrib && string.Equals(
+ namedAttrib.ArgumentName, "ValueFromPipeline",
+ StringComparison.OrdinalIgnoreCase
+ ),
+ false
+ );
+ // If the parameter has the ValueFromPipeline attribute and the scriptblock has a process block with
+ // $_ or $PSItem usage, then the parameter is considered used
+ if (valueFromPipeline != null && valueFromPipeline.GetValue() && hasProcessBlockWithPSItemOrUnderscore)
+
+ {
+ continue;
+ }
+
// there should be at least two usages of the variable since the parameter declaration counts as one
variableCount.TryGetValue(parameterAst.Name.VariablePath.UserPath, out int variableUsageCount);
if (variableUsageCount >= 2)
@@ -220,7 +250,7 @@ public string GetSourceName()
/// The scriptblock ast to scan
/// Previously generated data. New findings are added to any existing dictionary if present
/// a dictionary including all variables in the scriptblock and their count
- IDictionary GetVariableCount(ScriptBlockAst ast, Dictionary data = null)
+ IDictionary GetVariableCount(Ast ast, Dictionary data = null)
{
Dictionary content = data;
if (null == data)
diff --git a/Rules/Rules.csproj b/Rules/Rules.csproj
index 8fef9e969..7641bb033 100644
--- a/Rules/Rules.csproj
+++ b/Rules/Rules.csproj
@@ -2,7 +2,7 @@
$(ModuleVersion)
- net6;net462
+ net8;net462Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules$(ModuleVersion)Rules
@@ -16,7 +16,7 @@
-
+
@@ -53,15 +53,7 @@
PrepareResources;$(CompileDependsOn)
-
- $(DefineConstants);PSV3
-
-
-
- $(DefineConstants);PSV3;PSV4
-
-
-
+ $(DefineConstants);PSV7;CORECLR
diff --git a/Rules/Strings.resx b/Rules/Strings.resx
index ff75828cf..2a04fd759 100644
--- a/Rules/Strings.resx
+++ b/Rules/Strings.resx
@@ -1,17 +1,17 @@
-
@@ -124,7 +124,7 @@
Avoid Using Cmdlet Aliases or omitting the 'Get-' prefix.
- Empty catch blocks are considered poor design decisions because if an error occurs in the try block, this error is simply swallowed and not acted upon. While this does not inherently lead to bad things. It can and this should be avoided if possible. To fix a violation of this rule, using Write-Error or throw statements in catch blocks.
+ Empty catch blocks are considered poor design decisions because if an error occurs in the try block, this error is simply swallowed and not acted upon. While this does not inherently cause problems, it can, so it should be avoided where possible. To fix a violation of this rule, use Write-Error or throw statements in catch blocks.Avoid Using Empty Catch Block
@@ -136,7 +136,7 @@
Avoid Using Invoke-Expression
- Readability and clarity should be the goal of any script we expect to maintain over time. When calling a command that takes parameters, where possible consider using name parameters as opposed to positional parameters. To fix a violation of this rule, please use named parameters instead of positional parameters when calling a command.
+ Readability and clarity should be the goal of any script we expect to maintain over time. When calling a command that takes parameters, where possible consider using named parameters as opposed to positional parameters. To fix a violation of this rule, please use named parameters instead of positional parameters when calling a command.Avoid Using Positional Parameters
@@ -178,7 +178,7 @@
No Global Variables
- Checks that $null is on the left side of any equaltiy comparisons (eq, ne, ceq, cne, ieq, ine). When there is an array on the left side of a null equality comparison, PowerShell will check for a $null IN the array rather than if the array is null. If the two sides of the comaprision are switched this is fixed. Therefore, $null should always be on the left side of equality comparisons just in case.
+ Checks that $null is on the left side of any equality comparisons (eq, ne, ceq, cne, ieq, ine). When there is an array on the left side of a null equality comparison, PowerShell will check for a $null IN the array rather than if the array is null. If the two sides of the comparison are switched this is fixed. Therefore, $null should always be on the left side of equality comparisons just in case.$null should be on the left side of equality comparisons.
@@ -202,13 +202,13 @@
One Char
- For PowerShell 4.0 and earlier, a parameter named Credential with type PSCredential must have a credential transformation attribute defined after the PSCredential type attribute.
+ For PowerShell 4.0 and earlier, a parameter named Credential with type PSCredential must have a credential transformation attribute defined after the PSCredential type attribute.The Credential parameter in '{0}' must be of type PSCredential. For PowerShell 4.0 and earlier, please define a credential transformation attribute, e.g. [System.Management.Automation.Credential()], after the PSCredential type attribute.
- The Credential parameter found in the script block must be of type PSCredential. For PowerShell 4.0 and earlier please define a credential transformation attribute, e.g. [System.Management.Automation.Credential()], after the PSCredential type attribute.
+ The Credential parameter found in the script block must be of type PSCredential. For PowerShell 4.0 and earlier please define a credential transformation attribute, e.g. [System.Management.Automation.Credential()], after the PSCredential type attribute.Use PSCredential type.
@@ -535,7 +535,7 @@
PSDSC
- Use Standard Get/Set/Test TargetResource functions in DSC Resource
+ Use Standard Get/Set/Test TargetResource functions in DSC ResourceDSC Resource must implement Get, Set and Test-TargetResource functions. DSC Class must implement Get, Set and Test functions.
@@ -618,7 +618,7 @@
Use ShouldProcess For State Changing Functions
-
+
Functions that have verbs like New, Start, Stop, Set, Reset, Restart that change system state should support 'ShouldProcess'.
@@ -703,7 +703,7 @@
PowerShell help file needs to use UTF8 Encoding.
- File {0} has to use UTF8 instead of {1} encoding because it is a powershell help file.
+ File {0} has to use UTF8 instead of {1} encoding because it is a PowerShell help file.UseUTF8EncodingForHelpFile
@@ -742,7 +742,7 @@
Misleading Backtick
- Ending a line with an escaped whitepsace character is misleading. A trailing backtick is usually used for line continuation. Users typically don't intend to end a line with escaped whitespace.
+ Ending a line with an escaped whitespace character is misleading. A trailing backtick is usually used for line continuation. Users typically don't intend to end a line with escaped whitespace.MisleadingBacktick
@@ -769,7 +769,7 @@
In a module manifest, AliasesToExport, CmdletsToExport, FunctionsToExport and VariablesToExport fields should not use wildcards or $null in their entries. During module auto-discovery, if any of these entries are missing or $null or wildcard, PowerShell does some potentially expensive work to analyze the rest of the module.
- Do not use wildcard or $null in this field. Explicitly specify a list for {0}.
+ Do not use wildcard or $null in this field. Explicitly specify a list for {0}.UseToExportFieldsInManifest
@@ -786,16 +786,16 @@
Replace {0} with {1}
-
+
Create hashtables with literal initializers
-
+
Use literal initializer, @{{}}, for creating a hashtable as they are case-insensitive by default
-
- Create hashtables with literal initliazers
+
+ Create hashtables with literal initializers
-
+
UseLiteralInitializerForHashtable
@@ -874,7 +874,7 @@
The type accelerator '{0}' is not available by default in PowerShell version '{1}' on platform '{2}'
- Avoid global functiosn and aliases
+ Avoid global functions and aliasesChecks that global functions and aliases are not used. Global functions are strongly discouraged as they can cause errors across different systems.
@@ -979,7 +979,7 @@
Use consistent indentation
- Each statement block should have a consistent indenation.
+ Each statement block should have a consistent indentation.Indentation not consistent
@@ -991,7 +991,7 @@
Use whitespaces
- Check for whitespace between keyword and open paren/curly, around assigment operator ('='), around arithmetic operators and after separators (',' and ';')
+ Check for whitespace between keyword and open paren/curly, around assignment operator ('='), around arithmetic operators and after separators (',' and ';')Use space before open brace.
@@ -1015,10 +1015,10 @@
Use SupportsShouldProcess
- Commands typically provide Confirm and Whatif parameters to give more control on its execution in an interactive environment. In PowerShell, a command can use a SupportsShouldProcess attribute to provide this capability. Hence, manual addition of these parameters to a command is discouraged. If a commands need Confirm and Whatif parameters, then it should support ShouldProcess.
+ Commands typically provide Confirm and WhatIf parameters to give more control on its execution in an interactive environment. In PowerShell, a command can use a SupportsShouldProcess attribute to provide this capability. Hence, manual addition of these parameters to a command is discouraged. If a command needs Confirm and WhatIf parameters, then it should support ShouldProcess.
- Whatif and/or Confirm manually defined in function {0}. Instead, please use SupportsShouldProcess attribute.
+ WhatIf and/or Confirm manually defined in function {0}. Instead, please use SupportsShouldProcess attribute.AlignAssignmentStatement
@@ -1042,10 +1042,10 @@
Use a different variable name
- Changing automtic variables might have undesired side effects
+ Changing automatic variables might have undesired side effects
- This automatic variables is built into PowerShell and readonly.
+ This automatic variable is built into PowerShell and readonly.The Variable '{0}' cannot be assigned since it is a readonly automatic variable that is built into PowerShell, please use a different name.
@@ -1077,7 +1077,7 @@
PossibleIncorrectUsageOfRedirectionOperator
-
+
Use $null on the left hand side for safe comparison with $null.
@@ -1096,7 +1096,7 @@
Use exact casing of cmdlet/function/parameter name.
- For better readability and consistency, use the exact casing of the cmdlet/function/parameter.
+ For better readability and consistency, use consistent casing.Function/Cmdlet '{0}' does not match its exact casing '{1}'.
@@ -1104,6 +1104,15 @@
UseCorrectCasing
+
+ Keyword '{0}' does not match the expected case '{1}'.
+
+
+ Operator '{0}' does not match the expected case '{1}'.
+
+
+ Parameter '{0}' of function/cmdlet '{1}' does not match its exact casing '{2}'.
+
Use process block for command that accepts input from pipeline.
@@ -1129,7 +1138,7 @@
Ensure all parameters are used within the same script, scriptblock, or function where they are declared.
- The parameter '{0}' has been declared but not used.
+ The parameter '{0}' has been declared but not used.ReviewUnusedParameter
@@ -1168,7 +1177,7 @@
Avoid multiple type specifiers on parameters
- Prameter should not have more than one type specifier.
+ Parameter should not have more than one type specifier.Parameter '{0}' has more than one type specifier.
@@ -1188,9 +1197,6 @@
AvoidUsingBrokenHashAlgorithms
-
- Parameter '{0}' of function/cmdlet '{1}' does not match its exact casing '{2}'.
-
AvoidExclaimOperator
@@ -1213,9 +1219,129 @@
Avoid sending credentials and secrets over unencrypted connections.
- The insecure AllowUsingUnencryptedAuthentication switch was used. This should be avoided except for compatability with legacy systems.
+ The insecure AllowUnencryptedAuthentication switch was used. This should be avoided except for compatibility with legacy systems.AvoidUsingAllowUnencryptedAuthentication
-
\ No newline at end of file
+
+ Use Consistent Parameter Set Name
+
+
+ Parameter set names are case-sensitive in PowerShell. This rule checks for case mismatches between DefaultParameterSetName and ParameterSetName values, case mismatches between different ParameterSetName values, and missing DefaultParameterSetName when parameter sets are used.
+
+
+ Param block uses parameter sets but does not specify a DefaultParameterSetName. Consider adding DefaultParameterSetName to the CmdletBinding attribute.
+
+
+ DefaultParameterSetName '{0}' does not match the case of ParameterSetName '{1}'. Parameter set names are case-sensitive.
+
+
+ ParameterSetName '{0}' does not match the case of '{1}'. Parameter set names are case-sensitive and should use consistent casing.
+
+
+ Parameter '{0}' is declared in parameter-set '{1}' multiple times.
+
+
+ Parameter set names should not contain new lines.
+
+
+ Rename ParameterSet '{0}' to '{1}'.
+
+
+ UseConsistentParameterSetName
+
+
+ Avoid reserved words as function names
+
+
+ Avoid using reserved words as function names. Using reserved words as function names can cause errors or unexpected behavior in scripts.
+
+
+ AvoidReservedWordsAsFunctionNames
+
+
+ The reserved word '{0}' was used as a function name. This should be avoided.
+
+
+ UseConstrainedLanguageMode
+
+
+ Consider Constrained Language Mode Restrictions
+
+
+ Identifies script patterns that are restricted in Constrained Language Mode. Constrained Language Mode limits the types, cmdlets, and .NET methods that can be used to help secure PowerShell in environments requiring additional restrictions.
+
+
+ Add-Type is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment.
+
+
+ New-Object with the COM object '{0}' is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment.
+
+
+ XAML usage is not permitted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment.
+
+
+ Dot-sourcing may be restricted in Constrained Language Mode depending on the source location. Ensure scripts are from trusted locations if running in a restricted environment.
+
+
+ Invoke-Expression is restricted in Constrained Language Mode. Consider alternative approaches if this script will run in a restricted environment.
+
+
+ New-Object with type '{0}' is not permitted in Constrained Language Mode. Consider using an allowed type.
+
+
+ Type constraint [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type.
+
+
+ Type expression [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type.
+
+
+ Type cast [{0}] is not permitted in Constrained Language Mode. Consider using an allowed type.
+
+
+ Member '{1}' accessed on type [{0}] which is not permitted in Constrained Language Mode. Consider using an allowed type.
+
+
+ PowerShell class '{0}' is not permitted in Constrained Language Mode. Consider using alternative approaches such as hashtables or PSCustomObject.
+
+
+ Module manifest field '{0}' uses wildcard ('*') which is not recommended for Constrained Language Mode. Explicitly list exported items instead.
+
+
+ Module manifest field '{0}' contains script file '{1}' (.ps1). Use a module file (.psm1) or a binary module (.dll) instead for Constrained Language Mode compatibility.
+
+
+ Module manifest field 'ScriptsToProcess' contains script file '{0}' (.ps1). Scripts in ScriptsToProcess run in the caller's session state and are restricted in Constrained Language Mode. Consider moving this logic to module initialization code
+
+
+ [PSCustomObject]@{{}} syntax is not permitted in Constrained Language Mode. Use New-Object PSObject -Property @{{}} or plain hashtables instead.
+
+
+ Use a single ValueFromPipeline parameter per parameter set
+
+
+ Use at most a single ValueFromPipeline parameter per parameter set to avoid undefined or unexpected behaviour.
+
+
+ Multiple parameters ({0}) in parameter set '{1}' are marked as ValueFromPipeline. Only one parameter per parameter set should accept pipeline input.
+
+
+ UseSingleValueFromPipelineParameter
+
+
+ Use correct function parameters definition kind.
+
+
+ Use consistent parameters definition kind to prevent potential unexpected behavior with inline functions parameters or param() block.
+
+
+ UseConsistentParametersKind
+
+
+ Use param() block in function body instead of inline parameters.
+
+
+ Use inline parameters definition instead of param() block in function body.
+
+
diff --git a/Rules/UseConsistentIndentation.cs b/Rules/UseConsistentIndentation.cs
index bbb12bd41..41aa4ef4d 100644
--- a/Rules/UseConsistentIndentation.cs
+++ b/Rules/UseConsistentIndentation.cs
@@ -163,6 +163,7 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do
break;
case TokenKind.LParen:
+ AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine);
// When a line starts with a parenthesis and it is not the last non-comment token of that line,
// then indentation does not need to be increased.
if ((tokenIndex == 0 || tokens[tokenIndex - 1].Kind == TokenKind.NewLine) &&
@@ -173,7 +174,7 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do
break;
}
lParenSkippedIndentation.Push(false);
- AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine);
+ indentationLevel++;
break;
case TokenKind.Pipe:
diff --git a/Rules/UseConsistentParameterSetName.cs b/Rules/UseConsistentParameterSetName.cs
new file mode 100644
index 000000000..bf12f37e2
--- /dev/null
+++ b/Rules/UseConsistentParameterSetName.cs
@@ -0,0 +1,453 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Management.Automation.Language;
+#if !CORECLR
+using System.ComponentModel.Composition;
+#endif
+using System.Globalization;
+using System.Linq;
+using System.Management.Automation;
+using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
+
+namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
+{
+ ///
+ /// UseConsistentParameterSetName: Check for case-sensitive parameter set
+ /// name mismatches, missing default parameter set names, and parameter set
+ /// names containing new lines.
+ ///
+#if !CORECLR
+ [Export(typeof(IScriptRule))]
+#endif
+ public class UseConsistentParameterSetName : ConfigurableRule
+ {
+
+ private const string AllParameterSetsName = "__AllParameterSets";
+
+ ///
+ /// AnalyzeScript: Check for parameter set name issues.
+ ///
+ public override IEnumerable AnalyzeScript(Ast ast, string fileName)
+ {
+ if (ast == null)
+ {
+ throw new ArgumentNullException(Strings.NullAstErrorMessage);
+ }
+
+ var allParameterBlocks = ast
+ .FindAll(testAst => testAst is ParamBlockAst, true)
+ .Cast()
+ .Where(pb => pb.Parameters?.Count > 0);
+
+ foreach (var paramBlock in allParameterBlocks)
+ {
+ // If the paramblock has no parameters, skip it
+ if (paramBlock.Parameters.Count == 0)
+ {
+ continue;
+ }
+
+ // Get the CmdletBinding attribute and default parameter set name
+ // Or null if not present
+ var cmdletBindingAttr = Helper.Instance.GetCmdletBindingAttributeAst(paramBlock.Attributes);
+ var defaultParamSetName = GetNamedArgumentValue(cmdletBindingAttr, "DefaultParameterSetName");
+
+ // For each parameter block, build up a list of all the parameters
+ // and the parameter sets in which they appear.
+ List paramBlockInfo = new List();
+
+ foreach (var parameter in paramBlock.Parameters)
+ {
+ // If the parameter has no attributes, it is part of all
+ // parameter sets. We can ignore it for these checks.
+ if (parameter.Attributes.Count == 0)
+ {
+ continue;
+ }
+
+ // For each parameter attribute a parameter has, extract
+ // the parameter set and add it to our knowledge of the
+ // param block.
+ foreach (var attribute in parameter.Attributes.Where(attr => attr is AttributeAst).Cast())
+ {
+ if (string.Equals(attribute.TypeName?.Name, "Parameter", StringComparison.OrdinalIgnoreCase))
+ {
+ var parameterSetName = GetNamedArgumentValue(attribute, "ParameterSetName", AllParameterSetsName);
+ paramBlockInfo.Add(new ParameterSetInfo(parameter.Name.VariablePath.UserPath, parameterSetName, attribute));
+ }
+ }
+ }
+
+ // We now have a picture of the parameters and parameterset
+ // usage of this paramblock. We can make each check.
+
+ // Check 1: Default parameter set name
+ // -------------------------------------------------------------
+ // If we have parameter sets in use and the CmdletBinding
+ // attribute, but no default specified, warn about this.
+ if (string.IsNullOrEmpty(defaultParamSetName) &&
+ cmdletBindingAttr != null &&
+ paramBlockInfo.Any(p => p.ParameterSetName != AllParameterSetsName)
+ )
+ {
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameMissingDefaultError),
+ cmdletBindingAttr?.Extent ?? paramBlock.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName);
+ }
+
+ // Check 2: Parameter Declared Multiple Times in Same Set
+ // -------------------------------------------------------------
+ // If any parameter has more than one parameter attribute for
+ // the same parameterset, warn about each instance.
+ // Parameters cannot be declared multiple times in the same set.
+ // Calling a function that has a parameter declared multiple
+ // times in the same parameterset is a runtime exception -
+ // specifically a [System.Management.Automation.MetadataException]
+ // It'd be better to know before runtime.
+ // We use the same message text as the MetadataException for
+ // consistency
+ var duplicateAttributes = paramBlockInfo
+ .GroupBy(p => new { p.ParameterName, p.ParameterSetName })
+ .Where(g => g.Count() > 1)
+ .SelectMany(g => g);
+
+ foreach (var duplicate in duplicateAttributes)
+ {
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameMultipleDeclarationsError,
+ duplicate.ParameterName,
+ duplicate.ParameterSetName),
+ duplicate.ParameterAttributeAst.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName);
+ }
+
+ // Check 3: Validate Default Parameter Set
+ // -------------------------------------------------------------
+ // If a default parameter set is specified and matches one of
+ // the used parameter set names ignoring case, but not otherwise
+ // then we should warn about this
+ if (!string.IsNullOrEmpty(defaultParamSetName))
+ {
+ // Look for an exact (case-sensitive) match
+ var exactMatch = paramBlockInfo
+ .FirstOrDefault(p =>
+ string.Equals(
+ p.ParameterSetName,
+ defaultParamSetName,
+ StringComparison.Ordinal
+ )
+ );
+
+ if (exactMatch == null)
+ {
+ // No exact match, look for a case-insensitive match
+ var caseInsensitiveMatch = paramBlockInfo
+ .FirstOrDefault(p =>
+ string.Equals(
+ p.ParameterSetName,
+ defaultParamSetName,
+ StringComparison.OrdinalIgnoreCase
+ )
+ );
+
+ if (caseInsensitiveMatch != null)
+ {
+ var defaultParameterSetNameExtents = GetDefaultParameterSetNameValueExtent(cmdletBindingAttr);
+
+ // Emit a diagnostic for the first case-insensitive match
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameCaseMismatchDefaultError,
+ defaultParamSetName,
+ caseInsensitiveMatch.ParameterSetName),
+ defaultParameterSetNameExtents ?? cmdletBindingAttr?.Extent ?? paramBlock.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName);
+ }
+ }
+ }
+
+ // Check 4: Parameter Set Name Consistency
+ // -------------------------------------------------------------
+ // If a parameter set name is used in multiple places, it must
+ // be consistently used across all usages. This means the casing
+ // must match exactly. We should warn about any inconsistencies
+ // found.
+ var paramSetGroups = paramBlockInfo
+ .GroupBy(p => p.ParameterSetName, StringComparer.OrdinalIgnoreCase)
+ .Where(g =>
+ g.Select(p => p.ParameterSetName)
+ .Distinct(StringComparer.Ordinal)
+ .Skip(1).Any()
+ );
+
+ foreach (var group in paramSetGroups)
+ {
+ // Take the first instance as the canonical casing
+ var canonical = group.First();
+ foreach (var entry in group.Skip(1))
+ {
+ if (!string.Equals(
+ entry.ParameterSetName,
+ canonical.ParameterSetName,
+ StringComparison.Ordinal
+ )
+ )
+ {
+ var parameterSetNameExtents = GetParameterSetNameValueExtent(entry.ParameterAttributeAst);
+
+ if (parameterSetNameExtents != null)
+ {
+ var correction = new CorrectionExtent(
+ parameterSetNameExtents.StartLineNumber,
+ parameterSetNameExtents.EndLineNumber,
+ parameterSetNameExtents.StartColumnNumber,
+ parameterSetNameExtents.EndColumnNumber,
+ $"'{canonical.ParameterSetName}'",
+ fileName,
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameCaseMismatchSuggestedCorrectionDescription,
+ entry.ParameterSetName,
+ canonical.ParameterSetName
+ )
+ );
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameCaseMismatchParameterError,
+ entry.ParameterSetName,
+ canonical.ParameterSetName),
+ parameterSetNameExtents,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName,
+ null,
+ new List { correction });
+ }
+ else
+ {
+ // If we couldn't find the parameter set name extents, we can't create a correction
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameCaseMismatchParameterError,
+ entry.ParameterSetName,
+ canonical.ParameterSetName),
+ entry.ParameterAttributeAst.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName);
+ }
+ }
+ }
+ }
+
+ // Check 5: Parameter Set Names should not contain New Lines
+ // -------------------------------------------------------------
+ // There is no practical purpose for parameterset names to
+ // contain a newline
+ foreach (var entry in paramBlockInfo)
+ {
+ if (entry.ParameterSetName.Contains('\n') || entry.ParameterSetName.Contains('\r'))
+ {
+ var parameterSetNameExtents = GetParameterSetNameValueExtent(entry.ParameterAttributeAst);
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameNewLineError),
+ parameterSetNameExtents ?? entry.ParameterAttributeAst.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName);
+ }
+ }
+ if (defaultParamSetName != null &&
+ (defaultParamSetName.Contains('\n') || defaultParamSetName.Contains('\r')))
+ {
+ // If the default parameter set name contains new lines, warn about it
+ var defaultParameterSetNameExtents = GetDefaultParameterSetNameValueExtent(cmdletBindingAttr);
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameNewLineError,
+ defaultParamSetName),
+ defaultParameterSetNameExtents ?? cmdletBindingAttr?.Extent ?? paramBlock.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName);
+ }
+
+ }
+ }
+
+ ///
+ /// Retrieves the value of a named argument from an AttributeAst's NamedArguments collection.
+ /// If the named argument is not found, returns the provided default value.
+ /// If the argument value is a constant, returns its string representation; otherwise, returns the argument's text.
+ ///
+ /// The AttributeAst to search for the named argument.
+ /// The name of the argument to look for (case-insensitive).
+ /// The value to return if the named argument is not found. Defaults to null.
+ ///
+ /// The value of the named argument as a string if found; otherwise, the default value.
+ ///
+ private static string GetNamedArgumentValue(AttributeAst attributeAst, string argumentName, string defaultValue = null)
+ {
+ if (attributeAst == null || attributeAst.NamedArguments == null)
+ {
+ return defaultValue;
+ }
+
+ foreach (var namedArg in attributeAst.NamedArguments)
+ {
+ if (namedArg?.ArgumentName == null) continue;
+
+ if (string.Equals(namedArg.ArgumentName, argumentName, StringComparison.OrdinalIgnoreCase))
+ {
+ // Try to evaluate the argument value as a constant string
+ if (namedArg.Argument is ConstantExpressionAst constAst)
+ {
+ return constAst.Value?.ToString();
+ }
+ // If not a constant, try to get the string representation
+ return namedArg.Argument.Extent.Text;
+ }
+ }
+ return defaultValue;
+ }
+
+ ///
+ /// Finds the IScriptExtent of the value assigned to the ParameterSetName argument
+ /// in the given AttributeAst (if it is a [Parameter()] attribute).
+ /// Returns null if not found.
+ ///
+ /// The AttributeAst to search.
+ /// The IScriptExtent of the ParameterSetName value, or null if not found.
+ private static IScriptExtent GetParameterSetNameValueExtent(AttributeAst attributeAst)
+ {
+ return GetAttributeNamedArgumentValueExtent(attributeAst, "ParameterSetName", "Parameter");
+ }
+
+ ///
+ /// Finds the IScriptExtent of the value assigned to the DefaultParameterSetName argument
+ /// in the given AttributeAst (if it is a [CmdletBinding()] attribute).
+ /// Returns null if not found.
+ ///
+ /// The AttributeAst to search.
+ /// The IScriptExtent of the DefaultParameterSetName value, or null if not found.
+ private static IScriptExtent GetDefaultParameterSetNameValueExtent(AttributeAst attributeAst)
+ {
+ return GetAttributeNamedArgumentValueExtent(attributeAst, "DefaultParameterSetName", "CmdletBinding");
+ }
+
+ ///
+ /// Finds the IScriptExtent of the value of a named argument in the given AttributeAst.
+ /// Returns null if not found.
+ ///
+ /// The AttributeAst to search.
+ /// The name of the argument to find.
+ /// The expected type name of the attribute. i.e. Parameter (optional).
+ /// The IScriptExtent of the named argument value, or null if not found.
+ private static IScriptExtent GetAttributeNamedArgumentValueExtent(AttributeAst attributeAst, string argumentName, string expectedAttributeName = null)
+ {
+ if (attributeAst == null || attributeAst.NamedArguments == null)
+ return null;
+
+ if (!string.IsNullOrEmpty(expectedAttributeName) &&
+ !string.Equals(
+ attributeAst.TypeName?.Name,
+ expectedAttributeName,
+ StringComparison.OrdinalIgnoreCase)
+ )
+ return null;
+
+ foreach (var namedArg in attributeAst.NamedArguments)
+ {
+ if (string.Equals(namedArg.ArgumentName, argumentName, StringComparison.OrdinalIgnoreCase))
+ {
+ return namedArg.Argument?.Extent;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Represents information about a parameter and its parameter set.
+ ///
+ private class ParameterSetInfo
+ {
+ public string ParameterName { get; }
+ public string ParameterSetName { get; }
+ public AttributeAst ParameterAttributeAst { get; }
+
+ public ParameterSetInfo(string parameterName, string parameterSetName, AttributeAst parameterAttributeAst)
+ {
+ ParameterName = parameterName;
+ ParameterSetName = parameterSetName;
+ ParameterAttributeAst = parameterAttributeAst;
+ }
+ }
+
+ ///
+ /// GetName: Retrieves the name of this rule.
+ ///
+ /// The name of this rule
+ public override string GetName() => string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.NameSpaceFormat,
+ GetSourceName(),
+ Strings.UseConsistentParameterSetNameName
+ );
+
+ ///
+ /// GetCommonName: Retrieves the common name of this rule.
+ ///
+ /// The common name of this rule
+ public override string GetCommonName() => string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameCommonName
+ );
+
+ ///
+ /// GetDescription: Retrieves the description of this rule.
+ ///
+ /// The description of this rule
+ public override string GetDescription() => string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseConsistentParameterSetNameDescription
+ );
+
+ ///
+ /// Method: Retrieves the type of the rule: builtin, managed or module.
+ ///
+ public override SourceType GetSourceType() => SourceType.Builtin;
+
+ ///
+ /// GetSeverity: Retrieves the severity of the rule: error, warning of information.
+ ///
+ ///
+ public override RuleSeverity GetSeverity() => RuleSeverity.Warning;
+
+ ///
+ /// Method: Retrieves the module/assembly name the rule is from.
+ ///
+ public override string GetSourceName() => string.Format(
+ CultureInfo.CurrentCulture, Strings.SourceName
+ );
+ }
+}
diff --git a/Rules/UseConsistentParametersKind.cs b/Rules/UseConsistentParametersKind.cs
new file mode 100644
index 000000000..fd2dfa732
--- /dev/null
+++ b/Rules/UseConsistentParametersKind.cs
@@ -0,0 +1,171 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+#if !CORECLR
+using System.ComponentModel.Composition;
+#endif
+using System.Globalization;
+using System.Linq;
+using System.Management.Automation.Language;
+using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
+
+namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
+{
+ ///
+ /// UseConsistentParametersKind: Checks if function parameters definition kind is same as preferred.
+ ///
+#if !CORECLR
+ [Export(typeof(IScriptRule))]
+#endif
+ public class UseConsistentParametersKind : ConfigurableRule
+ {
+ private enum ParametersDefinitionKind
+ {
+ Inline,
+ ParamBlock
+ }
+
+ private ParametersDefinitionKind parametersKind;
+
+ ///
+ /// Construct an object of UseConsistentParametersKind type.
+ ///
+ public UseConsistentParametersKind() : base()
+ {
+ Enable = false; // Disable rule by default
+ }
+
+ ///
+ /// The type of preferred parameters definition for functions.
+ ///
+ /// Default value is "ParamBlock".
+ ///
+ [ConfigurableRuleProperty(defaultValue: "ParamBlock")]
+ public string ParametersKind
+ {
+ get
+ {
+ return parametersKind.ToString();
+ }
+ set
+ {
+ if (String.IsNullOrWhiteSpace(value) ||
+ !Enum.TryParse(value, true, out parametersKind))
+ {
+ parametersKind = ParametersDefinitionKind.ParamBlock;
+ }
+ }
+ }
+
+ ///
+ /// AnalyzeScript: Analyze the script to check if any function is using not preferred parameters kind.
+ ///
+ public override IEnumerable AnalyzeScript(Ast ast, string fileName)
+ {
+ if (ast == null) { throw new ArgumentNullException(Strings.NullAstErrorMessage); }
+
+ IEnumerable functionAsts = ast.FindAll(testAst => testAst is FunctionDefinitionAst, true);
+ if (parametersKind == ParametersDefinitionKind.ParamBlock)
+ {
+ return checkInlineParameters(functionAsts, fileName);
+ }
+ else
+ {
+ return checkParamBlockParameters(functionAsts, fileName);
+ }
+ }
+
+ private IEnumerable checkInlineParameters(IEnumerable functionAsts, string fileName)
+ {
+ foreach (FunctionDefinitionAst functionAst in functionAsts)
+ {
+ if (functionAst.Parameters != null)
+ {
+ yield return new DiagnosticRecord(
+ string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindInlineError, functionAst.Name),
+ functionAst.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ );
+ }
+ }
+ }
+
+ private IEnumerable checkParamBlockParameters(IEnumerable functionAsts, string fileName)
+ {
+ foreach (FunctionDefinitionAst functionAst in functionAsts)
+ {
+ if (functionAst.Body.ParamBlock != null)
+ {
+ yield return new DiagnosticRecord(
+ string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindParamBlockError, functionAst.Name),
+ functionAst.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ );
+ }
+ }
+ }
+
+ ///
+ /// Retrieves the common name of this rule.
+ ///
+ public override string GetCommonName()
+ {
+ return string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindCommonName);
+ }
+
+ ///
+ /// Retrieves the description of this rule.
+ ///
+ public override string GetDescription()
+ {
+ return string.Format(CultureInfo.CurrentCulture, Strings.UseConsistentParametersKindDescription);
+ }
+
+ ///
+ /// Retrieves the name of this rule.
+ ///
+ public override string GetName()
+ {
+ return string.Format(CultureInfo.CurrentCulture, Strings.NameSpaceFormat, GetSourceName(), Strings.UseConsistentParametersKindName);
+ }
+
+ ///
+ /// Retrieves the severity of the rule: error, warning or information.
+ ///
+ public override RuleSeverity GetSeverity()
+ {
+ return RuleSeverity.Warning;
+ }
+
+ ///
+ /// Gets the severity of the returned diagnostic record: error, warning, or information.
+ ///
+ ///
+ public DiagnosticSeverity GetDiagnosticSeverity()
+ {
+ return DiagnosticSeverity.Warning;
+ }
+
+ ///
+ /// Retrieves the name of the module/assembly the rule is from.
+ ///
+ public override string GetSourceName()
+ {
+ return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
+ }
+
+ ///
+ /// Retrieves the type of the rule, Builtin, Managed or Module.
+ ///
+ public override SourceType GetSourceType()
+ {
+ return SourceType.Builtin;
+ }
+ }
+}
diff --git a/Rules/UseConsistentWhitespace.cs b/Rules/UseConsistentWhitespace.cs
index a062e5d3f..7f7550ffe 100644
--- a/Rules/UseConsistentWhitespace.cs
+++ b/Rules/UseConsistentWhitespace.cs
@@ -257,6 +257,9 @@ private IEnumerable FindOpenBraceViolations(TokenOperations to
private IEnumerable FindInnerBraceViolations(TokenOperations tokenOperations)
{
+ // Ranges which represent braced member access. Tokens within these ranges should be
+ // excluded from formatting.
+ var exclusionRanges = tokenOperations.GetBracedMemberAccessRanges();
foreach (var lCurly in tokenOperations.GetTokenNodes(TokenKind.LCurly))
{
if (lCurly.Next == null
@@ -264,6 +267,10 @@ private IEnumerable FindInnerBraceViolations(TokenOperations t
|| lCurly.Next.Value.Kind == TokenKind.NewLine
|| lCurly.Next.Value.Kind == TokenKind.LineContinuation
|| lCurly.Next.Value.Kind == TokenKind.RCurly
+ || exclusionRanges.Any(range =>
+ lCurly.Value.Extent.StartOffset >= range.Item1 &&
+ lCurly.Value.Extent.EndOffset <= range.Item2
+ )
)
{
continue;
@@ -290,6 +297,10 @@ private IEnumerable FindInnerBraceViolations(TokenOperations t
|| rCurly.Previous.Value.Kind == TokenKind.NewLine
|| rCurly.Previous.Value.Kind == TokenKind.LineContinuation
|| rCurly.Previous.Value.Kind == TokenKind.AtCurly
+ || exclusionRanges.Any(range =>
+ rCurly.Value.Extent.StartOffset >= range.Item1 &&
+ rCurly.Value.Extent.EndOffset <= range.Item2
+ )
)
{
continue;
@@ -421,8 +432,8 @@ private IEnumerable FindParameterViolations(Ast ast)
{
int numberOfRedundantWhiteSpaces = rightExtent.StartColumnNumber - expectedStartColumnNumberOfRightExtent;
var correction = new CorrectionExtent(
- startLineNumber: leftExtent.StartLineNumber,
- endLineNumber: leftExtent.EndLineNumber,
+ startLineNumber: leftExtent.EndLineNumber,
+ endLineNumber: rightExtent.StartLineNumber,
startColumnNumber: leftExtent.EndColumnNumber + 1,
endColumnNumber: leftExtent.EndColumnNumber + 1 + numberOfRedundantWhiteSpaces,
text: string.Empty,
@@ -451,6 +462,7 @@ private IEnumerable FindSeparatorViolations(TokenOperations to
{
return node.Next != null
&& node.Next.Value.Kind != TokenKind.NewLine
+ && node.Next.Value.Kind != TokenKind.Comment
&& node.Next.Value.Kind != TokenKind.EndOfInput // semicolon can be followed by end of input
&& !IsPreviousTokenApartByWhitespace(node.Next);
};
diff --git a/Rules/UseConstrainedLanguageMode.cs b/Rules/UseConstrainedLanguageMode.cs
new file mode 100644
index 000000000..7d73dd5eb
--- /dev/null
+++ b/Rules/UseConstrainedLanguageMode.cs
@@ -0,0 +1,1101 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Management.Automation.Language;
+using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
+using System.Management.Automation;
+
+#if !CORECLR
+using System.ComponentModel.Composition;
+#endif
+using System.Globalization;
+
+namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
+{
+ ///
+ /// UseConstrainedLanguageMode: Checks for patterns that indicate Constrained Language Mode should be considered.
+ ///
+#if !CORECLR
+ [Export(typeof(IScriptRule))]
+#endif
+ public class UseConstrainedLanguageMode : ConfigurableRule
+ {
+ // Allowed COM objects in Constrained Language Mode
+ private static readonly HashSet AllowedComObjects = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Scripting.Dictionary",
+ "Scripting.FileSystemObject",
+ "VBScript.RegExp"
+ };
+
+ // Allowed types in Constrained Language Mode (type accelerators and common types)
+ private static readonly HashSet AllowedTypes = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "adsi", "adsisearcher", "Alias", "AllowEmptyCollection", "AllowEmptyString",
+ "AllowNull", "ArgumentCompleter", "ArgumentCompletions", "array", "bigint",
+ "bool", "byte", "char", "cimclass", "cimconverter", "ciminstance", "CimSession",
+ "cimtype", "CmdletBinding", "cultureinfo", "datetime", "decimal", "double",
+ "DscLocalConfigurationManager", "DscProperty", "DscResource", "ExperimentAction",
+ "Experimental", "ExperimentalFeature", "float", "guid", "hashtable", "int",
+ "int16", "int32", "int64", "ipaddress", "IPEndpoint", "long", "mailaddress",
+ "Microsoft.PowerShell.Commands.ModuleSpecification", "NoRunspaceAffinity",
+ "NullString", "Object", "ObjectSecurity", "ordered", "OutputType", "Parameter",
+ "PhysicalAddress", "pscredential", "pscustomobject", "PSDefaultValue",
+ "pslistmodifier", "psobject", "psprimitivedictionary", "PSTypeNameAttribute",
+ "regex", "sbyte", "securestring", "semver", "short", "single", "string",
+ "SupportsWildcards", "switch", "timespan", "uint", "uint16", "uint32", "uint64",
+ "ulong", "uri", "ushort", "ValidateCount", "ValidateDrive", "ValidateLength",
+ "ValidateNotNull", "ValidateNotNullOrEmpty", "ValidateNotNullOrWhiteSpace",
+ "ValidatePattern", "ValidateRange", "ValidateScript", "ValidateSet",
+ "ValidateTrustedData", "ValidateUserDrive", "version", "void", "WildcardPattern",
+ "wmi", "wmiclass", "wmisearcher", "X500DistinguishedName", "X509Certificate", "xml",
+ // Full type names for common allowed types
+ "System.Object", "System.String", "System.Int32", "System.Boolean", "System.Byte",
+ "System.Collections.Hashtable", "System.DateTime", "System.Version", "System.Uri",
+ "System.Guid", "System.TimeSpan", "System.Management.Automation.PSCredential",
+ "System.Management.Automation.PSObject", "System.Security.SecureString",
+ "System.Text.RegularExpressions.Regex", "System.Xml.XmlDocument",
+ "System.Collections.ArrayList", "System.Collections.Generic.List",
+ "System.Net.IPAddress", "System.Net.Mail.MailAddress"
+ };
+
+ ///
+ /// Cache for typed variable assignments per scope to avoid O(N*M) performance issues.
+ /// Key: Scope AST (FunctionDefinitionAst or ScriptBlockAst)
+ /// Value: Dictionary mapping variable names to their type names
+ ///
+ private Dictionary> _typedVariableCache;
+
+ ///
+ /// When True, ignores the presence of script signature blocks and runs all CLM checks
+ /// regardless of whether a script appears to be signed.
+ /// When False (default), scripts that contain a PowerShell signature block (for example,
+ /// one starting with '# SIG # Begin signature block') are treated as having elevated
+ /// permissions for this rule and only critical checks (dot-sourcing, parameter types,
+ /// manifests) are performed. No cryptographic validation or trust evaluation of the
+ /// signature is performed.
+ ///
+ [ConfigurableRuleProperty(defaultValue: false)]
+ public bool IgnoreSignatures { get; set; }
+
+ public UseConstrainedLanguageMode()
+ {
+ // This rule is disabled by default - users must explicitly enable it
+ Enable = false;
+
+ // IgnoreSignatures defaults to false (respects signatures)
+ IgnoreSignatures = false;
+ }
+
+ ///
+ /// Checks if a type name is allowed in Constrained Language Mode
+ ///
+ private bool IsTypeAllowed(string typeName)
+ {
+ if (string.IsNullOrWhiteSpace(typeName))
+ {
+ return true; // Can't determine, so don't flag
+ }
+
+ // Handle array types (e.g., string[], System.String[], int[][])
+ // Strip array brackets and check the base type
+ string baseTypeName = typeName;
+
+
+ // Handle multi-dimensional or jagged arrays by removing all brackets
+ while (baseTypeName.EndsWith("[]", StringComparison.Ordinal))
+ {
+ baseTypeName = baseTypeName.Substring(0, baseTypeName.Length - 2);
+ }
+
+
+ // Check exact match first
+ if (AllowedTypes.Contains(baseTypeName))
+ {
+ return true;
+ }
+
+ // Check simple name (last part after last dot)
+ if (baseTypeName.Contains('.'))
+ {
+ var simpleTypeName = baseTypeName.Substring(baseTypeName.LastIndexOf('.') + 1);
+ if (AllowedTypes.Contains(simpleTypeName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Analyzes the script to check for patterns that may require Constrained Language Mode.
+ ///
+ public override IEnumerable AnalyzeScript(Ast ast, string fileName)
+ {
+ if (ast == null)
+ {
+ throw new ArgumentNullException(nameof(ast));
+ }
+
+ // Initialize cache for this analysis to avoid O(N*M) performance issues
+ _typedVariableCache = new Dictionary>();
+
+ var diagnosticRecords = new List();
+
+ // Check if the file is signed (via signature block detection)
+ bool isFileSigned = IgnoreSignatures ? false : IsScriptSigned(fileName);
+
+ // Note: If IgnoreSignatures is true, isFileSigned will always be false,
+ // causing all CLM checks to run regardless of actual signature status
+
+ // Check if this is a module manifest (.psd1 file)
+ bool isModuleManifest = fileName != null && fileName.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase);
+
+ if (isModuleManifest)
+ {
+ // Perform PSD1-specific checks
+ // These checks are ALWAYS enforced, even for signed scripts
+ CheckModuleManifest(ast, fileName, diagnosticRecords);
+ }
+
+ // For signed scripts, only check specific patterns that are still restricted
+ // (unless IgnoreSignatures is true, then this block is skipped)
+ if (isFileSigned)
+ {
+ // Even signed scripts have these restrictions in CLM:
+
+ // 1. Check for dot-sourcing (still restricted in CLM even for signed scripts)
+ CheckDotSourcing(ast, fileName, diagnosticRecords);
+
+ // 2. Check for type constraints on parameters (still need to be validated)
+ CheckParameterTypeConstraints(ast, fileName, diagnosticRecords);
+
+ return diagnosticRecords;
+ }
+
+ // For unsigned scripts (or when IgnoreSignatures is true), perform all CLM checks
+ CheckAllClmRestrictions(ast, fileName, diagnosticRecords);
+
+ return diagnosticRecords;
+ }
+
+ ///
+ /// Checks if a PowerShell script file appears to be digitally signed.
+ /// Note: This performs a simple text check for the signature block marker.
+ /// It does NOT validate signature authenticity, certificate trust, or file integrity.
+ /// For production use, PowerShell's execution policy and Get-AuthenticodeSignature
+ /// should be used to properly validate signatures.
+ ///
+ private bool IsScriptSigned(string fileName)
+ {
+ if (string.IsNullOrEmpty(fileName) || !System.IO.File.Exists(fileName))
+ {
+ return false;
+ }
+
+ // Only check .ps1, .psm1, and .psd1 files
+ string extension = System.IO.Path.GetExtension(fileName);
+ if (!extension.Equals(".ps1", StringComparison.OrdinalIgnoreCase) &&
+ !extension.Equals(".psm1", StringComparison.OrdinalIgnoreCase) &&
+ !extension.Equals(".psd1", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ try
+ {
+ // Read the file content
+ string content = System.IO.File.ReadAllText(fileName);
+
+ // Check for signature block marker
+ // A signed PowerShell script contains a signature block that starts with:
+ // # SIG # Begin signature block
+ //
+ // IMPORTANT: This is a simple text check only. It does NOT validate:
+ // - Signature authenticity
+ // - Certificate validity or trust
+ // - File integrity (hash matching)
+ // - Certificate expiration
+ //
+ // This check assumes that if a signature block is present, the script
+ // was intended to be signed. Actual signature validation is performed
+ // by PowerShell at execution time based on execution policy.
+ return content.IndexOf("# SIG # Begin signature block", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+ catch
+ {
+ // If we can't read the file, assume it's not signed
+ return false;
+ }
+ }
+
+ ///
+ /// Performs all CLM restriction checks (for unsigned scripts).
+ ///
+ private void CheckAllClmRestrictions(Ast ast, string fileName, List diagnosticRecords)
+ {
+ var addTypeCommands = ast.FindAll(testAst =>
+ testAst is CommandAst cmdAst &&
+ cmdAst.GetCommandName() != null &&
+ cmdAst.GetCommandName().Equals("Add-Type", StringComparison.OrdinalIgnoreCase),
+ true);
+
+ foreach (CommandAst cmd in addTypeCommands)
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeAddTypeError),
+ cmd.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+
+ // Check for New-Object with COM objects and TypeName (only specific ones are allowed in CLM)
+ var newObjectCommands = ast.FindAll(testAst =>
+ testAst is CommandAst cmdAst &&
+ cmdAst.GetCommandName() != null &&
+ cmdAst.GetCommandName().Equals("New-Object", StringComparison.OrdinalIgnoreCase),
+ true);
+
+ foreach (CommandAst cmd in newObjectCommands)
+ {
+ // Use StaticParameterBinder to reliably get parameter values
+ var bindingResult = StaticParameterBinder.BindCommand(cmd, true);
+
+ // Check for -ComObject parameter
+ if (bindingResult.BoundParameters.ContainsKey("ComObject"))
+ {
+ string comObjectValue = null;
+
+ // Try to get the value from the AST directly first
+ if (bindingResult.BoundParameters["ComObject"].Value is StringConstantExpressionAst strAst)
+ {
+ comObjectValue = strAst.Value;
+ }
+ else
+ {
+ // Fall back to ConstantValue
+ comObjectValue = bindingResult.BoundParameters["ComObject"].ConstantValue as string;
+ }
+
+ // Only flag if COM object name was found AND it's not in the allowed list
+ if (!string.IsNullOrWhiteSpace(comObjectValue) && !AllowedComObjects.Contains(comObjectValue))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeComObjectError,
+ comObjectValue),
+ cmd.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+
+ // Check for -TypeName parameter
+ if (bindingResult.BoundParameters.ContainsKey("TypeName"))
+ {
+ var typeNameValue = bindingResult.BoundParameters["TypeName"].ConstantValue as string;
+
+ // If ConstantValue is null, try to extract from the AST Value
+ if (typeNameValue == null && bindingResult.BoundParameters["TypeName"].Value is StringConstantExpressionAst typeStrAst)
+ {
+ typeNameValue = typeStrAst.Value;
+ }
+
+ // Only flag if type name was found AND it's not in the allowed list
+ if (!string.IsNullOrWhiteSpace(typeNameValue) && !IsTypeAllowed(typeNameValue))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeNewObjectError,
+ typeNameValue),
+ cmd.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+
+ // Check for XAML usage (not allowed in Constrained Language Mode)
+ var xamlPatterns = ast.FindAll(testAst =>
+ testAst is StringConstantExpressionAst strAst &&
+ strAst.Value.Contains("<") && strAst.Value.Contains("xmlns"),
+ true);
+
+ foreach (StringConstantExpressionAst xamlAst in xamlPatterns)
+ {
+ if (xamlAst.Value.Contains("http://schemas.microsoft.com/winfx"))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeXamlError),
+ xamlAst.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+
+ // Check for dot-sourcing (also called separately for signed scripts)
+ CheckDotSourcing(ast, fileName, diagnosticRecords);
+
+ // Check for Invoke-Expression usage (restricted in Constrained Language Mode)
+ var invokeExpressionCommands = ast.FindAll(testAst =>
+ testAst is CommandAst cmdAst &&
+ cmdAst.GetCommandName() != null &&
+ cmdAst.GetCommandName().Equals("Invoke-Expression", StringComparison.OrdinalIgnoreCase),
+ true);
+
+ foreach (CommandAst cmd in invokeExpressionCommands)
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeInvokeExpressionError),
+ cmd.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+
+ // Check for class definitions (not allowed in Constrained Language Mode)
+ var classDefinitions = ast.FindAll(testAst =>
+ testAst is TypeDefinitionAst typeAst && typeAst.IsClass,
+ true);
+
+ foreach (TypeDefinitionAst classDef in classDefinitions)
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeClassError,
+ classDef.Name),
+ classDef.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+
+ // Check for parameter type constraints (also called separately for signed scripts)
+ CheckParameterTypeConstraints(ast, fileName, diagnosticRecords);
+
+ // Check for disallowed type constraints on variables (e.g., [System.Net.WebClient]$client)
+ var typeConstraints = ast.FindAll(testAst =>
+ testAst is TypeConstraintAst typeConstraint &&
+ !(typeConstraint.Parent is ParameterAst), // Exclude parameters - handled above
+ true);
+
+ foreach (TypeConstraintAst typeConstraint in typeConstraints)
+ {
+ var typeName = typeConstraint.TypeName.FullName;
+ if (!IsTypeAllowed(typeName))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeConstrainedTypeError,
+ typeName),
+ typeConstraint.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+
+ // Check for disallowed type expressions and casts (e.g., [System.Net.WebClient]::new() or $x -as [Type])
+ var typeExpressions = ast.FindAll(testAst => testAst is TypeExpressionAst, true);
+ foreach (TypeExpressionAst typeExpr in typeExpressions)
+ {
+ var typeName = typeExpr.TypeName.FullName;
+ if (!IsTypeAllowed(typeName))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeTypeExpressionError,
+ typeName),
+ typeExpr.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+
+ // Check for convert expressions (e.g., $x = [System.Net.WebClient]$value)
+ var convertExpressions = ast.FindAll(testAst => testAst is ConvertExpressionAst, true);
+ foreach (ConvertExpressionAst convertExpr in convertExpressions)
+ {
+ var typeName = convertExpr.Type.TypeName.FullName;
+
+ // Special case: [PSCustomObject]@{} is not allowed in CLM
+ // Even though PSCustomObject is an allowed type for parameters,
+ // the type cast syntax with hashtable literal is blocked in CLM
+ if (typeName.Equals("PSCustomObject", StringComparison.OrdinalIgnoreCase) &&
+ convertExpr.Child is HashtableAst)
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModePSCustomObjectError),
+ convertExpr.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ continue; // Already flagged, skip general type check
+ }
+
+ if (!IsTypeAllowed(typeName))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeConvertExpressionError,
+ typeName),
+ convertExpr.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+
+ // Check for member invocations on disallowed types
+ // This includes method calls and property access on variables with type constraints
+ var memberInvocations = ast.FindAll(testAst =>
+ testAst is InvokeMemberExpressionAst || testAst is MemberExpressionAst, true);
+
+ foreach (Ast memberAst in memberInvocations)
+ {
+ // Skip static member access - already handled by TypeExpressionAst check
+ if (memberAst is InvokeMemberExpressionAst invokeAst && invokeAst.Static)
+ {
+ continue;
+ }
+
+ if (memberAst is MemberExpressionAst memAst && memAst.Static)
+ {
+ continue;
+ }
+
+ // Get the expression being invoked on (e.g., the variable in $var.Method())
+ ExpressionAst targetExpr = memberAst is InvokeMemberExpressionAst invExpr
+ ? invExpr.Expression
+ : ((MemberExpressionAst)memberAst).Expression;
+
+ // Check if the target has a type constraint
+ string constrainedType = GetTypeConstraintFromExpression(targetExpr);
+ if (!string.IsNullOrWhiteSpace(constrainedType) && !IsTypeAllowed(constrainedType))
+ {
+ string memberName = memberAst is InvokeMemberExpressionAst inv
+ ? (inv.Member as StringConstantExpressionAst)?.Value ?? ""
+ : ((memberAst as MemberExpressionAst).Member as StringConstantExpressionAst)?.Value ?? "";
+
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeMemberAccessError,
+ constrainedType,
+ memberName),
+ memberAst.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+
+ ///
+ /// Checks for dot-sourcing patterns which are restricted in CLM even for signed scripts.
+ ///
+ private void CheckDotSourcing(Ast ast, string fileName, List diagnosticRecords)
+ {
+ // Dot-sourcing is detected by looking for commands where the extent text starts with a dot
+ // Example: . $PSScriptRoot\Helper.ps1
+ // Example: . .\script.ps1
+ // PowerShell doesn't have a specific DotSourceExpressionAst, so we check the command extent
+ var commands = ast.FindAll(testAst => testAst is CommandAst, true);
+
+ foreach (CommandAst cmdAst in commands)
+ {
+ // Check if the command extent starts with a dot followed by whitespace
+ // This indicates dot-sourcing
+ string extentText = cmdAst.Extent.Text.TrimStart();
+ if (extentText.StartsWith(".") && extentText.Length > 1 && char.IsWhiteSpace(extentText[1]))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDotSourceError),
+ cmdAst.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+
+ ///
+ /// Checks parameter type constraints which need validation even for signed scripts.
+ ///
+ private void CheckParameterTypeConstraints(Ast ast, string fileName, List diagnosticRecords)
+ {
+ // Find all parameter definitions
+ var parameters = ast.FindAll(testAst => testAst is ParameterAst, true);
+
+ foreach (ParameterAst param in parameters)
+ {
+ // Check for type constraints on parameters
+ var typeConstraints = param.Attributes.OfType();
+
+ foreach (var typeConstraint in typeConstraints)
+ {
+ var typeName = typeConstraint.TypeName.FullName;
+ if (!IsTypeAllowed(typeName))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeConstrainedTypeError,
+ typeName),
+ typeConstraint.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+ }
+
+ ///
+ /// Attempts to determine if an expression has a type constraint.
+ /// Returns the type name if found, otherwise null.
+ ///
+ private string GetTypeConstraintFromExpression(ExpressionAst expr)
+ {
+ if (expr == null)
+ {
+ return null;
+ }
+
+ // Check if this is a convert expression with a type (e.g., [Type]$var)
+ if (expr is ConvertExpressionAst convertExpr)
+ {
+ return convertExpr.Type.TypeName.FullName;
+ }
+
+ // Check if this is a variable expression
+ if (expr is VariableExpressionAst varExpr)
+ {
+ // Walk up the AST to find if this variable has a type constraint in a parameter
+ var parameterAst = FindParameterForVariable(varExpr);
+ if (parameterAst != null)
+ {
+ // Get the first type constraint attribute
+ var typeConstraint = parameterAst.Attributes
+ .OfType()
+ .FirstOrDefault();
+
+ if (typeConstraint != null)
+ {
+ return typeConstraint.TypeName.FullName;
+ }
+ }
+
+ // Check if the variable was declared with a type constraint elsewhere
+ // Look for assignment statements with type constraints
+ var assignmentWithType = FindTypedAssignment(varExpr);
+ if (assignmentWithType != null)
+ {
+ return assignmentWithType;
+ }
+ }
+
+ // Check if this is a member expression that might have a known return type
+ // For now, we'll be conservative and only check direct type constraints
+
+ return null;
+ }
+
+ ///
+ /// Finds the parameter AST for a given variable expression, if it exists.
+ ///
+ private ParameterAst FindParameterForVariable(VariableExpressionAst varExpr)
+ {
+ if (varExpr == null)
+ {
+ return null;
+ }
+
+ var varName = varExpr.VariablePath.UserPath;
+
+ // Walk up to find the containing function or script block
+ Ast current = varExpr.Parent;
+ while (current != null)
+ {
+ if (current is FunctionDefinitionAst funcAst)
+ {
+ // Check parameters in the param block
+ var paramBlock = funcAst.Body?.ParamBlock;
+ if (paramBlock?.Parameters != null)
+ {
+ foreach (var param in paramBlock.Parameters)
+ {
+ if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase))
+ {
+ return param;
+ }
+ }
+ }
+
+ // Check function parameters (for functions with parameters outside param block)
+ if (funcAst.Parameters != null)
+ {
+ foreach (var param in funcAst.Parameters)
+ {
+ if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase))
+ {
+ return param;
+ }
+ }
+ }
+
+ break; // Don't check outer function scopes
+ }
+
+ if (current is ScriptBlockAst scriptAst)
+ {
+ var paramBlock = scriptAst.ParamBlock;
+ if (paramBlock?.Parameters != null)
+ {
+ foreach (var param in paramBlock.Parameters)
+ {
+ if (string.Equals(param.Name.VariablePath.UserPath, varName, StringComparison.OrdinalIgnoreCase))
+ {
+ return param;
+ }
+ }
+ }
+ break; // Don't check outer script block scopes
+ }
+
+ current = current.Parent;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Builds and caches typed variable assignments for a given scope.
+ /// This is called once per scope to avoid O(N*M) performance issues.
+ ///
+ private Dictionary GetOrBuildTypedVariableCache(Ast scope)
+ {
+ if (scope == null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ // Check if we already have cached results for this scope
+ if (_typedVariableCache.TryGetValue(scope, out var cachedResults))
+ {
+ return cachedResults;
+ }
+
+ // Build the cache for this scope
+ var typedVariables = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Find all assignment statements in this scope
+ var assignments = scope.FindAll(testAst => testAst is AssignmentStatementAst, true);
+
+ foreach (AssignmentStatementAst assignment in assignments)
+ {
+ // Check if the left side is a convert expression with a variable
+ if (assignment.Left is ConvertExpressionAst convertExpr &&
+ convertExpr.Child is VariableExpressionAst assignedVar)
+ {
+ var varName = assignedVar.VariablePath.UserPath;
+ var typeName = convertExpr.Type.TypeName.FullName;
+
+ // Store in cache (first assignment wins)
+ if (!typedVariables.ContainsKey(varName))
+ {
+ typedVariables[varName] = typeName;
+ }
+ }
+ }
+
+ // Cache the results
+ _typedVariableCache[scope] = typedVariables;
+ return typedVariables;
+ }
+
+ ///
+ /// Looks for a typed assignment to a variable using cached results.
+ ///
+ private string FindTypedAssignment(VariableExpressionAst varExpr)
+ {
+ if (varExpr == null)
+ {
+ return null;
+ }
+
+ var varName = varExpr.VariablePath.UserPath;
+
+ // Walk up to find the containing function or script block
+ Ast searchScope = varExpr.Parent;
+ while (searchScope != null &&
+ !(searchScope is FunctionDefinitionAst) &&
+ !(searchScope is ScriptBlockAst))
+ {
+ searchScope = searchScope.Parent;
+ }
+
+ if (searchScope == null)
+ {
+ return null;
+ }
+
+ // Use cached results instead of re-scanning the entire scope
+ var typedVariables = GetOrBuildTypedVariableCache(searchScope);
+
+ if (typedVariables.TryGetValue(varName, out string typeName))
+ {
+ return typeName;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Checks module manifest (.psd1) files for CLM compatibility issues.
+ ///
+ private void CheckModuleManifest(Ast ast, string fileName, List diagnosticRecords)
+ {
+ // Find the hashtable in the manifest
+ var hashtableAst = ast.Find(x => x is HashtableAst, false) as HashtableAst;
+
+ if (hashtableAst == null)
+ {
+ return;
+ }
+
+ // Check for wildcard exports in FunctionsToExport, CmdletsToExport, AliasesToExport
+ CheckWildcardExports(hashtableAst, fileName, diagnosticRecords);
+
+ // Check for .ps1 files in RootModule, NestedModules, and ScriptsToProcess
+ CheckScriptModules(hashtableAst, fileName, diagnosticRecords);
+ }
+
+ ///
+ /// Checks for wildcard ('*') in export fields which are not allowed in CLM.
+ ///
+ private void CheckWildcardExports(HashtableAst hashtableAst, string fileName, List diagnosticRecords)
+ {
+ //AliasesToExport and VariablesToExport can use wildcards in CLM, but it is not recommended for performance reasons.
+ string[] exportFields = { "FunctionsToExport", "CmdletsToExport"};
+
+ foreach (var kvp in hashtableAst.KeyValuePairs)
+ {
+ if (kvp.Item1 is StringConstantExpressionAst keyAst)
+ {
+ string keyName = keyAst.Value;
+
+ if (exportFields.Contains(keyName, StringComparer.OrdinalIgnoreCase))
+ {
+ // Check if the value contains a wildcard
+ bool hasWildcard = false;
+ IScriptExtent wildcardExtent = null;
+
+ // The value in a hashtable is a StatementAst, need to extract the expression
+ var valueExpr = GetExpressionFromStatement(kvp.Item2);
+
+ if (valueExpr is StringConstantExpressionAst stringValue)
+ {
+ if (stringValue.Value == "*")
+ {
+ hasWildcard = true;
+ wildcardExtent = stringValue.Extent;
+ }
+ }
+ else if (valueExpr is ArrayLiteralAst arrayValue)
+ {
+ foreach (var element in arrayValue.Elements)
+ {
+ if (element is StringConstantExpressionAst strElement && strElement.Value == "*")
+ {
+ hasWildcard = true;
+ wildcardExtent = strElement.Extent;
+ break;
+ }
+ }
+ }
+ else if (valueExpr is ArrayExpressionAst arrayExpr)
+ {
+ // Array expressions like @('a', 'b') have a SubExpression inside
+ if (arrayExpr.SubExpression?.Statements != null)
+ {
+ foreach (var stmt in arrayExpr.SubExpression.Statements)
+ {
+ var expr = GetExpressionFromStatement(stmt);
+ if (expr is ArrayLiteralAst arrayLiteral)
+ {
+ foreach (var element in arrayLiteral.Elements)
+ {
+ if (element is StringConstantExpressionAst strElement && strElement.Value == "*")
+ {
+ hasWildcard = true;
+ wildcardExtent = strElement.Extent;
+ break;
+ }
+ }
+ }
+ else if (expr is StringConstantExpressionAst strElement && strElement.Value == "*")
+ {
+ // Handle single-item array expressions like @('*')
+ hasWildcard = true;
+ wildcardExtent = strElement.Extent;
+ break;
+ }
+ if (hasWildcard) break;
+ }
+ }
+ }
+
+ if (hasWildcard && wildcardExtent != null)
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeWildcardExportError,
+ keyName),
+ wildcardExtent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Checks for .ps1 files in RootModule, NestedModules, and ScriptsToProcess which are not recommended for CLM.
+ ///
+ private void CheckScriptModules(HashtableAst hashtableAst, string fileName, List diagnosticRecords)
+ {
+ string[] moduleFields = { "RootModule", "NestedModules", "ScriptsToProcess" };
+
+ foreach (var kvp in hashtableAst.KeyValuePairs)
+ {
+ if (kvp.Item1 is StringConstantExpressionAst keyAst)
+ {
+ string keyName = keyAst.Value;
+
+ if (moduleFields.Contains(keyName, StringComparer.OrdinalIgnoreCase))
+ {
+ var valueExpr = GetExpressionFromStatement(kvp.Item2);
+ CheckForPs1Files(valueExpr, keyName, fileName, diagnosticRecords);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Extracts an ExpressionAst from a StatementAst (typically from hashtable values).
+ ///
+ private ExpressionAst GetExpressionFromStatement(StatementAst statement)
+ {
+ if (statement is PipelineAst pipeline && pipeline.PipelineElements.Count == 1)
+ {
+ if (pipeline.PipelineElements[0] is CommandExpressionAst commandExpr)
+ {
+ return commandExpr.Expression;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Helper method to get the appropriate error message for .ps1 file usage in module manifests.
+ ///
+ private string GetPs1FileErrorMessage(string fieldName, string scriptFileName)
+ {
+ if (fieldName.Equals("ScriptsToProcess", StringComparison.OrdinalIgnoreCase))
+ {
+ return String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeScriptsToProcessError,
+ scriptFileName);
+ }
+ else
+ {
+ return String.Format(CultureInfo.CurrentCulture,
+ Strings.UseConstrainedLanguageModeScriptModuleError,
+ fieldName,
+ scriptFileName);
+ }
+ }
+
+ ///
+ /// Helper method to check if an expression contains .ps1 file references.
+ ///
+ private void CheckForPs1Files(ExpressionAst valueAst, string fieldName, string fileName, List diagnosticRecords)
+ {
+ if (valueAst is StringConstantExpressionAst stringValue)
+ {
+ if (stringValue.Value != null && stringValue.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ GetPs1FileErrorMessage(fieldName, stringValue.Value),
+ stringValue.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ else if (valueAst is ArrayLiteralAst arrayValue)
+ {
+ foreach (var element in arrayValue.Elements)
+ {
+ if (element is StringConstantExpressionAst strElement &&
+ strElement.Value != null &&
+ strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ GetPs1FileErrorMessage(fieldName, strElement.Value),
+ strElement.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+ else if (valueAst is ArrayExpressionAst arrayExpr)
+ {
+ // Array expressions like @('a', 'b') have a SubExpression inside
+ if (arrayExpr.SubExpression?.Statements != null)
+ {
+ foreach (var stmt in arrayExpr.SubExpression.Statements)
+ {
+ var expr = GetExpressionFromStatement(stmt);
+ if (expr is ArrayLiteralAst arrayLiteral)
+ {
+ foreach (var element in arrayLiteral.Elements)
+ {
+ if (element is StringConstantExpressionAst strElement &&
+ strElement.Value != null &&
+ strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ GetPs1FileErrorMessage(fieldName, strElement.Value),
+ strElement.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+ else if (expr is StringConstantExpressionAst strElement &&
+ strElement.Value != null &&
+ strElement.Value.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
+ {
+ diagnosticRecords.Add(
+ new DiagnosticRecord(
+ GetPs1FileErrorMessage(fieldName, strElement.Value),
+ strElement.Extent,
+ GetName(),
+ GetDiagnosticSeverity(),
+ fileName
+ ));
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Retrieves the common name of this rule.
+ ///
+ public override string GetCommonName()
+ {
+ return string.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeCommonName);
+ }
+
+ ///
+ /// Retrieves the description of this rule.
+ ///
+ public override string GetDescription()
+ {
+ return string.Format(CultureInfo.CurrentCulture, Strings.UseConstrainedLanguageModeDescription);
+ }
+
+ ///
+ /// Retrieves the name of this rule.
+ ///
+ public override string GetName()
+ {
+ return string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.NameSpaceFormat,
+ GetSourceName(),
+ Strings.UseConstrainedLanguageModeName);
+ }
+
+ ///
+ /// Retrieves the severity of the rule: error, warning or information.
+ ///
+ public override RuleSeverity GetSeverity()
+ {
+ return RuleSeverity.Warning;
+ }
+
+ ///
+ /// Gets the severity of the returned diagnostic record: error, warning, or information.
+ ///
+ public DiagnosticSeverity GetDiagnosticSeverity()
+ {
+ return DiagnosticSeverity.Warning;
+ }
+
+ ///
+ /// Retrieves the name of the module/assembly the rule is from.
+ ///
+ public override string GetSourceName()
+ {
+ return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
+ }
+
+ ///
+ /// Retrieves the type of the rule, Builtin, Managed or Module.
+ ///
+ public override SourceType GetSourceType()
+ {
+ return SourceType.Builtin;
+ }
+ }
+}
diff --git a/Rules/UseCorrectCasing.cs b/Rules/UseCorrectCasing.cs
index 9d3abd098..f4f2c40b7 100644
--- a/Rules/UseCorrectCasing.cs
+++ b/Rules/UseCorrectCasing.cs
@@ -22,82 +22,139 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
#endif
public class UseCorrectCasing : ConfigurableRule
{
+
+ /// If true, require the case of all operators to be lowercase.
+ [ConfigurableRuleProperty(defaultValue: true)]
+ public bool CheckOperator { get; set; }
+
+ /// If true, require the case of all keywords to be lowercase.
+ [ConfigurableRuleProperty(defaultValue: true)]
+ public bool CheckKeyword { get; set; }
+
+ /// If true, require the case of all commands to match their actual casing.
+ [ConfigurableRuleProperty(defaultValue: true)]
+ public bool CheckCommands { get; set; }
+
+ private TokenFlags operators = TokenFlags.BinaryOperator | TokenFlags.UnaryOperator;
+
///
/// AnalyzeScript: Analyze the script to check if cmdlet alias is used.
///
public override IEnumerable AnalyzeScript(Ast ast, string fileName)
{
- if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage);
-
- IEnumerable commandAsts = ast.FindAll(testAst => testAst is CommandAst, true);
+ if (ast is null) throw new ArgumentNullException(Strings.NullAstErrorMessage);
- // Iterates all CommandAsts and check the command name.
- foreach (CommandAst commandAst in commandAsts)
+ if (CheckOperator || CheckKeyword)
{
- string commandName = commandAst.GetCommandName();
-
- // Handles the exception caused by commands like, {& $PLINK $args 2> $TempErrorFile}.
- // You can also review the remark section in following document,
- // MSDN: CommandAst.GetCommandName Method
- if (commandName == null)
+ // Iterate tokens to look for the keywords and operators
+ for (int i = 0; i < Helper.Instance.Tokens.Length; i++)
{
- continue;
- }
+ Token token = Helper.Instance.Tokens[i];
- var commandInfo = Helper.Instance.GetCommandInfo(commandName);
- if (commandInfo == null || commandInfo.CommandType == CommandTypes.ExternalScript || commandInfo.CommandType == CommandTypes.Application)
- {
- continue;
+ if (CheckKeyword && ((token.TokenFlags & TokenFlags.Keyword) != 0))
+ {
+ string correctCase = token.Text.ToLowerInvariant();
+ if (!token.Text.Equals(correctCase, StringComparison.Ordinal))
+ {
+ yield return GetDiagnosticRecord(token, fileName, correctCase, Strings.UseCorrectCasingKeywordError);
+ }
+ continue;
+ }
+
+ if (CheckOperator && ((token.TokenFlags & operators) != 0))
+ {
+ string correctCase = token.Text.ToLowerInvariant();
+ if (!token.Text.Equals(correctCase, StringComparison.Ordinal))
+ {
+ yield return GetDiagnosticRecord(token, fileName, correctCase, Strings.UseCorrectCasingOperatorError);
+ }
+ }
}
+ }
- var shortName = commandInfo.Name;
- var fullyqualifiedName = $"{commandInfo.ModuleName}\\{shortName}";
- var isFullyQualified = commandName.Equals(fullyqualifiedName, StringComparison.OrdinalIgnoreCase);
- var correctlyCasedCommandName = isFullyQualified ? fullyqualifiedName : shortName;
+ if (CheckCommands)
+ {
+ // Iterate command ASTs for command and parameter names
+ IEnumerable commandAsts = ast.FindAll(testAst => testAst is CommandAst, true);
- if (!commandName.Equals(correctlyCasedCommandName, StringComparison.Ordinal))
+ // Iterates all CommandAsts and check the command name.
+ foreach (CommandAst commandAst in commandAsts)
{
- yield return new DiagnosticRecord(
- string.Format(CultureInfo.CurrentCulture, Strings.UseCorrectCasingError, commandName, correctlyCasedCommandName),
- GetCommandExtent(commandAst),
- GetName(),
- DiagnosticSeverity.Warning,
- fileName,
- commandName,
- suggestedCorrections: GetCorrectionExtent(commandAst, correctlyCasedCommandName));
- }
+ string commandName = commandAst.GetCommandName();
- var commandParameterAsts = commandAst.FindAll(
- testAst => testAst is CommandParameterAst, true).Cast();
- Dictionary availableParameters;
- try
- {
- availableParameters = commandInfo.Parameters;
- }
- // It's a known issue that objects from PowerShell can have a runspace affinity,
- // therefore if that happens, we query a fresh object instead of using the cache.
- // https://github.com/PowerShell/PowerShell/issues/4003
- catch (InvalidOperationException)
- {
- commandInfo = Helper.Instance.GetCommandInfo(commandName, bypassCache: true);
- availableParameters = commandInfo.Parameters;
- }
- foreach (var commandParameterAst in commandParameterAsts)
- {
- var parameterName = commandParameterAst.ParameterName;
- if (availableParameters.TryGetValue(parameterName, out ParameterMetadata parameterMetaData))
+ // Handles the exception caused by commands like, {& $PLINK $args 2> $TempErrorFile}.
+ // You can also review the remark section in following document,
+ // MSDN: CommandAst.GetCommandName Method
+ if (commandName == null)
{
- var correctlyCasedParameterName = parameterMetaData.Name;
- if (!parameterName.Equals(correctlyCasedParameterName, StringComparison.Ordinal))
- {
- yield return new DiagnosticRecord(
- string.Format(CultureInfo.CurrentCulture, Strings.UseCorrectCasingParameterError, parameterName, commandName, correctlyCasedParameterName),
- GetCommandExtent(commandAst),
- GetName(),
- DiagnosticSeverity.Warning,
- fileName,
+ continue;
+ }
+
+ var commandInfo = Helper.Instance.GetCommandInfo(commandName);
+ if (commandInfo == null || commandInfo.CommandType == CommandTypes.ExternalScript || commandInfo.CommandType == CommandTypes.Application)
+ {
+ continue;
+ }
+
+ var shortName = commandInfo.Name;
+ var fullyqualifiedName = $"{commandInfo.ModuleName}\\{shortName}";
+ var isFullyQualified = commandName.Equals(fullyqualifiedName, StringComparison.OrdinalIgnoreCase);
+ var correctlyCasedCommandName = isFullyQualified ? fullyqualifiedName : shortName;
+
+ if (!commandName.Equals(correctlyCasedCommandName, StringComparison.Ordinal))
+ {
+ var extent = GetCommandExtent(commandAst);
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseCorrectCasingError,
commandName,
- suggestedCorrections: GetCorrectionExtent(commandParameterAst, correctlyCasedParameterName));
+ correctlyCasedCommandName),
+ extent,
+ GetName(),
+ DiagnosticSeverity.Information,
+ fileName,
+ correctlyCasedCommandName,
+ GetCorrectionExtent(commandAst, extent, correctlyCasedCommandName));
+ }
+
+ var commandParameterAsts = commandAst.FindAll(
+ testAst => testAst is CommandParameterAst, true).Cast();
+ Dictionary availableParameters;
+ try
+ {
+ availableParameters = commandInfo.Parameters;
+ }
+ // It's a known issue that objects from PowerShell can have a runspace affinity,
+ // therefore if that happens, we query a fresh object instead of using the cache.
+ // https://github.com/PowerShell/PowerShell/issues/4003
+ catch (InvalidOperationException)
+ {
+ commandInfo = Helper.Instance.GetCommandInfo(commandName, bypassCache: true);
+ availableParameters = commandInfo.Parameters;
+ }
+ foreach (var commandParameterAst in commandParameterAsts)
+ {
+ var parameterName = commandParameterAst.ParameterName;
+ if (availableParameters.TryGetValue(parameterName, out ParameterMetadata parameterMetaData))
+ {
+ var correctlyCasedParameterName = parameterMetaData.Name;
+ if (!parameterName.Equals(correctlyCasedParameterName, StringComparison.Ordinal))
+ {
+ yield return new DiagnosticRecord(
+ string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.UseCorrectCasingParameterError,
+ commandParameterAst.Extent.Text,
+ commandName,
+ correctlyCasedParameterName),
+ commandParameterAst.Extent,
+ GetName(),
+ DiagnosticSeverity.Information,
+ fileName,
+ correctlyCasedParameterName,
+ GetCorrectionExtent(commandParameterAst, commandParameterAst.Extent, correctlyCasedParameterName));
+ }
}
}
}
@@ -124,44 +181,43 @@ private IScriptExtent GetCommandExtent(CommandAst commandAst)
return commandAst.Extent;
}
- private IEnumerable GetCorrectionExtent(CommandAst commandAst, string correctlyCaseName)
+ private IEnumerable GetCorrectionExtent(Ast ast, IScriptExtent extent, string correctlyCaseName)
{
- var description = string.Format(
- CultureInfo.CurrentCulture,
- Strings.UseCorrectCasingDescription,
- correctlyCaseName,
- correctlyCaseName);
- var cmdExtent = GetCommandExtent(commandAst);
var correction = new CorrectionExtent(
- cmdExtent.StartLineNumber,
- cmdExtent.EndLineNumber,
- cmdExtent.StartColumnNumber,
- cmdExtent.EndColumnNumber,
+ extent.StartLineNumber,
+ extent.EndLineNumber,
+ // For parameters, add +1 because of the dash before the parameter name
+ (ast is CommandParameterAst ? extent.StartColumnNumber + 1 : extent.StartColumnNumber),
+ // and do not use EndColumnNumber property, because sometimes it's all of: -ParameterName:$ParameterValue
+ (ast is CommandParameterAst ? extent.StartColumnNumber + 1 + ((CommandParameterAst)ast).ParameterName.Length : extent.EndColumnNumber),
correctlyCaseName,
- commandAst.Extent.File,
- description);
+ extent.File,
+ GetDescription());
yield return correction;
}
- private IEnumerable GetCorrectionExtent(CommandParameterAst commandParameterAst, string correctlyCaseName)
+ private DiagnosticRecord GetDiagnosticRecord(Token token, string fileName, string correction, string message)
{
- var description = string.Format(
- CultureInfo.CurrentCulture,
- Strings.UseCorrectCasingDescription,
- correctlyCaseName,
- correctlyCaseName);
- var cmdExtent = commandParameterAst.Extent;
- var correction = new CorrectionExtent(
- cmdExtent.StartLineNumber,
- cmdExtent.EndLineNumber,
- // +1 because of the dash before the parameter name
- cmdExtent.StartColumnNumber + 1,
- // do not use EndColumnNumber property as it would not cover the case where the colon syntax: -ParameterName:$ParameterValue
- cmdExtent.StartColumnNumber + 1 + commandParameterAst.ParameterName.Length,
- correctlyCaseName,
- commandParameterAst.Extent.File,
- description);
- yield return correction;
+ var extents = new[]
+ {
+ new CorrectionExtent(
+ token.Extent.StartLineNumber,
+ token.Extent.EndLineNumber,
+ token.Extent.StartColumnNumber,
+ token.Extent.EndColumnNumber,
+ correction,
+ token.Extent.File,
+ GetDescription())
+ };
+
+ return new DiagnosticRecord(
+ string.Format(CultureInfo.CurrentCulture, message, token.Text, correction),
+ token.Extent,
+ GetName(),
+ DiagnosticSeverity.Information,
+ fileName,
+ correction, // return the keyword case as the id, so you can turn this off for specific keywords...
+ suggestedCorrections: extents);
}
///
diff --git a/Rules/UseDeclaredVarsMoreThanAssignments.cs b/Rules/UseDeclaredVarsMoreThanAssignments.cs
index 5a8440ada..b35caafbc 100644
--- a/Rules/UseDeclaredVarsMoreThanAssignments.cs
+++ b/Rules/UseDeclaredVarsMoreThanAssignments.cs
@@ -143,7 +143,7 @@ private IEnumerable AnalyzeScriptBlockAst(ScriptBlockAst scrip
if (assignmentVarAst != null)
{
// Ignore if variable is global or environment variable or scope is drive qualified variable
- if (!Helper.Instance.IsVariableGlobalOrEnvironment(assignmentVarAst, scriptBlockAst)
+ if (!Helper.Instance.IsVariableGlobalOrEnvironment(assignmentVarAst)
&& !assignmentVarAst.VariablePath.IsScript
&& assignmentVarAst.VariablePath.DriveName == null)
{
diff --git a/Rules/UseIdenticalMandatoryParametersDSC.cs b/Rules/UseIdenticalMandatoryParametersDSC.cs
index 56acb5b48..9c453e3a1 100644
--- a/Rules/UseIdenticalMandatoryParametersDSC.cs
+++ b/Rules/UseIdenticalMandatoryParametersDSC.cs
@@ -2,8 +2,6 @@
// Licensed under the MIT License.
// this rule can only compile on v4+
-#if (PSV4 || !PSV3)
-
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -334,6 +332,4 @@ private FileInfo GetModuleManifest(string fileName)
.FirstOrDefault();
}
}
-}
-
-#endif
+}
\ No newline at end of file
diff --git a/Rules/UseLiteralInitializerForHashtable.cs b/Rules/UseLiteralInitializerForHashtable.cs
index 8f59d8332..56a31508f 100644
--- a/Rules/UseLiteralInitializerForHashtable.cs
+++ b/Rules/UseLiteralInitializerForHashtable.cs
@@ -62,7 +62,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
///
public string GetCommonName()
{
- return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitilializerForHashtableCommonName);
+ return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitializerForHashtableCommonName);
}
///
@@ -70,7 +70,7 @@ public string GetCommonName()
///
public string GetDescription()
{
- return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitilializerForHashtableDescription);
+ return string.Format(CultureInfo.CurrentCulture, Strings.UseLiteralInitializerForHashtableDescription);
}
///
@@ -82,7 +82,7 @@ public string GetName()
CultureInfo.CurrentCulture,
Strings.NameSpaceFormat,
GetSourceName(),
- Strings.UseLiteralInitilializerForHashtableName);
+ Strings.UseLiteralInitializerForHashtableName);
}
///
@@ -170,7 +170,7 @@ public override AstVisitAction VisitInvokeMemberExpression(InvokeMemberExpressio
|| !HasIgnoreCaseComparerArg(methodCallAst.Arguments))
{
var dr = new DiagnosticRecord(
- Strings.UseLiteralInitilializerForHashtableDescription,
+ Strings.UseLiteralInitializerForHashtableDescription,
methodCallAst.Extent,
GetName(),
GetDiagnosticSeverity(),
@@ -205,7 +205,7 @@ private void AnalyzeNewObjectCommand(CommandAst commandAst)
}
var dr = new DiagnosticRecord(
- Strings.UseLiteralInitilializerForHashtableDescription,
+ Strings.UseLiteralInitializerForHashtableDescription,
commandAst.Extent,
GetName(),
GetDiagnosticSeverity(),
diff --git a/Rules/UseOutputTypeCorrectly.cs b/Rules/UseOutputTypeCorrectly.cs
index 099cd9f25..ac7ff4a01 100644
--- a/Rules/UseOutputTypeCorrectly.cs
+++ b/Rules/UseOutputTypeCorrectly.cs
@@ -22,12 +22,8 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
#endif
public class UseOutputTypeCorrectly : SkipTypeDefinition, IScriptRule
{
- #if !(PSV3||PSV4)
-
private IEnumerable _classes;
- #endif
-
///
/// AnalyzeScript: Checks that objects returned in a cmdlet have their types declared in OutputType Attribute
///
@@ -41,12 +37,8 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
DiagnosticRecords.Clear();
this.fileName = fileName;
- #if !(PSV3||PSV4)
-
_classes = ast.FindAll(item => item is TypeDefinitionAst && ((item as TypeDefinitionAst).IsClass), true).Cast();
- #endif
-
ast.Visit(this);
return DiagnosticRecords;
@@ -103,16 +95,8 @@ public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst fun
}
}
- #if PSV3
-
- List> returnTypes = FindPipelineOutput.OutputTypes(funcAst);
-
- #else
-
List> returnTypes = FindPipelineOutput.OutputTypes(funcAst, _classes);
- #endif
-
HashSet specialTypes = new HashSet(StringComparer.OrdinalIgnoreCase);
specialTypes.Add(typeof(Unreached).FullName);
specialTypes.Add(typeof(Undetermined).FullName);
diff --git a/Rules/UseShouldProcessForStateChangingFunctions.cs b/Rules/UseShouldProcessForStateChangingFunctions.cs
index 4448e6693..0d526042f 100644
--- a/Rules/UseShouldProcessForStateChangingFunctions.cs
+++ b/Rules/UseShouldProcessForStateChangingFunctions.cs
@@ -102,7 +102,7 @@ public string GetCommonName()
/// The description of this rule
public string GetDescription()
{
- return string.Format(CultureInfo.CurrentCulture, Strings.UseShouldProcessForStateChangingFunctionsDescrption);
+ return string.Format(CultureInfo.CurrentCulture, Strings.UseShouldProcessForStateChangingFunctionsDescription);
}
///
diff --git a/Rules/UseSingleValueFromPipelineParameter.cs b/Rules/UseSingleValueFromPipelineParameter.cs
new file mode 100644
index 000000000..0b69880aa
--- /dev/null
+++ b/Rules/UseSingleValueFromPipelineParameter.cs
@@ -0,0 +1,188 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Management.Automation.Language;
+#if !CORECLR
+using System.ComponentModel.Composition;
+#endif
+
+namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
+{
+#if !CORECLR
+ [Export(typeof(IScriptRule))]
+#endif
+
+ ///
+ /// Rule that identifies parameter blocks with multiple parameters in
+ /// the same parameter set that are marked as ValueFromPipeline=true, which
+ /// can cause undefined behavior.
+ ///
+ public class UseSingleValueFromPipelineParameter : ConfigurableRule
+ {
+ private const string AllParameterSetsName = "__AllParameterSets";
+
+ ///
+ /// Analyzes the PowerShell AST for parameter sets with multiple ValueFromPipeline parameters.
+ ///
+ /// The PowerShell Abstract Syntax Tree to analyze.
+ /// The name of the file being analyzed (for diagnostic reporting).
+ /// A collection of diagnostic records for each violating parameter.
+ public override IEnumerable AnalyzeScript(Ast ast, string fileName)
+ {
+ if (ast == null)
+ {
+ throw new ArgumentNullException(Strings.NullAstErrorMessage);
+ }
+ // Find all param blocks that have a Parameter attribute with
+ // ValueFromPipeline set to true.
+ var paramBlocks = ast.FindAll(testAst => testAst is ParamBlockAst, true)
+ .Where(paramBlock => paramBlock.FindAll(
+ attributeAst => attributeAst is AttributeAst attr &&
+ ParameterAttributeAstHasValueFromPipeline(attr),
+ true
+ ).Any());
+
+ foreach (var paramBlock in paramBlocks)
+ {
+ // Find all parameter declarations in the current param block
+ // Convert the generic ast objects into ParameterAst Objects
+ // For each ParameterAst, find all it's attributes that have
+ // ValueFromPipeline set to true (either explicitly or
+ // implicitly). Flatten the results into a single collection of
+ // Annonymous objects relating the parameter with it's attribute
+ // and then group them by parameter set name.
+ //
+ //
+ // https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_parameter_sets?#reserved-parameter-set-name
+ //
+ // The default parameter set name is '__AllParameterSets'.
+ // Not specifying a parameter set name and using the parameter
+ // set name '__AllParameterSets' are equivalent, so we shouldn't
+ // treat them like they're different just because one is an
+ // empty string and the other is not.
+ //
+ // Filter the list to only keep parameter sets that have more
+ // than one ValueFromPipeline parameter.
+ var parameterSetGroups = paramBlock.FindAll(n => n is ParameterAst, true)
+ .Cast()
+ .SelectMany(parameter => parameter.FindAll(
+ a => a is AttributeAst attr && ParameterAttributeAstHasValueFromPipeline(attr),
+ true
+ ).Cast().Select(attr => new { Parameter = parameter, Attribute = attr }))
+ .GroupBy(item => GetParameterSetForAttribute(item.Attribute) ?? AllParameterSetsName)
+ .Where(group => group.Count() > 1);
+
+
+ foreach (var group in parameterSetGroups)
+ {
+ // __AllParameterSets being the default name is...obscure.
+ // Instead we'll show the user "default". It's more than
+ // likely the user has not specified a parameter set name,
+ // so default will make sense. If they have used 'default'
+ // as their parameter set name, then we're still correct.
+ var parameterSetName = group.Key == AllParameterSetsName ? "default" : group.Key;
+
+ // Create a concatenated string of parameter names that
+ // conflict in this parameter set
+ var parameterNames = string.Join(", ", group.Select(item => item.Parameter.Name.VariablePath.UserPath));
+
+ // We emit a diagnostic record for each offending parameter
+ // attribute in the parameter set so it's obvious where all the
+ // occurrences are.
+ foreach (var item in group)
+ {
+ var message = string.Format(CultureInfo.CurrentCulture,
+ Strings.UseSingleValueFromPipelineParameterError,
+ parameterNames,
+ parameterSetName);
+
+ yield return new DiagnosticRecord(
+ message,
+ item.Attribute.Extent,
+ GetName(),
+ DiagnosticSeverity.Warning,
+ fileName,
+ parameterSetName);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Returns whether the specified AttributeAst represents a Parameter attribute
+ /// that has the ValueFromPipeline named argument set to true (either explicitly or
+ /// implicitly).
+ ///
+ /// The Parameter attribute to examine.
+ /// Whether the attribute has the ValueFromPipeline named argument set to true.
+ private static bool ParameterAttributeAstHasValueFromPipeline(AttributeAst attributeAst)
+ {
+ // Exit quickly if the attribute is null, has no named arguments, or
+ // is not a parameter attribute.
+ if (attributeAst?.NamedArguments == null ||
+ !string.Equals(attributeAst.TypeName?.Name, "Parameter", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return attributeAst.NamedArguments
+ .OfType()
+ .Any(namedArg => string.Equals(
+ namedArg?.ArgumentName,
+ "ValueFromPipeline",
+ StringComparison.OrdinalIgnoreCase
+ // Helper.Instance.GetNamedArgumentAttributeValue handles both explicit ($true)
+ // and implicit (no value specified) ValueFromPipeline declarations
+ ) && Helper.Instance.GetNamedArgumentAttributeValue(namedArg));
+ }
+
+ ///
+ /// Gets the ParameterSetName value from a Parameter attribute.
+ ///
+ /// The Parameter attribute to examine.
+ /// The parameter set name, or null if not found or empty.
+ private static string GetParameterSetForAttribute(AttributeAst attributeAst)
+ {
+ // Exit quickly if the attribute is null, has no named arguments, or
+ // is not a parameter attribute.
+ if (attributeAst?.NamedArguments == null ||
+ !string.Equals(attributeAst.TypeName.Name, "Parameter", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return attributeAst.NamedArguments
+ .OfType()
+ .Where(namedArg => string.Equals(
+ namedArg?.ArgumentName,
+ "ParameterSetName",
+ StringComparison.OrdinalIgnoreCase
+ ))
+ .Select(namedArg => namedArg?.Argument)
+ .OfType()
+ .Select(stringConstAst => stringConstAst?.Value)
+ .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
+ }
+
+ public override string GetCommonName() => Strings.UseSingleValueFromPipelineParameterCommonName;
+
+ public override string GetDescription() => Strings.UseSingleValueFromPipelineParameterDescription;
+
+ public override string GetName() => string.Format(
+ CultureInfo.CurrentCulture,
+ Strings.NameSpaceFormat,
+ GetSourceName(),
+ Strings.UseSingleValueFromPipelineParameterName);
+
+ public override RuleSeverity GetSeverity() => RuleSeverity.Warning;
+
+ public override string GetSourceName() => Strings.SourceName;
+
+ public override SourceType GetSourceType() => SourceType.Builtin;
+ }
+}
\ No newline at end of file
diff --git a/Rules/UseStandardDSCFunctionsInResource.cs b/Rules/UseStandardDSCFunctionsInResource.cs
index 7022da2d4..386198ba5 100644
--- a/Rules/UseStandardDSCFunctionsInResource.cs
+++ b/Rules/UseStandardDSCFunctionsInResource.cs
@@ -64,12 +64,6 @@ public IEnumerable AnalyzeDSCClass(Ast ast, string fileName)
{
if (ast == null) throw new ArgumentNullException(Strings.NullAstErrorMessage);
- #if (PSV3||PSV4)
-
- return null;
-
- #else
-
List resourceFunctionNames = new List(new string[] {"Test", "Get", "Set"});
IEnumerable dscClasses = ast.FindAll(item =>
@@ -90,8 +84,6 @@ item is TypeDefinitionAst
}
}
}
-
- #endif
}
///
diff --git a/Rules/UseUsingScopeModifierInNewRunspaces.cs b/Rules/UseUsingScopeModifierInNewRunspaces.cs
index a435c1d31..46e1eea8a 100644
--- a/Rules/UseUsingScopeModifierInNewRunspaces.cs
+++ b/Rules/UseUsingScopeModifierInNewRunspaces.cs
@@ -91,11 +91,7 @@ public string GetSourceName()
return string.Format(CultureInfo.CurrentCulture, Strings.SourceName);
}
-#if !(PSV3 || PSV4)
private class SyntaxCompatibilityVisitor : AstVisitor2
-#else
- private class SyntaxCompatibilityVisitor : AstVisitor
-#endif
{
private const DiagnosticSeverity Severity = DiagnosticSeverity.Warning;
diff --git a/Tests/Documentation/RuleDocumentation.tests.ps1 b/Tests/Documentation/RuleDocumentation.tests.ps1
index a62c8506e..d8779af4f 100644
--- a/Tests/Documentation/RuleDocumentation.tests.ps1
+++ b/Tests/Documentation/RuleDocumentation.tests.ps1
@@ -15,12 +15,6 @@ Describe "Validate rule documentation files" {
}} |
Sort-Object
- # Remove rules from the diff list that aren't supported on old PS version
- if ($PSVersionTable.PSVersion.Major -eq 4) {
- $docs = $docs | Where-Object {$_ -notmatch '^PSAvoidGlobalAliases$'}
- $readmeRules = $readmeRules | Where-Object { $_ -notmatch '^PSAvoidGlobalAliases$' }
- }
-
$rulesDocsDiff = Compare-Object -ReferenceObject $rules -DifferenceObject $docs -SyncWindow 25
$rulesReadmeDiff = Compare-Object -ReferenceObject $rules -DifferenceObject $readmeRules -SyncWindow 25
}
diff --git a/Tests/Engine/CommunityAnalyzerRules/CommunityAnalyzerRules.psm1 b/Tests/Engine/CommunityAnalyzerRules/CommunityAnalyzerRules.psm1
index f9abf9950..c28d80ec8 100644
--- a/Tests/Engine/CommunityAnalyzerRules/CommunityAnalyzerRules.psm1
+++ b/Tests/Engine/CommunityAnalyzerRules/CommunityAnalyzerRules.psm1
@@ -232,165 +232,158 @@ function Measure-RequiresModules
}
}
+<#
+.SYNOPSIS
+ You can store the type name in a variable or using -f operator to reduce the amount of redundant information in your script.
+.DESCRIPTION
+ When interacting with classes that have long type names, you want to reduce the amount of redundant information in your script.
+ To fix a violation of this rule, please store the type name in a variable or using -f operator. For example:
+ $namespace = "System.Collections.{0}"; $arrayList = New-Object ($namespace -f "ArrayList"); $queue = New-Object ($namespace -f "Queue")
+.EXAMPLE
+ Measure-LongClassName -CommandAst $CommandAst
+.INPUTS
+ [System.Management.Automation.Language.CommandAst]
+.OUTPUTS
+ [Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
+.NOTES
+ Reference: 3.11. Reduce Typying for Long Class Names, Windows PowerShell Cookbook, Third Edition
+#>
+function Measure-LongClassName
+{
+ [CmdletBinding()]
+ [OutputType([Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
+ Param
+ (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [System.Management.Automation.Language.CommandAst]
+ $CommandAst
+ )
+
+ Process
+ {
+ $results = @()
+
+ # The StaticParameterBinder help us to find the argument of TypeName.
+ $spBinder = [System.Management.Automation.Language.StaticParameterBinder]
+
+ # Checks New-Object without ComObject parameter command only.
+ if ($null -ne $CommandAst.GetCommandName())
+ {
+ if ($CommandAst.GetCommandName() -ne "new-object")
+ {
+ return $results
+ }
+ }
+ else
+ {
+ return $results
+ }
+
+ try
+ {
+ [System.Management.Automation.Language.StaticBindingResult]$sbResults = $spBinder::BindCommand($CommandAst, $true)
+ foreach ($sbResult in $sbResults)
+ {
+ # TypeName cannot be found if user run command like, New-Object -ComObject Scripting.FileSystemObject.
+ if ($null -eq $sbResult.BoundParameters["TypeName"].ConstantValue) { continue }
+
+ if ($sbResult.BoundParameters["TypeName"].ConstantValue.ToString().Split('.').Length -ge 3)
+ {
+ # $sbResult.BoundParameters["TypeName"].Value is a CommandElementAst, so we can return an extent.
+ $result = New-Object `
+ -Typename "Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord" `
+ -ArgumentList $Messages.MeasureLongClassName,$sbResult.BoundParameters["TypeName"].Value.Extent,$PSCmdlet.MyInvocation.InvocationName,Information,$null
+
+ $results += $result
+ }
+ }
+
+ return $results
+ }
+ catch
+ {
+ $PSCmdlet.ThrowTerminatingError($PSItem)
+ }
+
+
+ }
+}
-# The two rules in the following if block use StaticParameterBinder class.
-# StaticParameterBinder class was introduced in PSv4.
-if ($PSVersionTable.PSVersion -ge [Version]'4.0.0')
+<#
+.SYNOPSIS
+ Please do not use COM objects when calling New-Object.
+.DESCRIPTION
+ If you can't use just PowerShell, use .NET, external commands or COM objects, in that order of preference. COM objects are rarely well-documented, making them harder for someone else to research and understand.
+ They do not always work flawlessly in PowerShell, as they must be used through .NET's Interop layer, which isn't 100% perfect.
+ To fix a violation of this rule, please do not use COM objects when calling New-Object.
+.EXAMPLE
+ Measure-ComObject -CommandAst $CommandAst
+.INPUTS
+ [System.Management.Automation.Language.CommandAst]
+.OUTPUTS
+ [Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
+.NOTES
+ Reference: The Purity Laws, The Community Book of PowerShell Practices.
+#>
+function Measure-ComObject
{
- <#
- .SYNOPSIS
- You can store the type name in a variable or using -f operator to reduce the amount of redundant information in your script.
- .DESCRIPTION
- When interacting with classes that have long type names, you want to reduce the amount of redundant information in your script.
- To fix a violation of this rule, please store the type name in a variable or using -f operator. For example:
- $namespace = "System.Collections.{0}"; $arrayList = New-Object ($namespace -f "ArrayList"); $queue = New-Object ($namespace -f "Queue")
- .EXAMPLE
- Measure-LongClassName -CommandAst $CommandAst
- .INPUTS
- [System.Management.Automation.Language.CommandAst]
- .OUTPUTS
- [Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
- .NOTES
- Reference: 3.11. Reduce Typying for Long Class Names, Windows PowerShell Cookbook, Third Edition
- #>
- function Measure-LongClassName
- {
- [CmdletBinding()]
- [OutputType([Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
- Param
- (
- [Parameter(Mandatory = $true)]
- [ValidateNotNullOrEmpty()]
- [System.Management.Automation.Language.CommandAst]
- $CommandAst
- )
-
- Process
- {
- $results = @()
-
- # The StaticParameterBinder help us to find the argument of TypeName.
- $spBinder = [System.Management.Automation.Language.StaticParameterBinder]
-
- # Checks New-Object without ComObject parameter command only.
- if ($null -ne $CommandAst.GetCommandName())
- {
- if ($CommandAst.GetCommandName() -ne "new-object")
- {
- return $results
- }
- }
- else
- {
- return $results
- }
-
- try
- {
- [System.Management.Automation.Language.StaticBindingResult]$sbResults = $spBinder::BindCommand($CommandAst, $true)
- foreach ($sbResult in $sbResults)
- {
- # TypeName cannot be found if user run command like, New-Object -ComObject Scripting.FileSystemObject.
- if ($null -eq $sbResult.BoundParameters["TypeName"].ConstantValue) { continue }
-
- if ($sbResult.BoundParameters["TypeName"].ConstantValue.ToString().Split('.').Length -ge 3)
- {
- # $sbResult.BoundParameters["TypeName"].Value is a CommandElementAst, so we can return an extent.
- $result = New-Object `
- -Typename "Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord" `
- -ArgumentList $Messages.MeasureLongClassName,$sbResult.BoundParameters["TypeName"].Value.Extent,$PSCmdlet.MyInvocation.InvocationName,Information,$null
-
- $results += $result
- }
- }
-
- return $results
- }
- catch
- {
- $PSCmdlet.ThrowTerminatingError($PSItem)
- }
-
-
- }
- }
-
- <#
- .SYNOPSIS
- Please do not use COM objects when calling New-Object.
- .DESCRIPTION
- If you can't use just PowerShell, use .NET, external commands or COM objects, in that order of preference. COM objects are rarely well-documented, making them harder for someone else to research and understand.
- They do not always work flawlessly in PowerShell, as they must be used through .NET's Interop layer, which isn't 100% perfect.
- To fix a violation of this rule, please do not use COM objects when calling New-Object.
- .EXAMPLE
- Measure-ComObject -CommandAst $CommandAst
- .INPUTS
- [System.Management.Automation.Language.CommandAst]
- .OUTPUTS
- [Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
- .NOTES
- Reference: The Purity Laws, The Community Book of PowerShell Practices.
- #>
- function Measure-ComObject
- {
- [CmdletBinding()]
- [OutputType([Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
- Param
- (
- [Parameter(Mandatory = $true)]
- [ValidateNotNullOrEmpty()]
- [System.Management.Automation.Language.CommandAst]
- $CommandAst
- )
-
- Process
- {
- $results = @()
-
- # The StaticParameterBinder help us to find the argument of TypeName.
- $spBinder = [System.Management.Automation.Language.StaticParameterBinder]
-
- # Checks New-Object without ComObject parameter command only.
- if ($null -ne $CommandAst.GetCommandName())
- {
- if ($CommandAst.GetCommandName() -ne "new-object")
- {
- return $results
- }
- }
- else
- {
- return $results
- }
-
- try
- {
- [System.Management.Automation.Language.StaticBindingResult]$sbResults = $spBinder::BindCommand($CommandAst, $true)
- foreach ($sbResult in $sbResults)
- {
- if ($sbResults.BoundParameters.ContainsKey("ComObject"))
- {
- # $sbResult.BoundParameters["TypeName"].Value is a CommandElementAst, so we can return an extent.
- $result = New-Object `
- -Typename "Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord" `
- -ArgumentList $Messages.MeasureComObject,$sbResult.BoundParameters["ComObject"].Value.Extent,$PSCmdlet.MyInvocation.InvocationName,Warning,$null
-
- $results += $result
- }
- }
-
- return $results
- }
- catch
- {
- $PSCmdlet.ThrowTerminatingError($PSItem)
- }
-
-
- }
- }
-
-} # end if ($PSVersionTable.PSVersion -ge [Version]'4.0')
+ [CmdletBinding()]
+ [OutputType([Microsoft.Windows.Powershell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
+ Param
+ (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [System.Management.Automation.Language.CommandAst]
+ $CommandAst
+ )
+
+ Process
+ {
+ $results = @()
+
+ # The StaticParameterBinder help us to find the argument of TypeName.
+ $spBinder = [System.Management.Automation.Language.StaticParameterBinder]
+
+ # Checks New-Object without ComObject parameter command only.
+ if ($null -ne $CommandAst.GetCommandName())
+ {
+ if ($CommandAst.GetCommandName() -ne "new-object")
+ {
+ return $results
+ }
+ }
+ else
+ {
+ return $results
+ }
+
+ try
+ {
+ [System.Management.Automation.Language.StaticBindingResult]$sbResults = $spBinder::BindCommand($CommandAst, $true)
+ foreach ($sbResult in $sbResults)
+ {
+ if ($sbResults.BoundParameters.ContainsKey("ComObject"))
+ {
+ # $sbResult.BoundParameters["TypeName"].Value is a CommandElementAst, so we can return an extent.
+ $result = New-Object `
+ -Typename "Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord" `
+ -ArgumentList $Messages.MeasureComObject,$sbResult.BoundParameters["ComObject"].Value.Extent,$PSCmdlet.MyInvocation.InvocationName,Warning,$null
+
+ $results += $result
+ }
+ }
+
+ return $results
+ }
+ catch
+ {
+ $PSCmdlet.ThrowTerminatingError($PSItem)
+ }
+
+
+ }
+}
<#
diff --git a/Tests/Engine/CustomizedRule.tests.ps1 b/Tests/Engine/CustomizedRule.tests.ps1
index ba0686d75..ce60f6dce 100644
--- a/Tests/Engine/CustomizedRule.tests.ps1
+++ b/Tests/Engine/CustomizedRule.tests.ps1
@@ -257,7 +257,7 @@ Describe "Test importing correct customized rules" {
$customizedRulePath.Count | Should -Be 1
}
- It "loads custom rules that contain version in their path" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ It "loads custom rules that contain version in their path" {
$customizedRulePath = Invoke-ScriptAnalyzer $PSScriptRoot\TestScript.ps1 -CustomRulePath $PSScriptRoot\VersionedSampleRule\SampleRuleWithVersion
$customizedRulePath.Count | Should -Be 1
@@ -265,7 +265,7 @@ Describe "Test importing correct customized rules" {
$customizedRulePath.Count | Should -Be 1
}
- It "loads custom rules that contain version in their path with the RecurseCustomRule switch" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ It "loads custom rules that contain version in their path with the RecurseCustomRule switch" {
$customizedRulePath = Invoke-ScriptAnalyzer $PSScriptRoot\TestScript.ps1 -CustomRulePath $PSScriptRoot\VersionedSampleRule -RecurseCustomRulePath
$customizedRulePath.Count | Should -Be 1
diff --git a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1 b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1
index 93824060a..422b585bf 100644
--- a/Tests/Engine/GetScriptAnalyzerRule.tests.ps1
+++ b/Tests/Engine/GetScriptAnalyzerRule.tests.ps1
@@ -63,16 +63,13 @@ Describe "Test Name parameters" {
It "get Rules with no parameters supplied" {
$defaultRules = Get-ScriptAnalyzerRule
- $expectedNumRules = 70
- if ($PSVersionTable.PSVersion.Major -le 4)
- {
- # for PSv3 PSAvoidGlobalAliases is not shipped because
- # it uses StaticParameterBinder.BindCommand which is
- # available only on PSv4 and above
-
- $expectedNumRules--
- }
- $defaultRules.Count | Should -Be $expectedNumRules
+ # Dynamically count the expected number of rules from source files
+ # by finding all C# files with [Export(typeof(I...Rule))] attributes
+ $rulesRoot = Resolve-Path "$PSScriptRoot/../../Rules"
+ $expectedNumRules = (Get-ChildItem -Path $rulesRoot -Filter '*.cs' -Recurse |
+ Select-String -Pattern 'Export\(typeof\s*\(I\w+Rule\)\)' |
+ Select-Object -ExpandProperty Path -Unique).Count
+ $defaultRules.Count | Should -Be $expectedNumRules
}
It "is a positional parameter" {
@@ -100,11 +97,7 @@ Describe "Test RuleExtension" {
BeforeAll {
$community = "CommunityAnalyzerRules"
$measureRequired = "Measure-RequiresModules"
- $expectedNumCommunityRules = 10
- if ($PSVersionTable.PSVersion -ge [Version]'4.0.0')
- {
- $expectedNumCommunityRules = 12
- }
+ $expectedNumCommunityRules = 12
}
It "with the module folder path" {
$ruleExtension = Get-ScriptAnalyzerRule -CustomizedRulePath $PSScriptRoot\CommunityAnalyzerRules | Where-Object {$_.SourceName -eq $community}
@@ -154,17 +147,17 @@ Describe "Test RuleExtension" {
Describe "TestSeverity" {
It "filters rules based on the specified rule severity" {
$rules = Get-ScriptAnalyzerRule -Severity Error
- $rules.Count | Should -Be 7
+ $rules.Count | Should -Be 8
}
It "filters rules based on multiple severity inputs"{
$rules = Get-ScriptAnalyzerRule -Severity Error,Information
- $rules.Count | Should -Be 18
+ $rules.Count | Should -Be 19
}
It "takes lower case inputs" {
$rules = Get-ScriptAnalyzerRule -Severity error
- $rules.Count | Should -Be 7
+ $rules.Count | Should -Be 8
}
}
diff --git a/Tests/Engine/Helper.tests.ps1 b/Tests/Engine/Helper.tests.ps1
index b0932be32..3d53e71f1 100644
--- a/Tests/Engine/Helper.tests.ps1
+++ b/Tests/Engine/Helper.tests.ps1
@@ -33,7 +33,7 @@ Describe "Test Directed Graph" {
}
Context "Runspaces should be disposed" {
- It "Running analyzer 100 times should only create a limited number of runspaces" -Skip:$($PSVersionTable.PSVersion.Major -le 4) {
+ It "Running analyzer 100 times should only create a limited number of runspaces" {
$null = 1..100 | ForEach-Object { Invoke-ScriptAnalyzer -ScriptDefinition 'gci' }
(Get-Runspace).Count | Should -BeLessOrEqual 14 -Because 'Number of Runspaces should be bound (size of runspace pool cache is 10)'
}
diff --git a/Tests/Engine/InvokeScriptAnalyzer.tests.ps1 b/Tests/Engine/InvokeScriptAnalyzer.tests.ps1
index 06b94cb78..980836218 100644
--- a/Tests/Engine/InvokeScriptAnalyzer.tests.ps1
+++ b/Tests/Engine/InvokeScriptAnalyzer.tests.ps1
@@ -71,7 +71,7 @@ Describe "Test available parameters" {
}
}
- Context "SaveDscDependency parameter" -Skip:($testingLibraryUsage -or ($PSVersionTable.PSVersion -lt '5.0')) {
+ Context "SaveDscDependency parameter" -Skip:($testingLibraryUsage) {
It "has the parameter" {
$params.ContainsKey("SaveDscDependency") | Should -BeTrue
}
@@ -372,6 +372,7 @@ Describe "Test CustomizedRulePath" {
BeforeAll {
$measureRequired = "CommunityAnalyzerRules\Measure-RequiresModules"
}
+
Context "When used correctly" {
It "with the module folder path" {
$customizedRulePath = Invoke-ScriptAnalyzer $PSScriptRoot\TestScript.ps1 -CustomizedRulePath $PSScriptRoot\CommunityAnalyzerRules | Where-Object { $_.RuleName -eq $measureRequired }
@@ -516,7 +517,6 @@ Describe "Test CustomizedRulePath" {
}
Describe "Test -Fix Switch" {
-
BeforeAll {
$scriptName = "TestScriptWithFixableWarnings.ps1"
$testSource = Join-Path $PSScriptRoot $scriptName
@@ -561,69 +561,89 @@ Describe "Test -EnableExit Switch" {
$pssaPath = (Get-Module PSScriptAnalyzer).Path
- & $pwshExe -Command "Import-Module '$pssaPath'; Invoke-ScriptAnalyzer -ScriptDefinition gci -EnableExit"
+ & $pwshExe -NoProfile -Command "Import-Module '$pssaPath'; Invoke-ScriptAnalyzer -ScriptDefinition gci -EnableExit"
- $LASTEXITCODE | Should -Be 1
+ $LASTEXITCODE | Should -Be 1
}
- Describe "-ReportSummary switch" {
- BeforeAll {
- $pssaPath = (Get-Module PSScriptAnalyzer).Path
+ It "Returns exit code equivalent to number of warnings for multiple piped files" {
+ if ($IsCoreCLR)
+ {
+ $pwshExe = (Get-Process -Id $PID).Path
+ }
+ else
+ {
+ $pwshExe = 'powershell'
+ }
- if ($IsCoreCLR)
- {
- $pwshExe = (Get-Process -Id $PID).Path
- }
- else
- {
- $pwshExe = 'powershell'
- }
+ $pssaPath = (Get-Module PSScriptAnalyzer).Path
- $reportSummaryFor1Warning = '*1 rule violation found. Severity distribution: Error = 0, Warning = 1, Information = 0*'
- }
+ & $pwshExe -NoProfile {
+ Import-Module $Args[0]
+ Get-ChildItem $Args[1] | Invoke-ScriptAnalyzer -EnableExit
+ } -Args $pssaPath, "$PSScriptRoot\RecursionDirectoryTest"
- It "prints the correct report summary using the -NoReportSummary switch" {
- $result = & $pwshExe -Command "Import-Module '$pssaPath'; Invoke-ScriptAnalyzer -ScriptDefinition gci -ReportSummary"
+ $LASTEXITCODE | Should -Be 2
+ }
+}
- "$result" | Should -BeLike $reportSummaryFor1Warning
- }
- It "does not print the report summary when not using -NoReportSummary switch" {
- $result = & $pwshExe -Command "Import-Module '$pssaPath'; Invoke-ScriptAnalyzer -ScriptDefinition gci"
+Describe "-ReportSummary switch" {
+ BeforeAll {
+ $pssaPath = (Get-Module PSScriptAnalyzer).Path
- "$result" | Should -Not -BeLike $reportSummaryFor1Warning
+ if ($IsCoreCLR)
+ {
+ $pwshExe = (Get-Process -Id $PID).Path
+ }
+ else
+ {
+ $pwshExe = 'powershell'
}
+
+ $reportSummaryFor1Warning = '*1 rule violation found. Severity distribution: Error = 0, Warning = 1, Information = 0*'
}
- # using statements are only supported in v5+
- Describe "Handles parse errors due to unknown types" -Skip:($testingLibraryUsage -or ($PSVersionTable.PSVersion -lt '5.0')) {
- BeforeAll {
- $script = @'
- using namespace Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels
- using namespace Microsoft.Azure.Commands.Common.Authentication.Abstractions
- Import-Module "AzureRm"
- class MyClass { [IStorageContext]$StorageContext } # This will result in a parser error due to [IStorageContext] type that comes from the using statement but is not known at parse time
+ It "prints the correct report summary using the -NoReportSummary switch" {
+ $result = & $pwshExe -NoProfile -Command "Import-Module '$pssaPath'; Invoke-ScriptAnalyzer -ScriptDefinition gci -ReportSummary"
+
+ "$result" | Should -BeLike $reportSummaryFor1Warning
+ }
+ It "does not print the report summary when not using -NoReportSummary switch" {
+ $result = & $pwshExe -Command "Import-Module '$pssaPath'; Invoke-ScriptAnalyzer -ScriptDefinition gci"
+
+ "$result" | Should -Not -BeLike $reportSummaryFor1Warning
+ }
+}
+
+# using statements are only supported in v5+
+Describe "Handles parse errors due to unknown types" -Skip:($testingLibraryUsage) {
+ BeforeAll {
+ $script = @'
+ using namespace Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels
+ using namespace Microsoft.Azure.Commands.Common.Authentication.Abstractions
+ Import-Module "AzureRm"
+ class MyClass { [IStorageContext]$StorageContext } # This will result in a parser error due to [IStorageContext] type that comes from the using statement but is not known at parse time
'@
- }
- It "does not throw and detect one expected warning after the parse error has occured when using -ScriptDefintion parameter set" {
- $warnings = Invoke-ScriptAnalyzer -ScriptDefinition $script
- $warnings.Count | Should -Be 1
- $warnings.RuleName | Should -Be 'TypeNotFound'
- }
+ }
+ It "does not throw and detect one expected warning after the parse error has occured when using -ScriptDefintion parameter set" {
+ $warnings = Invoke-ScriptAnalyzer -ScriptDefinition $script
+ $warnings.Count | Should -Be 1
+ $warnings.RuleName | Should -Be 'TypeNotFound'
+ }
- It "does not throw and detect one expected warning after the parse error has occured when using -Path parameter set" {
- $testFilePath = "TestDrive:\testfile.ps1"
- Set-Content $testFilePath -Value $script
- $warnings = Invoke-ScriptAnalyzer -Path $testFilePath
- $warnings.Count | Should -Be 1
- $warnings.RuleName | Should -Be 'TypeNotFound'
- }
+ It "does not throw and detect one expected warning after the parse error has occured when using -Path parameter set" {
+ $testFilePath = "TestDrive:\testfile.ps1"
+ Set-Content $testFilePath -Value $script
+ $warnings = Invoke-ScriptAnalyzer -Path $testFilePath
+ $warnings.Count | Should -Be 1
+ $warnings.RuleName | Should -Be 'TypeNotFound'
}
+}
- Describe 'Handles static Singleton (issue 1182)' -Skip:($testingLibraryUsage -or ($PSVersionTable.PSVersion -lt '5.0')) {
- It 'Does not throw or return diagnostic record' {
- $scriptDefinition = 'class T { static [T]$i }; function foo { [CmdletBinding()] param () $script:T.WriteLog() }'
- Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -ErrorAction Stop | Should -BeNullOrEmpty
- }
+Describe 'Handles static Singleton (issue 1182)' -Skip:($testingLibraryUsage) {
+ It 'Does not throw or return diagnostic record' {
+ $scriptDefinition = 'class T { static [T]$i }; function foo { [CmdletBinding()] param () $script:T.WriteLog() }'
+ Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -ErrorAction Stop | Should -BeNullOrEmpty
}
}
diff --git a/Tests/Engine/ModuleDependencyHandler.tests.ps1 b/Tests/Engine/ModuleDependencyHandler.tests.ps1
index 09bd74912..92735fb9e 100644
--- a/Tests/Engine/ModuleDependencyHandler.tests.ps1
+++ b/Tests/Engine/ModuleDependencyHandler.tests.ps1
@@ -3,7 +3,7 @@
function Get-Skip
{
- if ($testingLibararyUsage -or ($PSVersionTable.PSVersion -lt '5.0'))
+ if ($testingLibararyUsage)
{
return $true
}
@@ -50,7 +50,6 @@ Describe "Resolve DSC Resource Dependency" {
Context "Module handler class" {
BeforeAll {
- if ($PSVersionTable.PSVersion -lt '5.0') { return }
$moduleHandlerType = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.ModuleDependencyHandler]
$oldEnvVars = Get-Item Env:\* | Sort-Object -Property Key
$savedPSModulePath = $env:PSModulePath
@@ -59,7 +58,7 @@ Describe "Resolve DSC Resource Dependency" {
if ( $skipTest ) { return }
$env:PSModulePath = $savedPSModulePath
}
- It "Sets defaults correctly" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ It "Sets defaults correctly" {
$rsp = [runspacefactory]::CreateRunspace()
$rsp.Open()
$depHandler = $moduleHandlerType::new($rsp)
@@ -82,15 +81,15 @@ Describe "Resolve DSC Resource Dependency" {
$rsp.Dispose()
}
- It "Keeps the environment variables unchanged" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ It "Keeps the environment variables unchanged" {
Test-EnvironmentVariables($oldEnvVars)
}
- It "Throws if runspace is null" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ It "Throws if runspace is null" {
{$moduleHandlerType::new($null)} | Should -Throw
}
- It "Throws if runspace is not opened" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ It "Throws if runspace is not opened" {
$rsp = [runspacefactory]::CreateRunspace()
{$moduleHandlerType::new($rsp)} | Should -Throw
$rsp.Dispose()
diff --git a/Tests/Engine/ModuleHelp.Tests.ps1 b/Tests/Engine/ModuleHelp.Tests.ps1
index ac40dcf94..21188e019 100644
--- a/Tests/Engine/ModuleHelp.Tests.ps1
+++ b/Tests/Engine/ModuleHelp.Tests.ps1
@@ -62,14 +62,8 @@ $paramBlockList = @(
'AttachAndDebug' # Reason: When building with DEGUG configuration, an additional parameter 'AttachAndDebug' will be added to Invoke-ScriptAnalyzer and Invoke-Formatter, but there is no Help for those, as they are not intended for production usage.
)
[string] $ModuleName = 'PSScriptAnalyzer'
-if ($PSVersionTable.PSVersion -lt '5.0') {
- $ms = New-Object -TypeName 'Microsoft.PowerShell.Commands.ModuleSpecification' -ArgumentList $ModuleName
- $commands = Get-Command -Module $ms.Name
-}
-else {
- $ms = [Microsoft.PowerShell.Commands.ModuleSpecification]@{ ModuleName = $ModuleName; RequiredVersion = $RequiredVersion }
- $commands = Get-Command -FullyQualifiedModule $ms
-}
+$ms = [Microsoft.PowerShell.Commands.ModuleSpecification]@{ ModuleName = $ModuleName; RequiredVersion = $RequiredVersion }
+$commands = Get-Command -FullyQualifiedModule $ms
$testCases = $commands.ForEach{
@{
@@ -92,9 +86,6 @@ BeforeAll {
$paramBlockList = @(
'AttachAndDebug' # Reason: When building with DEGUG configuration, an additional parameter 'AttachAndDebug' will be added to Invoke-ScriptAnalyzer and Invoke-Formatter, but there is no Help for those, as they are not intended for production usage.
)
- if ($PSVersionTable.PSVersion -lt '5.0') {
- $paramBlockList += 'SaveDscDependency'
- }
}
diff --git a/Tests/Engine/RuleSuppression.tests.ps1 b/Tests/Engine/RuleSuppression.tests.ps1
index c014dbc12..2d31a6ddf 100644
--- a/Tests/Engine/RuleSuppression.tests.ps1
+++ b/Tests/Engine/RuleSuppression.tests.ps1
@@ -56,28 +56,28 @@ Describe "RuleSuppressionWithoutScope" {
It "Suppresses rule with extent created using ScriptExtent constructor" {
Invoke-ScriptAnalyzer `
- -ScriptDefinition $ruleSuppressionAvoidUsernameAndPassword `
- -IncludeRule "PSAvoidUsingUserNameAndPassWordParams" `
- -OutVariable ruleViolations `
- -SuppressedOnly
+ -ScriptDefinition $ruleSuppressionAvoidUsernameAndPassword `
+ -IncludeRule "PSAvoidUsingUserNameAndPassWordParams" `
+ -OutVariable ruleViolations `
+ -SuppressedOnly
$ruleViolations.Count | Should -Be 1
- }
+ }
}
Context "Script" {
It "Does not raise violations" {
- $suppression = $violations | Where-Object {$_.RuleName -eq "PSProvideCommentHelp" }
+ $suppression = $violations | Where-Object { $_.RuleName -eq "PSProvideCommentHelp" }
$suppression.Count | Should -Be 0
- $suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSProvideCommentHelp" }
+ $suppression = $violationsUsingScriptDefinition | Where-Object { $_.RuleName -eq "PSProvideCommentHelp" }
$suppression.Count | Should -Be 0
}
}
Context "RuleSuppressionID" {
It "Only suppress violations for that ID" {
- $suppression = $violations | Where-Object {$_.RuleName -eq "PSAvoidDefaultValueForMandatoryParameter" }
+ $suppression = $violations | Where-Object { $_.RuleName -eq "PSAvoidDefaultValueForMandatoryParameter" }
$suppression.Count | Should -Be 1
- $suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSAvoidDefaultValueForMandatoryParameter" }
+ $suppression = $violationsUsingScriptDefinition | Where-Object { $_.RuleName -eq "PSAvoidDefaultValueForMandatoryParameter" }
$suppression.Count | Should -Be 1
}
@@ -93,10 +93,10 @@ function SuppressPwdParam()
}
'@
Invoke-ScriptAnalyzer `
- -ScriptDefinition $ruleSuppressionIdAvoidPlainTextPassword `
- -IncludeRule "PSAvoidUsingPlainTextForPassword" `
- -OutVariable ruleViolations `
- -SuppressedOnly
+ -ScriptDefinition $ruleSuppressionIdAvoidPlainTextPassword `
+ -IncludeRule "PSAvoidUsingPlainTextForPassword" `
+ -OutVariable ruleViolations `
+ -SuppressedOnly
$ruleViolations.Count | Should -Be 1
}
@@ -246,8 +246,165 @@ function MyFunc
}
}
+ Context "RuleSuppressionID with named arguments" {
+ It "Should work with named argument syntax" {
+ $scriptWithNamedArgs = @'
+function SuppressPasswordParam()
+{
+ [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute(RuleName="PSAvoidUsingPlainTextForPassword", RuleSuppressionId="password1")]
+ param(
+ [string] $password1,
+ [string] $password2
+ )
+}
+'@
+
+ $diagnostics = Invoke-ScriptAnalyzer `
+ -ScriptDefinition $scriptWithNamedArgs `
+ -IncludeRule "PSAvoidUsingPlainTextForPassword"
+ $suppressions = Invoke-ScriptAnalyzer `
+ -ScriptDefinition $scriptWithNamedArgs `
+ -IncludeRule "PSAvoidUsingPlainTextForPassword" `
+ -SuppressedOnly
+
+ # There should be one unsuppressed diagnostic (password2) and one suppressed diagnostic (password1)
+ $diagnostics | Should -HaveCount 1
+ $diagnostics[0].RuleName | Should -BeExactly "PSAvoidUsingPlainTextForPassword"
+ $diagnostics[0].RuleSuppressionID | Should -BeExactly "password2"
+
+ $suppressions | Should -HaveCount 1
+ $suppressions[0].RuleName | Should -BeExactly "PSAvoidUsingPlainTextForPassword"
+ $suppressions[0].RuleSuppressionID | Should -BeExactly "password1"
+ }
+
+ It "Should work with mixed positional and named argument syntax" {
+ $scriptWithMixedArgs = @'
+function SuppressPasswordParam()
+{
+ [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", Scope="Function")]
+ param(
+ [string] $password1,
+ [string] $password2
+ )
+}
+'@
+
+ $diagnostics = Invoke-ScriptAnalyzer `
+ -ScriptDefinition $scriptWithMixedArgs `
+ -IncludeRule "PSAvoidUsingPlainTextForPassword"
+
+ # All violations should be suppressed since there's no RuleSuppressionID filtering
+ $diagnostics | Should -HaveCount 0
+ }
+
+ It "Should work with custom rule from issue #1686 comment" {
+ # This test recreates the exact scenario from GitHub issue 1686 comment
+ # with a custom rule that populates RuleSuppressionID for targeted suppression
+
+ # Custom rule module that creates violations with specific RuleSuppressionIDs
+ $customRuleScript = @'
+function Measure-AvoidFooBarCommand {
+ [CmdletBinding()]
+ [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
+ param(
+ [Parameter(Mandatory)]
+ [ValidateNotNullOrEmpty()]
+ [System.Management.Automation.Language.ScriptBlockAst]
+ $ScriptBlockAst
+ )
+
+ $results = @()
+
+ # Find all command expressions
+ $commandAsts = $ScriptBlockAst.FindAll({
+ param($node)
+ $node -is [System.Management.Automation.Language.CommandAst]
+ }, $true)
+
+ foreach ($commandAst in $commandAsts) {
+ $commandName = $commandAst.GetCommandName()
+ if ($commandName -match '^(Get-FooBar|Set-FooBar)$') {
+ # Create a diagnostic with the command name as RuleSuppressionID
+ $result = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]::new(
+ "Avoid using $commandName command",
+ $commandAst.Extent,
+ 'Measure-AvoidFooBarCommand',
+ 'Warning',
+ $null,
+ $commandName # This becomes the RuleSuppressionID
+ )
+ $results += $result
+ }
+ }
+
+ return $results
+}
+
+Export-ModuleMember -Function Measure-AvoidFooBarCommand
+'@
+
+ # Script that uses the custom rule with targeted suppression
+ $scriptWithCustomRuleSuppression = @'
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('Measure-AvoidFooBarCommand', RuleSuppressionId = 'Get-FooBar', Scope = 'Function', Target = 'Allow-GetFooBar')]
+param()
+
+function Test-BadCommands {
+ Get-FooBar # Line 6 - Should NOT be suppressed (wrong function)
+ Set-FooBar # Line 7 - Should NOT be suppressed (different RuleSuppressionID)
+}
+
+function Allow-GetFooBar {
+ Get-FooBar # Line 11 - Should be suppressed (matches RuleSuppressionId and Target)
+ Set-FooBar # Line 12 - Should NOT be suppressed (different RuleSuppressionID)
+}
+'@
+
+ # Save custom rule to temporary file
+ $customRuleFile = [System.IO.Path]::GetTempFileName()
+ $customRuleModuleFile = [System.IO.Path]::ChangeExtension($customRuleFile, '.psm1')
+ Set-Content -Path $customRuleModuleFile -Value $customRuleScript
+
+ try
+ {
+ # Check suppressed violations - this is the key test for our fix
+ $suppressions = Invoke-ScriptAnalyzer `
+ -ScriptDefinition $scriptWithCustomRuleSuppression `
+ -CustomRulePath $customRuleModuleFile `
+ -SuppressedOnly `
+ -ErrorAction SilentlyContinue
+
+ # The core functionality: RuleSuppressionID with named arguments should work for custom rules
+ # We should have at least one suppressed Get-FooBar violation
+ $suppressions | Should -Not -BeNullOrEmpty -Because "RuleSuppressionID named arguments should work for custom rules"
+
+ $getFooBarSuppressions = $suppressions | Where-Object { $_.RuleSuppressionID -eq 'Get-FooBar' }
+ $getFooBarSuppressions | Should -Not -BeNullOrEmpty -Because "Get-FooBar should be suppressed based on RuleSuppressionID"
+
+ # Verify the suppression occurred in the right function (Allow-GetFooBar)
+ $getFooBarSuppressions | Should -Not -BeNullOrEmpty
+ $getFooBarSuppressions[0].RuleName | Should -BeExactly 'Measure-AvoidFooBarCommand'
+
+ # Get unsuppressed violations to verify selective suppression
+ $diagnostics = Invoke-ScriptAnalyzer `
+ -ScriptDefinition $scriptWithCustomRuleSuppression `
+ -CustomRulePath $customRuleModuleFile `
+ -ErrorAction SilentlyContinue
+
+ # Should still have violations for Set-FooBar (different RuleSuppressionID) and Get-FooBar in wrong function
+ $setFooBarViolations = $diagnostics | Where-Object { $_.RuleSuppressionID -eq 'Set-FooBar' }
+ $setFooBarViolations | Should -Not -BeNullOrEmpty -Because "Set-FooBar should not be suppressed (different RuleSuppressionID)"
+
+ }
+ finally
+ {
+ Remove-Item -Path $customRuleModuleFile -ErrorAction SilentlyContinue
+ Remove-Item -Path $customRuleFile -ErrorAction SilentlyContinue
+ }
+ }
+ }
+
Context "Rule suppression within DSC Configuration definition" {
- It "Suppresses rule" -skip:($IsLinux -or $IsMacOS -or ($PSVersionTable.PSVersion.Major -lt 5)) {
+ It "Suppresses rule" -Skip:($IsLinux -or $IsMacOS) {
$suppressedRule = Invoke-ScriptAnalyzer -ScriptDefinition $ruleSuppressionInConfiguration -SuppressedOnly
$suppressedRule.Count | Should -Be 1
}
@@ -281,9 +438,9 @@ function MyFunc
Describe "RuleSuppressionWithScope" {
Context "FunctionScope" {
It "Does not raise violations" {
- $suppression = $violations | Where-Object {$_.RuleName -eq "PSAvoidUsingPositionalParameters" }
+ $suppression = $violations | Where-Object { $_.RuleName -eq "PSAvoidUsingPositionalParameters" }
$suppression.Count | Should -Be 0
- $suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSAvoidUsingPositionalParameters" }
+ $suppression = $violationsUsingScriptDefinition | Where-Object { $_.RuleName -eq "PSAvoidUsingPositionalParameters" }
$suppression.Count | Should -Be 0
}
}
@@ -353,4 +510,4 @@ Describe "RuleSuppressionWithScope" {
$suppressed.Count | Should -Be 1
}
}
- }
+}
diff --git a/Tests/Engine/RuleSuppressionClass.tests.ps1 b/Tests/Engine/RuleSuppressionClass.tests.ps1
index 28c3aad22..22dc8e333 100644
--- a/Tests/Engine/RuleSuppressionClass.tests.ps1
+++ b/Tests/Engine/RuleSuppressionClass.tests.ps1
@@ -2,11 +2,6 @@
# Licensed under the MIT License.
BeforeAll {
- $script:skipForV3V4 = $true
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0') {
- $script:skipForV3V4 = $false
- }
-
$violationsUsingScriptDefinition = Invoke-ScriptAnalyzer -ScriptDefinition (Get-Content -Raw "$PSScriptRoot\RuleSuppression.ps1")
$violations = Invoke-ScriptAnalyzer "$PSScriptRoot\RuleSuppression.ps1"
}
@@ -14,7 +9,7 @@ BeforeAll {
Describe "RuleSuppressionWithoutScope" {
Context "Class" {
- It "Does not raise violations" -skip:$script:skipForV3V4 {
+ It "Does not raise violations" {
$suppression = $violations | Where-Object {$_.RuleName -eq "PSAvoidUsingInvokeExpression" }
$suppression.Count | Should -Be 0
$suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSAvoidUsingInvokeExpression" }
@@ -23,7 +18,7 @@ Describe "RuleSuppressionWithoutScope" {
}
Context "FunctionInClass" {
- It "Does not raise violations" -skip:$script:skipForV3V4 {
+ It "Does not raise violations" {
$suppression = $violations | Where-Object {$_.RuleName -eq "PSAvoidUsingCmdletAliases" }
$suppression.Count | Should -Be 0
$suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSAvoidUsingCmdletAliases" }
@@ -32,7 +27,7 @@ Describe "RuleSuppressionWithoutScope" {
}
Context "Script" {
- It "Does not raise violations" -skip:$script:skipForV3V4 {
+ It "Does not raise violations" {
$suppression = $violations | Where-Object {$_.RuleName -eq "PSProvideCommentHelp" }
$suppression.Count | Should -Be 0
$suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSProvideCommentHelp" }
@@ -41,7 +36,7 @@ Describe "RuleSuppressionWithoutScope" {
}
Context "RuleSuppressionID" {
- It "Only suppress violations for that ID" -skip:$script:skipForV3V4 {
+ It "Only suppress violations for that ID" {
$suppression = $violations | Where-Object {$_.RuleName -eq "PSAvoidDefaultValueForMandatoryParameter" }
$suppression.Count | Should -Be 1
$suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSAvoidDefaultValueForMandatoryParameter" }
@@ -52,7 +47,7 @@ Describe "RuleSuppressionWithoutScope" {
Describe "RuleSuppressionWithScope" {
Context "FunctionScope" {
- It "Does not raise violations" -skip:$script:skipForV3V4 {
+ It "Does not raise violations" {
$suppression = $violations | Where-Object {$_.RuleName -eq "PSAvoidUsingPositionalParameters" }
$suppression.Count | Should -Be 0
$suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSAvoidUsingPositionalParameters" }
@@ -61,7 +56,7 @@ Describe "RuleSuppressionWithScope" {
}
Context "ClassScope" {
- It "Does not raise violations" -skip:$script:skipForV3V4 {
+ It "Does not raise violations" {
$suppression = $violations | Where-Object {$_.RuleName -eq "PSAvoidUsingConvertToSecureStringWithPlainText" }
$suppression.Count | Should -Be 0
$suppression = $violationsUsingScriptDefinition | Where-Object {$_.RuleName -eq "PSAvoidUsingConvertToSecureStringWithPlainText" }
diff --git a/Tests/Engine/Settings.tests.ps1 b/Tests/Engine/Settings.tests.ps1
index 2e95bdd04..917b4ed8e 100644
--- a/Tests/Engine/Settings.tests.ps1
+++ b/Tests/Engine/Settings.tests.ps1
@@ -377,4 +377,34 @@ Describe "Settings Class" {
@{ Expr = ';)' }
)
}
+
+ Context "FindSettingsMode" {
+ BeforeAll {
+ $findSettingsMode = ($settingsTypeName -as [type]).GetMethod(
+ 'FindSettingsMode',
+ [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Static)
+
+ $outputObject = [System.Object]::new()
+ }
+
+ It "Should detect hashtable" {
+ $settings = @{}
+ $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable"
+ }
+
+ It "Should detect hashtable wrapped by a PSObject" {
+ $settings = [PSObject]@{} # Force the settings hashtable to be wrapped
+ $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "Hashtable"
+ }
+
+ It "Should detect string" {
+ $settings = ""
+ $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File"
+ }
+
+ It "Should detect string wrapped by a PSObject" {
+ $settings = [PSObject]"" # Force the settings string to be wrapped
+ $findSettingsMode.Invoke($null, @($settings, $null, [ref]$outputObject)) | Should -Be "File"
+ }
+ }
}
diff --git a/Tests/Engine/TokenOperations.tests.ps1 b/Tests/Engine/TokenOperations.tests.ps1
index 97fef3958..1bb1d9298 100644
--- a/Tests/Engine/TokenOperations.tests.ps1
+++ b/Tests/Engine/TokenOperations.tests.ps1
@@ -18,4 +18,181 @@ $h = @{
$hashTableAst | Should -BeOfType [System.Management.Automation.Language.HashTableAst]
$hashTableAst.Extent.Text | Should -Be '@{ z = "hi" }'
}
+
+ Context 'Braced Member Access Ranges' {
+
+ BeforeDiscovery {
+ $RangeTests = @(
+ @{
+ Name = 'No braced member access'
+ ScriptDef = '$object.Prop'
+ ExpectedRanges = @()
+ }
+ @{
+ Name = 'No braced member access on braced variable name'
+ ScriptDef = '${object}.Prop'
+ ExpectedRanges = @()
+ }
+ @{
+ Name = 'Braced member access'
+ ScriptDef = '$object.{Prop}'
+ ExpectedRanges = @(
+ ,@(8, 14)
+ )
+ }
+ @{
+ Name = 'Braced member access with spaces'
+ ScriptDef = '$object. { Prop }'
+ ExpectedRanges = @(
+ ,@(9, 17)
+ )
+ }
+ @{
+ Name = 'Braced member access with newline'
+ ScriptDef = "`$object.`n{ Prop }"
+ ExpectedRanges = @(
+ ,@(9, 17)
+ )
+ }
+ @{
+ Name = 'Braced member access with comment'
+ ScriptDef = "`$object. <#comment#>{Prop}"
+ ExpectedRanges = @(
+ ,@(20, 26)
+ )
+ }
+ @{
+ Name = 'Braced member access with multi-line comment'
+ ScriptDef = "`$object. <#`ncomment`n#>{Prop}"
+ ExpectedRanges = @(
+ ,@(22, 28)
+ )
+ }
+ @{
+ Name = 'Braced member access with inline comment'
+ ScriptDef = "`$object. #comment`n{Prop}"
+ ExpectedRanges = @(
+ ,@(18, 24)
+ )
+ }
+ @{
+ Name = 'Braced member access with inner curly braces'
+ ScriptDef = "`$object.{{Prop}}"
+ ExpectedRanges = @(
+ ,@(8, 16)
+ )
+ }
+ @{
+ Name = 'Indexed Braced member access'
+ ScriptDef = "`$object[0].{Prop}"
+ ExpectedRanges = @(
+ ,@(11, 17)
+ )
+ }
+ @{
+ Name = 'Parenthesized Braced member access'
+ ScriptDef = "(`$object).{Prop}"
+ ExpectedRanges = @(
+ ,@(10, 16)
+ )
+ }
+ @{
+ Name = 'Chained Braced member access'
+ ScriptDef = "`$object.{Prop}.{InnerProp}"
+ ExpectedRanges = @(
+ ,@(8, 14)
+ ,@(15, 26)
+ )
+ }
+ @{
+ Name = 'Multiple Braced member access in larger script'
+ ScriptDef = @'
+$var = 1
+$a.prop.{{inner}}
+$a.{
+ $a.{Prop}
+}
+'@
+ ExpectedRanges = @(
+ ,@(17, 26)
+ ,@(30, 47)
+ )
+ }
+ )
+ }
+
+ It 'Should correctly identify range for ' -ForEach $RangeTests {
+ $tokens = $null
+ $parseErrors = $null
+ $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($ScriptDef, [ref] $tokens, [ref] $parseErrors)
+ $tokenOperations = [Microsoft.Windows.PowerShell.ScriptAnalyzer.TokenOperations]::new($tokens, $scriptAst)
+ $ranges = $tokenOperations.GetBracedMemberAccessRanges()
+ $ranges.Count | Should -Be $ExpectedRanges.Count
+ for ($i = 0; $i -lt $ranges.Count; $i++) {
+ $ranges[$i].Item1 | Should -Be $ExpectedRanges[$i][0]
+ $ranges[$i].Item2 | Should -Be $ExpectedRanges[$i][1]
+ }
+ }
+
+ It 'Should not identify dot-sourcing as braced member access' {
+ $scriptText = @'
+. {5+5}
+$a=4;. {10+15}
+'@
+ $tokens = $null
+ $parseErrors = $null
+ $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($scriptText, [ref] $tokens, [ref] $parseErrors)
+ $tokenOperations = [Microsoft.Windows.PowerShell.ScriptAnalyzer.TokenOperations]::new($tokens, $scriptAst)
+ $ranges = $tokenOperations.GetBracedMemberAccessRanges()
+ $ranges.Count | Should -Be 0
+ }
+
+ It 'Should not return a range for an incomplete bracket pair (parse error)' {
+ $scriptText = @'
+$object.{MemberName
+'@
+ $tokens = $null
+ $parseErrors = $null
+ $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($scriptText, [ref] $tokens, [ref] $parseErrors)
+ $tokenOperations = [Microsoft.Windows.PowerShell.ScriptAnalyzer.TokenOperations]::new($tokens, $scriptAst)
+ $ranges = $tokenOperations.GetBracedMemberAccessRanges()
+ $ranges.Count | Should -Be 0
+ }
+
+ It 'Should find the correct range for null-conditional braced member access' {
+ $scriptText = '$object?.{Prop}'
+ $tokens = $null
+ $parseErrors = $null
+ $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($scriptText, [ref] $tokens, [ref] $parseErrors)
+ $tokenOperations = [Microsoft.Windows.PowerShell.ScriptAnalyzer.TokenOperations]::new($tokens, $scriptAst)
+ $ranges = $tokenOperations.GetBracedMemberAccessRanges()
+ $ranges.Count | Should -Be 1
+ $ExpectedRanges = @(
+ ,@(9, 15)
+ )
+ for ($i = 0; $i -lt $ranges.Count; $i++) {
+ $ranges[$i].Item1 | Should -Be $ExpectedRanges[$i][0]
+ $ranges[$i].Item2 | Should -Be $ExpectedRanges[$i][1]
+ }
+ } -Skip:$($PSVersionTable.PSVersion.Major -lt 7)
+
+ It 'Should find the correct range for nested null-conditional braced member access' {
+ $scriptText = '$object?.{Prop?.{InnerProp}}'
+ $tokens = $null
+ $parseErrors = $null
+ $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($scriptText, [ref] $tokens, [ref] $parseErrors)
+ $tokenOperations = [Microsoft.Windows.PowerShell.ScriptAnalyzer.TokenOperations]::new($tokens, $scriptAst)
+ $ranges = $tokenOperations.GetBracedMemberAccessRanges()
+ $ranges.Count | Should -Be 1
+ $ExpectedRanges = @(
+ ,@(9, 28)
+ )
+ for ($i = 0; $i -lt $ranges.Count; $i++) {
+ $ranges[$i].Item1 | Should -Be $ExpectedRanges[$i][0]
+ $ranges[$i].Item2 | Should -Be $ExpectedRanges[$i][1]
+ }
+ } -Skip:$($PSVersionTable.PSVersion.Major -lt 7)
+
+ }
+
}
\ No newline at end of file
diff --git a/Tests/Rules/AlignAssignmentStatement.tests.ps1 b/Tests/Rules/AlignAssignmentStatement.tests.ps1
index 9a94f48ce..1262acdf8 100644
--- a/Tests/Rules/AlignAssignmentStatement.tests.ps1
+++ b/Tests/Rules/AlignAssignmentStatement.tests.ps1
@@ -2,137 +2,956 @@
# Licensed under the MIT License.
BeforeAll {
- $testRootDirectory = Split-Path -Parent $PSScriptRoot
- Import-Module (Join-Path $testRootDirectory "PSScriptAnalyzerTestHelper.psm1")
+ function New-AlignAssignmentSettings {
+ [OutputType([hashtable])]
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [bool]
+ $CheckHashtable = $false,
+ [Parameter()]
+ [bool]
+ $AlignHashtableKvpWithInterveningComment = $false,
+ [Parameter()]
+ [bool]
+ $CheckEnums = $false,
+ [Parameter()]
+ [bool]
+ $IncludeValuelessEnumMembers = $false,
+ [Parameter()]
+ [bool]
+ $AlignEnumMemberWithInterveningComment = $false
+ )
+ return @{
+ IncludeRules = @('PSAlignAssignmentStatement')
+ Rules = @{
+ PSAlignAssignmentStatement = @{
+ Enable = $true
+ CheckHashtable = $CheckHashtable
+ AlignHashtableKvpWithInterveningComment = $AlignHashtableKvpWithInterveningComment
+ CheckEnums = $CheckEnums
+ IncludeValuelessEnumMembers = $IncludeValuelessEnumMembers
+ AlignEnumMemberWithInterveningComment = $AlignEnumMemberWithInterveningComment
+ }
+ }
+ }
+ }
- $ruleConfiguration = @{
- Enable = $true
- CheckHashtable = $true
+ function Get-NonParseDiagnostics {
+ [OutputType([object[]])]
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory, ValueFromPipeline)]
+ [object[]]
+ $Diagnostics
+ )
+ process {
+ $Diagnostics | Where-Object {
+ $_.RuleName -eq 'PSAlignAssignmentStatement'
+ }
+ }
}
- $settings = @{
- IncludeRules = @("PSAlignAssignmentStatement")
- Rules = @{
- PSAlignAssignmentStatement = $ruleConfiguration
+ function Apply-Corrections {
+ [OutputType([string])]
+ [CmdletBinding()]
+ param(
+ [string]
+ $Original,
+ [object[]]
+ $Diagnostics
+ )
+ # Note: This only works to apply the correction extents because all of
+ # our corrections are simple, single line operations.
+ $lines = $Original -split "`n"
+ foreach ($Diagnostic in $Diagnostics) {
+ if (-not $Diagnostic.SuggestedCorrections) {
+ continue
+ }
+ foreach ($extent in $Diagnostic.SuggestedCorrections) {
+ $lineIndex = $extent.StartLineNumber - 1
+ $prefix = $lines[$lineIndex].Substring(
+ 0, $extent.StartColumnNumber - 1
+ )
+ $suffix = $lines[$lineIndex].Substring(
+ $extent.EndColumnNumber - 1
+ )
+ $lines[$lineIndex] = $prefix + $extent.Text + $suffix
+
+ }
}
+ return ($lines -join "`n")
}
}
-Describe "AlignAssignmentStatement" {
- Context "When assignment statements are in hashtable" {
- It "Should find violation when assignment statements are not aligned (whitespace needs to be added)" {
+Describe 'AlignAssignmentStatement' {
+
+ Context 'When checking Hashtables is disabled' {
+
+ It 'Should not find violations in mis-aligned hashtables' {
+ $def = @'
+@{
+ 'Key' = 'Value'
+ 'LongerKey' = 'Value'
+}
+'@
+ $settings = New-AlignAssignmentSettings
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+
+ }
+
+ It 'Should not find violations in DSC configuration blocks' {
+ $def = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name = '"RSAT"'
+ }
+ }
+}
+'@
+ $settings = New-AlignAssignmentSettings
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+
+ } -Skip:($IsLinux -or $IsMacOS)
+
+ }
+
+ Context 'When Hashtable checking is enabled' {
+
+ It 'Should not find violations in empty single-line hashtable' {
+ $def = '@{}'
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+ }
+
+ It 'Should not find violations in empty multi-line hashtable' {
+ $def = @'
+@{
+
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+ }
+
+ It 'Should not find violation in aligned, single-line, single-kvp hashtable' {
+ $def = '@{"Key" = "Value"}'
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It 'Should find violation in mis-aligned, single-line, single-kvp hashtable' {
+ $def = '@{"Key" = "Value"}'
+ $expected = '@{"Key" = "Value"}'
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should not find violations in mis-aligned hashtable with multiple kvp on a single line' {
+ $def = '@{"Key1" = "Value1";"Key2"="Value2"}'
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find violations in well aligned, multi-line, multi-kvp hashtable' {
+ $def = @'
+@{
+ 'Key1' = 'Value1'
+ 'Key2' = 'Value2'
+ 'Key3' = 'Value3'
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+ }
+
+ It 'Should find violations in mis-aligned, multi-line, multi-kvp hashtable' {
+ $def = @'
+@{
+ 'Key1'= 'Value1'
+ 'Key12' = 'Value2'
+ 'Key123' = 'Value3'
+}
+'@
+
+ $expected = @'
+@{
+ 'Key1' = 'Value1'
+ 'Key12' = 'Value2'
+ 'Key123' = 'Value3'
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 2
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should ignore lines with intervening comments when AlignHashtableKvpWithInterveningComment is false' {
+ $def = @'
+@{
+ 'Key1' <#comment#>= 'Value1'
+ 'Key12' = 'Value2'
+ 'Key123' = 'Value3'
+}
+'@
+
+ $expected = @'
+@{
+ 'Key1' <#comment#>= 'Value1'
+ 'Key12' = 'Value2'
+ 'Key123' = 'Value3'
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should align lines with intervening comments when AlignHashtableKvpWithInterveningComment is true' {
+ $def = @'
+@{
+ 'Key1' <#comment#>= 'Value1'
+ 'Key12' = 'Value2'
+ 'Key123' = 'Value3'
+}
+'@
+
+ $expected = @'
+@{
+ 'Key1' <#comment#> = 'Value1'
+ 'Key12' = 'Value2'
+ 'Key123' = 'Value3'
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 3
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should not find violations when intervening comment is already aligned and AlignHashtableKvpWithInterveningComment is true' {
$def = @'
-$hashtable = @{
- property1 = "value"
- anotherProperty = "another value"
+@{
+ 'Key1' <#comment#> = 'Value1'
+ 'Key2' = 'Value2'
+ 'Key3' = 'Value3'
}
'@
- # Expected output after correction should be the following
- # $hashtable = @{
- # property1 = "value"
- # anotherProperty = "another value"
- # }
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
- $violations.Count | Should -Be 1
- Test-CorrectionExtentFromContent $def $violations 1 ' ' ' '
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
}
- It "Should find violation when assignment statements are not aligned (whitespace needs to be removed)" {
+ It 'Should not find violations when intervening comment is on right of equals sign and AlignHashtableKvpWithInterveningComment is true' {
$def = @'
-$hashtable = @{
- property1 = "value"
- anotherProperty = "another value"
+@{
+ 'Key1' = <#comment#> 'Value1'
+ 'Key2' = 'Value2'
+ 'Key3' = 'Value3'
}
'@
- # Expected output should be the following
- # $hashtable = @{
- # property1 = "value"
- # anotherProperty = "another value"
- # }
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
- $violations.Count | Should -Be 1
- Test-CorrectionExtentFromContent $def $violations 1 ' ' ' '
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
}
- It "Should not crash if property name reaches further to the right than the longest property name (regression test for issue 1067)" {
+ It 'Should ignore kvp with a line continuation between key and equals sign' {
$def = @'
-$hashtable = @{ property1 = "value"
- anotherProperty = "another value"
+@{
+ 'LongerKey' `
+ = <#comment#> 'Value1'
+ 'Key1' = 'Value2'
+ 'Key12' = 'Value3'
+}
+'@
+
+ $expected = @'
+@{
+ 'LongerKey' `
+ = <#comment#> 'Value1'
+ 'Key1' = 'Value2'
+ 'Key12' = 'Value3'
}
'@
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings -ErrorAction Stop | Get-Count | Should -Be 0
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
}
- It "Should ignore if a hashtable is empty" {
+ It 'Should correctly align kvp when key is a string containing an equals sign' {
$def = @'
-$x = @{ }
+@{
+ 'key1=5' = 'Value1'
+ 'Key1' = 'Value2'
+ 'Key12' = 'Value3'
+}
'@
- Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Get-Count | Should -Be 0
+ $expected = @'
+@{
+ 'key1=5' = 'Value1'
+ 'Key1' = 'Value2'
+ 'Key12' = 'Value3'
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 3
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should correctly align kvp when key is an expression containing an assignment' {
+ # Note: `($key='key1')` defines the variable `$key` and sets it's
+ # value to 'key1'. The entire expression evaluates to 'key1'
+ # which is then used as the hashtable key. So the first key
+ # at runtime is equal to the string 'key1'.
+ $def = @'
+@{
+ ($key='key1') = 'Value1'
+ 'Key2' = 'Value2'
+ 'Key3' = 'Value3'
+}
+'@
+
+ $expected = @'
+@{
+ ($key='key1') = 'Value1'
+ 'Key2' = 'Value2'
+ 'Key3' = 'Value3'
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 3
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should correctly align hashtables independantly when nested' {
+ $def = @'
+@{
+ 'key1' = 5
+ 'key12' = @{
+ 'nestedKey1' = 'Value1'
+ 'nestedKey12'= 'Value2'
+ 'nestedKey123'= @{
+ 'superNestedKey1' = 'Value1'
+ 'superNestedKey12'='Value2'
}
}
+ 'key123' = 'Value3'
+}
+'@
- Context "When assignment statements are in DSC Configuration" {
- It "Should find violations when assignment statements are not aligned" -skip:($IsLinux -or $IsMacOS) {
+ $expected = @'
+@{
+ 'key1' = 5
+ 'key12' = @{
+ 'nestedKey1' = 'Value1'
+ 'nestedKey12' = 'Value2'
+ 'nestedKey123' = @{
+ 'superNestedKey1' = 'Value1'
+ 'superNestedKey12' ='Value2'
+ }
+ }
+ 'key123' = 'Value3'
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 8
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should not find violations in aligned DSC configuration blocks' {
+ $def = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name = '"RSAT"'
+ }
+ }
+}
+'@
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+
+ } -Skip:($IsLinux -or $IsMacOS)
+
+ It 'Should find violations in mis-aligned DSC configuration blocks' {
$def = @'
-Configuration MyDscConfiguration {
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name = '"RSAT"'
+ }
+ }
+}
+'@
- param(
- [string[]]$ComputerName="localhost"
- )
- Node $ComputerName {
- WindowsFeature MyFeatureInstance {
- Ensure = "Present"
- Name = "RSAT"
+ $expected = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name = '"RSAT"'
}
- WindowsFeature My2ndFeatureInstance {
- Ensure = "Present"
- Name = "Bitlocker"
+ }
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+
+ } -Skip:($IsLinux -or $IsMacOS)
+
+ It 'Should ignore lines in DSC configuration blocks with intervening comments when AlignHashtableKvpWithInterveningComment is false' {
+ $def = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name <#asdasd#>= '"RSAT"'
+ Other = 'Value'
}
}
}
'@
- Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Get-Count | Should -Be 2
+
+ $expected = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name <#asdasd#>= '"RSAT"'
+ Other = 'Value'
}
}
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $false
- if ($PSVersionTable.PSVersion.Major -ge 5) {
- Context "When assignment statements are in DSC Configuration that has parse errors" {
- It "Should find violations when assignment statements are not aligned" -skip:($IsLinux -or $IsMacOS) {
- $def = @'
-Configuration Sample_ChangeDescriptionAndPermissions
-{
- Import-DscResource -Module NonExistentModule
- # A Configuration block can have zero or more Node blocks
- Node $NodeName
- {
- # Next, specify one or more resource blocks
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
- NonExistentModule MySMBShare
- {
- Ensure = "Present"
- Name = "MyShare"
- Path = "C:\Demo\Temp"
- ReadAccess = "author"
- FullAccess = "some other author"
- Description = "This is an updated description for this share"
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ } -Skip:($IsLinux -or $IsMacOS)
+
+ It 'Should align lines in DSC configuration blocks with intervening comments when AlignHashtableKvpWithInterveningComment is true' {
+ $def = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name <#asdasd#>= '"RSAT"'
+ Other = 'Value'
}
}
}
'@
- # This invocation will throw parse error caused by "Undefined DSC resource" because
- # NonExistentModule is not really avaiable to load. Therefore we set erroraction to
- # SilentlyContinue
- Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings -ErrorAction SilentlyContinue |
- Where-Object { $_.Severity -ne "ParseError" } |
- Get-Count |
- Should -Be 4
- }
+
+ $expected = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name <#asdasd#> = '"RSAT"'
+ Other = 'Value'
+ }
+ }
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 3
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ } -Skip:($IsLinux -or $IsMacOS)
+
+ It 'Should ignore lines with a line continuation in DSC configuration blocks' {
+ $def = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name `
+ = '"RSAT"'
+ Other = 'Value'
+ }
+ }
+}
+'@
+
+ $expected = @'
+Configuration C1 {
+ Node localhost {
+ NonExistentResource X {
+ Ensure = '"Present"'
+ Name `
+ = '"RSAT"'
+ Other = 'Value'
}
}
}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckHashtable $true -AlignHashtableKvpWithInterveningComment $false
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ } -Skip:($IsLinux -or $IsMacOS)
+
+ }
+
+ Context 'When Enum checking is disabled' {
+
+ It 'Should not find violations in mis-aligned enums' {
+ $def = @'
+enum E1 {
+ Short = 1
+ Longer = 2
+ Longest = 3
+}
+'@
+ $settings = New-AlignAssignmentSettings
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+
+ }
+
+ }
+
+ Context 'When Enum checking is enabled' {
+
+ It 'Should not find violations in empty single-line enum' {
+ $def = 'enum E1 {}'
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+ }
+
+ It 'Should not find violations in empty multi-line enum' {
+ $def = @'
+enum E1 {
+
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+ }
+
+ It 'Should not find violations in single-member, valueless, single-line enum' {
+ $def = 'enum E1 { Member }'
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+ }
+
+ It 'Should not find violations in aligned single-member, explicitly valued, single-line enum' {
+ $def = 'enum E1 { Member = 1 }'
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics |
+ Should -BeNullOrEmpty
+ }
+
+ It 'Should find violations in mis-aligned single-member, explicitly valued, single-line enum' {
+ $def = 'enum E1 { Member = 1 }'
+
+ $expected = 'enum E1 { Member = 1 }'
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should find violations in mis-aligned single-member, explicitly valued, multi-line enum' {
+ $def = @'
+enum E1 {
+ Member = 1
+}
+'@
+
+ $expected = @'
+enum E1 {
+ Member = 1
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should not find violations in aligned, multi-member enum' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member2 = 2
+ Member3 = 3
+ Member4 = 4
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It 'Should find violations in mis-aligned, multi-member enum' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 = 3
+ Member1234 = 4
+}
+'@
+
+ $expected = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 = 3
+ Member1234 = 4
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 3
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should find violations in mis-aligned, multi-member, mixed-valued enum' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12
+ Member123 = 3
+ Member1234
+}
+'@
+
+ $expected = @'
+enum E1 {
+ Member1 = 1
+ Member12
+ Member123 = 3
+ Member1234
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 1
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should ignore lines with intervening comments when AlignEnumMemberWithInterveningComment is false' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 <#Comment#>= 3
+ Member1234 = 4
+}
+'@
+
+ $expected = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 <#Comment#>= 3
+ Member1234 = 4
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $false
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 2
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should align lines with intervening comments when AlignHashtableKvpWithInterveningComment is true' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 <#Comment#>= 3
+ Member1234 = 4
+}
+'@
+
+ $expected = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 <#Comment#> = 3
+ Member1234 = 4
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 4
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should not find violations when intervening comment is already aligned and AlignEnumMemberWithInterveningComment is true' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 <#Comment#> = 3
+ Member1234 = 4
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find violations when intervening comment is on right of equals sign and AlignEnumMemberWithInterveningComment is true' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12 = 2
+ Member123 = <#Comment#> 3
+ Member1234 = 4
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true -AlignEnumMemberWithInterveningComment $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It 'Should ignore member with a line continuation between name and equals sign' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12 `
+ = 2
+ Member123 = 3
+ Member1234 = 4
+}
+'@
+
+ $expected = @'
+enum E1 {
+ Member1 = 1
+ Member12 `
+ = 2
+ Member123 = 3
+ Member1234 = 4
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 2
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should use valueless members for alignment when IncludeValuelessEnumMembers is true' {
+ $def = @'
+enum E1 {
+ Member1 = 1
+ Member12
+ Member123 = 3
+ Member1234
+}
+'@
+
+ $expected = @'
+enum E1 {
+ Member1 = 1
+ Member12
+ Member123 = 3
+ Member1234
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true -IncludeValuelessEnumMembers $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -HaveCount 2
+
+ $corrected = Apply-Corrections -Original $def -Diagnostics $violations
+ $corrected | Should -BeExactly $expected
+ }
+
+ It 'Should not find violations where all members are valueless and IncludeValuelessEnumMembers is true' {
+ $def = @'
+enum E1 {
+ Member1
+ Member12
+ Member123
+ Member1234
+}
+'@
+
+ $settings = New-AlignAssignmentSettings -CheckEnums $true -IncludeValuelessEnumMembers $true
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings |
+ Get-NonParseDiagnostics
+ $violations | Should -BeNullOrEmpty
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/Tests/Rules/AvoidAssignmentToAutomaticVariable.tests.ps1 b/Tests/Rules/AvoidAssignmentToAutomaticVariable.tests.ps1
index 8130990c2..98f82be41 100644
--- a/Tests/Rules/AvoidAssignmentToAutomaticVariable.tests.ps1
+++ b/Tests/Rules/AvoidAssignmentToAutomaticVariable.tests.ps1
@@ -65,13 +65,22 @@ Describe "AvoidAssignmentToAutomaticVariables" {
It "Variable produces warning of Severity " -TestCases $testCases_AutomaticVariables {
param ($VariableName, $ExpectedSeverity)
- $warnings = Invoke-ScriptAnalyzer -ScriptDefinition "`$${VariableName} = 'foo'" -ExcludeRule PSUseDeclaredVarsMoreThanAssignments
+ [System.Array] $warnings = Invoke-ScriptAnalyzer -ScriptDefinition "`$${VariableName} = 'foo'" -ExcludeRule PSUseDeclaredVarsMoreThanAssignments
$warnings.Count | Should -Be 1
$warnings.Severity | Should -Be $ExpectedSeverity
$warnings.RuleName | Should -Be $ruleName
}
- It "Using Variable as parameter name produces warning of Severity error" -TestCases $testCases_AutomaticVariables {
+ It "Using Variable as foreach assignment produces warning of Severity " -TestCases $testCases_AutomaticVariables {
+ param ($VariableName, $ExpectedSeverity)
+
+ [System.Array] $warnings = Invoke-ScriptAnalyzer -ScriptDefinition "foreach (`$$VariableName in `$foo) {}"
+ $warnings.Count | Should -Be 1
+ $warnings.Severity | Should -Be $ExpectedSeverity
+ $warnings.RuleName | Should -Be $ruleName
+ }
+
+ It "Using Variable as parameter name produces warning of Severity " -TestCases $testCases_AutomaticVariables {
param ($VariableName, $ExpectedSeverity)
[System.Array] $warnings = Invoke-ScriptAnalyzer -ScriptDefinition "function foo{Param(`$$VariableName)}" -ExcludeRule PSReviewUnusedParameter
@@ -80,7 +89,7 @@ Describe "AvoidAssignmentToAutomaticVariables" {
$warnings.RuleName | Should -Be $ruleName
}
- It "Using Variable as parameter name in param block produces warning of Severity error" -TestCases $testCases_AutomaticVariables {
+ It "Using Variable as parameter name in param block produces warning of Severity " -TestCases $testCases_AutomaticVariables {
param ($VariableName, $ExpectedSeverity)
[System.Array] $warnings = Invoke-ScriptAnalyzer -ScriptDefinition "function foo(`$$VariableName){}"
@@ -94,6 +103,13 @@ Describe "AvoidAssignmentToAutomaticVariables" {
$warnings.Count | Should -Be 0
}
+ It "Does not flag true or false being used in ValidateSet" {
+ # All other read-only automatic variables cannot be used in ValidateSet
+ # they result in a ParseError. $true and $false are permitted however.
+ [System.Array] $warnings = Invoke-ScriptAnalyzer -ScriptDefinition 'param([ValidateSet($true,$false)]$MyVar)$MyVar' -ExcludeRule PSReviewUnusedParameter
+ $warnings.Count | Should -Be 0
+ }
+
It "Does not throw a NullReferenceException when using assigning a .Net property to a .Net property (Bug in 1.17.0 - issue 1007)" {
Invoke-ScriptAnalyzer -ScriptDefinition '[foo]::bar = [baz]::qux' -ErrorAction Stop
}
diff --git a/Tests/Rules/AvoidDefaultValueForMandatoryParameter.tests.ps1 b/Tests/Rules/AvoidDefaultValueForMandatoryParameter.tests.ps1
index a087a97f4..32a62f9be 100644
--- a/Tests/Rules/AvoidDefaultValueForMandatoryParameter.tests.ps1
+++ b/Tests/Rules/AvoidDefaultValueForMandatoryParameter.tests.ps1
@@ -6,37 +6,176 @@ BeforeAll {
}
Describe "AvoidDefaultValueForMandatoryParameter" {
- Context "When there are violations" {
- It "has 1 provide default value for mandatory parameter violation with CmdletBinding" {
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'Function foo{ [CmdletBinding()]Param([Parameter(Mandatory)]$Param1=''defaultValue'') }' |
- Where-Object { $_.RuleName -eq $ruleName }
+
+ Context "Basic mandatory parameter violations" {
+ It "should flag mandatory parameter with default value (implicit)" {
+ $script = 'Function Test { Param([Parameter(Mandatory)]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 1
+ }
+
+ It "should flag mandatory parameter with default value (explicit true)" {
+ $script = 'Function Test { Param([Parameter(Mandatory=$true)]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 1
+ }
+
+ It "should flag mandatory parameter with default value (numeric true)" {
+ $script = 'Function Test { Param([Parameter(Mandatory=1)]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 1
+ }
+ }
+
+ Context "Parameter sets (multiple Parameter attributes)" {
+ It "should NOT flag parameter mandatory in some but not all parameter sets" {
+ $script = @'
+Function Test {
+ Param(
+ [Parameter(Mandatory, ParameterSetName='Set1')]
+ [Parameter(ParameterSetName='Set2')]
+ $Param = 'default'
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+
+ It "should flag parameter mandatory in ALL parameter sets" {
+ $script = @'
+Function Test {
+ Param(
+ [Parameter(Mandatory, ParameterSetName='Set1')]
+ [Parameter(Mandatory, ParameterSetName='Set2')]
+ $Param = 'default'
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
$violations.Count | Should -Be 1
}
- It "has 1 provide default value for mandatory=$true parameter violation without CmdletBinding" {
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'Function foo{ Param([Parameter(Mandatory=$true)]$Param1=''defaultValue'') }' |
- Where-Object { $_.RuleName -eq $ruleName }
+ It "should handle mixed mandatory/non-mandatory in multiple parameter sets" {
+ $script = @'
+Function Test {
+ Param(
+ [Parameter(Mandatory=$true, ParameterSetName='Set1')]
+ [Parameter(Mandatory=$false, ParameterSetName='Set2')]
+ [Parameter(ParameterSetName='Set3')]
+ $Param = 'default'
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context "Script-level param blocks" {
+ It "should flag mandatory parameters with defaults in script-level param blocks" {
+ $script = @'
+Param(
+ [Parameter(Mandatory)]
+ $ScriptParam = 'default'
+)
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
$violations.Count | Should -Be 1
}
- It "returns violations when the parameter is specified as mandatory=1 and has a default value" {
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'Function foo{ Param([Parameter(Mandatory=1)]$Param1=''defaultValue'') }' |
- Where-Object { $_.RuleName -eq $ruleName }
+ It "should NOT flag non-mandatory parameters in script-level param blocks" {
+ $script = @'
+Param(
+ [Parameter()]
+ $ScriptParam = 'default'
+)
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context "Non-Parameter attributes" {
+ It "should NOT flag non-Parameter attributes with Mandatory property" {
+ $script = 'Function Test { Param([MyCustomAttribute(Mandatory)]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+
+ It "should NOT flag parameters with only validation attributes" {
+ $script = 'Function Test { Param([ValidateNotNull()]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context "Valid scenarios (no violations)" {
+ It "should NOT flag mandatory parameters without default values" {
+ $script = 'Function Test { Param([Parameter(Mandatory)]$Param) }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+
+ It "should NOT flag non-mandatory parameters with default values" {
+ $script = 'Function Test { Param([Parameter(Mandatory=$false)]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+
+ It "should NOT flag parameters without Parameter attributes" {
+ $script = 'Function Test { Param($Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+
+ It "should NOT flag mandatory=0 parameters" {
+ $script = 'Function Test { Param([Parameter(Mandatory=0)]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context "Complex scenarios" {
+ It "should handle multiple parameters with mixed violations" {
+ $script = @'
+Function Test {
+ Param(
+ [Parameter(Mandatory)]$BadParam = "default",
+ [Parameter()]$GoodParam = "default",
+ [Parameter(Mandatory)]$AnotherBadParam = "default"
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 2
+ }
+
+ It "should work with CmdletBinding" {
+ $script = 'Function Test { [CmdletBinding()]Param([Parameter(Mandatory)]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
$violations.Count | Should -Be 1
}
}
- Context "When there are no violations" {
- It "has 1 provide default value for mandatory parameter violation" {
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'Function foo{ Param([Parameter(Mandatory=$false)]$Param1=''val1'', [Parameter(Mandatory)]$Param2=''val2'', $Param3=''val3'') }' |
- Where-Object { $_.RuleName -eq $ruleName }
+ Context "Edge cases" {
+ It "should handle empty param blocks gracefully" {
+ $script = 'Function Test { Param() }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
$violations.Count | Should -Be 0
}
- It "returns no violations when the parameter is specified as mandatory=0 and has a default value" {
- $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'Function foo{ Param([Parameter(Mandatory=0)]$Param1=''val1'') }' |
- Where-Object { $_.RuleName -eq $ruleName }
+ It "should handle null/empty default values" {
+ $script = 'Function Test { Param([Parameter(Mandatory)]$Param = $null) }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
+ $violations.Count | Should -Be 1
+ }
+
+ It "should handle parameters with multiple non-Parameter attributes" {
+ $script = 'Function Test { Param([ValidateNotNull()][Alias("P")]$Param = "default") }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $script | Where-Object { $_.RuleName -eq $ruleName }
$violations.Count | Should -Be 0
}
}
+
}
diff --git a/Tests/Rules/AvoidGlobalAliases.tests.ps1 b/Tests/Rules/AvoidGlobalAliases.tests.ps1
index e57b00446..bfb1c4e0d 100644
--- a/Tests/Rules/AvoidGlobalAliases.tests.ps1
+++ b/Tests/Rules/AvoidGlobalAliases.tests.ps1
@@ -1,8 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
-$IsV3OrV4 = ($PSVersionTable.PSVersion.Major -eq 3) -or ($PSVersionTable.PSVersion.Major -eq 4)
-
BeforeAll {
$AvoidGlobalAliasesError = "Avoid creating aliases with a Global scope."
$violationName = "PSAvoidGlobalAliases"
@@ -12,17 +10,17 @@ BeforeAll {
Describe "$violationName " {
Context "When there are violations" {
- It "Has 4 avoid global alias violations" -Skip:$IsV3OrV4 {
+ It "Has 4 avoid global alias violations" {
$violations.Count | Should -Be 4
}
- It "Has the correct description message" -Skip:$IsV3OrV4 {
+ It "Has the correct description message" {
$violations[0].Message | Should -Match $AvoidGlobalAliasesError
}
}
Context "When there are no violations" {
- It "Returns no violations" -Skip:$IsV3OrV4 {
+ It "Returns no violations" {
$noViolations.Count | Should -Be 0
}
}
diff --git a/Tests/Rules/AvoidReservedWordsAsFunctionNames.tests.ps1 b/Tests/Rules/AvoidReservedWordsAsFunctionNames.tests.ps1
new file mode 100644
index 000000000..260b9fe3a
--- /dev/null
+++ b/Tests/Rules/AvoidReservedWordsAsFunctionNames.tests.ps1
@@ -0,0 +1,147 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+# Keep in sync with the rule's reserved words list in
+# Rules/AvoidReservedWordsAsFunctionNames.cs
+$reservedWords = @(
+ 'begin','break','catch','class','configuration',
+ 'continue','data','define','do',
+ 'dynamicparam','else','elseif','end',
+ 'enum','exit','filter','finally',
+ 'for','foreach','from','function',
+ 'if','parallel','param','process',
+ 'return','sequence','switch',
+ 'throw','trap','try','type',
+ 'until','using','var','while','workflow'
+)
+
+$randomCasedReservedWords = @(
+ 'bEgIN','bReAk','cAtCh','CLasS','cONfiGuRaTioN',
+ 'cONtiNuE','dAtA','dEFInE','Do',
+ 'DyNaMiCpArAm','eLsE','eLsEiF','EnD',
+ 'EnUm','eXiT','fIlTeR','fINaLLy',
+ 'FoR','fOrEaCh','fROm','fUnCtIoN',
+ 'iF','pArAlLeL','PaRaM','pRoCeSs',
+ 'ReTuRn','sEqUeNcE','SwItCh',
+ 'tHrOw','TrAp','tRy','TyPe',
+ 'uNtIl','UsInG','VaR','wHiLe','wOrKfLoW'
+)
+
+$functionScopes = @(
+ "global", "local", "script", "private"
+)
+
+# Generate all combinations of reserved words and function scopes
+$scopedReservedWordCases = foreach ($scope in $functionScopes) {
+ foreach ($word in $reservedWords) {
+ @{
+ Scope = $scope
+ Name = $word
+ }
+ }
+}
+
+# Build variants of reserved words where the reserverd word:
+# appearing at the start and end of a function
+# name.
+$substringReservedWords = $reservedWords | ForEach-Object {
+ "$($_)A", "A$($_)", $_.Substring(0, $_.Length - 1)
+}
+
+$safeFunctionNames = @(
+ 'Get-Something','Do-Work','Classify-Data','Begin-Process'
+)
+
+BeforeAll {
+ $ruleName = 'PSAvoidReservedWordsAsFunctionNames'
+}
+
+Describe 'AvoidReservedWordsAsFunctionNames' {
+ Context 'When function names are reserved words' {
+ It 'flags reserved word "<_>" as a violation' -TestCases $reservedWords {
+
+ $scriptDefinition = "function $_ { 'test' }"
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+
+ $violations.Count | Should -Be 1
+ $violations[0].Severity | Should -Be 'Warning'
+ $violations[0].RuleName | Should -Be $ruleName
+ # Message text should include the function name as used
+ $violations[0].Message | Should -Be "The reserved word '$_' was used as a function name. This should be avoided."
+ # Extent should ideally capture only the function name
+ $violations[0].Extent.Text | Should -Be $_
+ }
+
+ It 'flags the correct extent for a function named Function' {
+
+ $scriptDefinition = "Function Function { 'test' }"
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+
+ $violations.Count | Should -Be 1
+ $violations[0].Severity | Should -Be 'Warning'
+ $violations[0].RuleName | Should -Be $ruleName
+ # Message text should include the function name as used
+ $violations[0].Message | Should -Be "The reserved word 'Function' was used as a function name. This should be avoided."
+ # Extent should ideally capture only the function name
+ $violations[0].Extent.Text | Should -Be 'Function'
+
+ # Make sure the extent is the correct `Function` (not the one at the
+ # very start)
+ $violations[0].Extent.StartOffset | Should -not -Be 0
+ }
+
+ # Functions can have scopes. So function global:function {} should still
+ # alert.
+ It 'flags reserved word "" with scope "" as a violation' -TestCases $scopedReservedWordCases {
+ param($Scope, $Name)
+
+ $scriptDefinition = "function $($Scope):$($Name) { 'test' }"
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+
+ $violations.Count | Should -Be 1
+ $violations[0].Severity | Should -Be 'Warning'
+ $violations[0].RuleName | Should -Be $ruleName
+ $violations[0].Message | Should -Be "The reserved word '$Name' was used as a function name. This should be avoided."
+ $violations[0].Extent.Text | Should -Be "$($Scope):$($Name)"
+ }
+
+
+ It 'detects case-insensitively for "<_>"' -TestCases $randomCasedReservedWords {
+ $scriptDefinition = "function $_ { }"
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Be "The reserved word '$_' was used as a function name. This should be avoided."
+ }
+
+ It 'reports one finding per offending function' {
+ $scriptDefinition = 'function class { };function For { };function Safe-Name { };function TRy { }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+
+ $violations.Count | Should -Be 3
+ $violations | ForEach-Object { $_.Severity | Should -Be 'Warning' }
+ ($violations | Select-Object -ExpandProperty Extent | Select-Object -ExpandProperty Text) |
+ Sort-Object |
+ Should -Be @('class','For','TRy')
+ }
+ }
+
+ Context 'When there are no violations' {
+ It 'does not flag safe function name "<_>"' -TestCases $safeFunctionNames {
+ $scriptDefinition = "function $_ { }"
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+ $violations.Count | Should -Be 0
+ }
+
+ It 'does not flag when script has no functions' {
+ $scriptDefinition = '"hello";$x = 1..3 | ForEach-Object { $_ }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+ $violations.Count | Should -Be 0
+ }
+
+ It 'does not flag substring-like name "<_>"' -TestCases $substringReservedWords {
+ $scriptDefinition = "function $_ { }"
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule @($ruleName)
+ $violations.Count | Should -Be 0
+ }
+ }
+}
diff --git a/Tests/Rules/AvoidTrailingWhitespace.tests.ps1 b/Tests/Rules/AvoidTrailingWhitespace.tests.ps1
index f130a77c5..e5a45f0d3 100644
--- a/Tests/Rules/AvoidTrailingWhitespace.tests.ps1
+++ b/Tests/Rules/AvoidTrailingWhitespace.tests.ps1
@@ -9,6 +9,9 @@ BeforeAll {
$settings = @{
IncludeRules = @($ruleName)
+ Rules = @{
+ $ruleName = @{}
+ }
}
}
@@ -34,4 +37,26 @@ Describe "AvoidTrailingWhitespace" {
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
Test-CorrectionExtentFromContent $def $violations 1 $Whitespace ''
}
+
+ It 'Should be used by Invoke-Formatter, when in settings, replacing trailing ' -TestCases $testCases {
+ param (
+ [string] $Whitespace
+ )
+ # Test also guards against regression where single-character lines, with trailing whitespace
+ # would be removed entirely. See issues #1757, #1992
+ $def = @"
+Function Get-Example {
+ 'Example'$Whitespace
+}$Whitespace
+"@
+
+ $expected = @"
+Function Get-Example {
+ 'Example'
}
+"@
+ $formatted = Invoke-Formatter -ScriptDefinition $def -Settings $settings
+ $formatted | Should -Be $expected
+ }
+
+}
\ No newline at end of file
diff --git a/Tests/Rules/DscExamplesPresent.tests.ps1 b/Tests/Rules/DscExamplesPresent.tests.ps1
index fd98c00dd..e4dc8e547 100644
--- a/Tests/Rules/DscExamplesPresent.tests.ps1
+++ b/Tests/Rules/DscExamplesPresent.tests.ps1
@@ -5,7 +5,7 @@ BeforeAll {
$ruleName = "PSDSCDscExamplesPresent"
}
- Describe "DscExamplesPresent rule in class based resource" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ Describe "DscExamplesPresent rule in class based resource" {
BeforeAll {
$examplesPath = "$PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\Examples"
$classResourcePath = "$PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1"
diff --git a/Tests/Rules/DscTestsPresent.tests.ps1 b/Tests/Rules/DscTestsPresent.tests.ps1
index b81104d1d..e49e80623 100644
--- a/Tests/Rules/DscTestsPresent.tests.ps1
+++ b/Tests/Rules/DscTestsPresent.tests.ps1
@@ -5,7 +5,7 @@ BeforeAll {
$ruleName = "PSDSCDscTestsPresent"
}
- Describe "DscTestsPresent rule in class based resource" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ Describe "DscTestsPresent rule in class based resource" {
BeforeAll {
$testsPath = "$PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\Tests"
$classResourcePath = "$PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1"
diff --git a/Tests/Rules/PSCredentialType.tests.ps1 b/Tests/Rules/PSCredentialType.tests.ps1
index ceb04e3ee..4aa2c35a7 100644
--- a/Tests/Rules/PSCredentialType.tests.ps1
+++ b/Tests/Rules/PSCredentialType.tests.ps1
@@ -12,9 +12,6 @@ Describe "PSCredentialType" {
Context "When there are violations" {
BeforeAll {
$expectedViolations = 1
- if (($PSVersionTable.PSVersion.Major -eq 3) -or ($PSVersionTable.PSVersion.Major -eq 4)) {
- $expectedViolations = 2
- }
}
It ("has correct count of PSCredential type violations" -f $expectedViolations) {
$violations.Count | Should -Be $expectedViolations
diff --git a/Tests/Rules/PlaceCloseBrace.tests.ps1 b/Tests/Rules/PlaceCloseBrace.tests.ps1
index 85ccb8979..fb741ee2a 100644
--- a/Tests/Rules/PlaceCloseBrace.tests.ps1
+++ b/Tests/Rules/PlaceCloseBrace.tests.ps1
@@ -383,4 +383,49 @@ if ($true) {
$violations.Count | Should -Be 0
}
}
+
+ Context "When formatting presets handle if/else" {
+ BeforeAll {
+ $AllmanDefinition = @"
+if (`$true)
+{
+ 'yes'
+}
+else
+{
+ 'no'
+}
+"@
+ $OTBSDefinition = @"
+if (`$true) {
+ 'yes'
+} else {
+ 'no'
+}
+"@
+ $StroustrupDefinition = @"
+if (`$true) {
+ 'yes'
+}
+else {
+ 'no'
+}
+"@
+ }
+
+ It "Allman should have all opening and closing braces on a new line" {
+ Invoke-Formatter -ScriptDefinition $OTBSDefinition -Settings 'CodeFormattingAllman' | Should -Be $AllmanDefinition
+ Invoke-Formatter -ScriptDefinition $StroustrupDefinition -Settings 'CodeFormattingAllman' | Should -Be $AllmanDefinition
+ }
+
+ It "OTBS should place else on same line as the if closing bracket" {
+ Invoke-Formatter -ScriptDefinition $AllmanDefinition -Settings 'CodeFormattingOTBS' | Should -Be $OTBSDefinition
+ Invoke-Formatter -ScriptDefinition $StroustrupDefinition -Settings 'CodeFormattingOTBS' | Should -Be $OTBSDefinition
+ }
+
+ It "Stroustrup should place else on a new line after the if closing bracket" {
+ Invoke-Formatter -ScriptDefinition $AllmanDefinition -Settings 'CodeFormattingStroustrup' | Should -Be $StroustrupDefinition
+ Invoke-Formatter -ScriptDefinition $OTBSDefinition -Settings 'CodeFormattingStroustrup' | Should -Be $StroustrupDefinition
+ }
+ }
}
diff --git a/Tests/Rules/ProvideCommentHelp.tests.ps1 b/Tests/Rules/ProvideCommentHelp.tests.ps1
index 2216f15f0..f0900b07d 100644
--- a/Tests/Rules/ProvideCommentHelp.tests.ps1
+++ b/Tests/Rules/ProvideCommentHelp.tests.ps1
@@ -22,9 +22,7 @@ BeforeAll {
$violations = Invoke-ScriptAnalyzer $PSScriptRoot\BadCmdlet.ps1 | Where-Object {$_.RuleName -eq $violationName}
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0') {
- $dscViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
- }
+ $dscViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
$noViolations = Invoke-ScriptAnalyzer $PSScriptRoot\GoodCmdlet.ps1 | Where-Object {$_.RuleName -eq $violationName}
@@ -334,7 +332,7 @@ $s$s$s$s
}
- It "Does not count violation in DSC class" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ It "Does not count violation in DSC class" {
$dscViolations.Count | Should -Be 0
}
}
diff --git a/Tests/Rules/ReturnCorrectTypesForDSCFunctions.tests.ps1 b/Tests/Rules/ReturnCorrectTypesForDSCFunctions.tests.ps1
index 1ed4f610f..fc10ae39c 100644
--- a/Tests/Rules/ReturnCorrectTypesForDSCFunctions.tests.ps1
+++ b/Tests/Rules/ReturnCorrectTypesForDSCFunctions.tests.ps1
@@ -8,11 +8,8 @@ BeforeAll {
$violations = Invoke-ScriptAnalyzer $PSScriptRoot\DSCResourceModule\DSCResources\MSFT_WaitForAll\MSFT_WaitForAll.psm1 | Where-Object {$_.RuleName -eq $violationName}
$noViolations = Invoke-ScriptAnalyzer $PSScriptRoot\DSCResourceModule\DSCResources\MSFT_WaitForAny\MSFT_WaitForAny.psm1 | Where-Object {$_.RuleName -eq $violationName}
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0')
- {
- $classViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\BadDscResource\BadDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
- $noClassViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
- }
+ $classViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\BadDscResource\BadDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
+ $noClassViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
}
Describe "ReturnCorrectTypesForDSCFunctions" {
@@ -33,7 +30,7 @@ Describe "ReturnCorrectTypesForDSCFunctions" {
}
}
- Describe "StandardDSCFunctionsInClass" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+ Describe "StandardDSCFunctionsInClass" {
Context "When there are violations" {
It "has 4 return correct types for DSC functions violations" {
$classViolations.Count | Should -Be 4
diff --git a/Tests/Rules/ReviewUnusedParameter.tests.ps1 b/Tests/Rules/ReviewUnusedParameter.tests.ps1
index 59d8b160d..9e4202dcf 100644
--- a/Tests/Rules/ReviewUnusedParameter.tests.ps1
+++ b/Tests/Rules/ReviewUnusedParameter.tests.ps1
@@ -20,6 +20,30 @@ Describe "ReviewUnusedParameter" {
$Violations.Count | Should -Be 2
}
+ It "has 1 violation - function with 1 parameter with ValueFromPipeline set to false and `$_ usage inside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline = $false)] $Param1) process {$_}}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 1
+ }
+
+ It "has 1 violation - function with 1 parameter with ValueFromPipeline set to false and `$PSItem usage inside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline = $false)] $Param1) process {$PSItem}}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 1
+ }
+
+ It "has 1 violation - function with 1 parameter with ValueFromPipeline set to true and `$_ usage outside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline = $true)] $Param1) $_}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 1
+ }
+
+ It "has 1 violation - function with 1 parameter with ValueFromPipeline set to true and `$PSItem usage outside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline = $true)] $Param1) $PSItem}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 1
+ }
+
It "has 1 violation - scriptblock with 1 unused parameter" {
$ScriptDefinition = '{ param ($Param1) }'
$Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
@@ -59,6 +83,30 @@ Describe "ReviewUnusedParameter" {
$Violations.Count | Should -Be 0
}
+ It "has no violation - function with 1 parameter with ValueFromPipeline explictly set to true and `$_ usage inside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline = $true)] $Param1) process {$_}}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 0
+ }
+
+ It "has no violation - function with 1 parameter with ValueFromPipeline explictly set to true and `$PSItem usage inside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline = $true)] $Param1) process {$PSItem}}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 0
+ }
+
+ It "has no violation - function with 1 parameter with ValueFromPipeline implicitly set to true and `$_ usage inside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline)] $Param1) process{$_}}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 0
+ }
+
+ It "has no violation - function with 1 parameter with ValueFromPipeline implicitly set to true and `$PSItem usage inside process block" {
+ $ScriptDefinition = 'function BadFunc1 { param ([Parameter(ValueFromPipeline)] $Param1) process{$PSItem}}'
+ $Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
+ $Violations.Count | Should -Be 0
+ }
+
It "has no violations when using PSBoundParameters" {
$ScriptDefinition = 'function Bound { param ($Param1) Get-Foo @PSBoundParameters }'
$Violations = Invoke-ScriptAnalyzer -ScriptDefinition $ScriptDefinition -IncludeRule $RuleName
diff --git a/Tests/Rules/UseCompatibleSyntax.Tests.ps1 b/Tests/Rules/UseCompatibleSyntax.Tests.ps1
index 64ac464ae..263adf8a8 100644
--- a/Tests/Rules/UseCompatibleSyntax.Tests.ps1
+++ b/Tests/Rules/UseCompatibleSyntax.Tests.ps1
@@ -12,15 +12,10 @@ BeforeDiscovery {
@{ Script = '$y.$methodWithAVeryLongName()'; Versions = @(3) }
@{ Script = '$typeExpression::$staticMember'; Versions = @() }
@{ Script = '$typeExpression::$dynamicStaticMethodName()'; Versions = @(3) }
+ @{ Script = "class MyClass { }"; Versions = @(3,4) }
+ @{ Script = "enum MyEnum { One; Two }"; Versions = @(3,4) }
)
- # PS v3/4 won't parse classes or enums
- if ($PSVersionTable.PSVersion.Major -ge 5)
- {
- $testCases += @(
- @{ Script = "class MyClass { }"; Versions = @(3,4) }
- @{ Script = "enum MyEnum { One; Two }"; Versions = @(3,4) }
- )
- }
+
# PS v6+ won't parse workflows
if ($PSVersionTable.PSVersion.Major -le 5)
{
@@ -79,16 +74,7 @@ Describe "PSUseCompatibleSyntax" {
$diagnostics = Invoke-ScriptAnalyzer -IncludeRule PSUseCompatibleSyntax -Path "$PSScriptRoot/CompatibilityRuleAssets/IncompatibleScript.ps1" -Settings $settings `
| Where-Object { $_.RuleName -eq 'PSUseCompatibleSyntax' }
- if ($PSVersionTable.PSVersion.Major -ge 5)
- {
- $expected = 5
- }
- else
- {
- # PSv3/4 can't detect class/enum parts
- $expected = 4
- }
-
+ $expected = 5
$diagnostics.Count | Should -Be $expected
}
diff --git a/Tests/Rules/UseConsistentIndentation.tests.ps1 b/Tests/Rules/UseConsistentIndentation.tests.ps1
index 1b556baed..0d26ff39d 100644
--- a/Tests/Rules/UseConsistentIndentation.tests.ps1
+++ b/Tests/Rules/UseConsistentIndentation.tests.ps1
@@ -11,7 +11,7 @@ Describe "UseConsistentIndentation" {
function Invoke-FormatterAssertion {
param(
[string] $ScriptDefinition,
- [string] $ExcpectedScriptDefinition,
+ [string] $ExpectedScriptDefinition,
[int] $NumberOfExpectedWarnings,
[hashtable] $Settings
)
@@ -19,9 +19,9 @@ Describe "UseConsistentIndentation" {
# Unit test just using this rule only
$violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
$violations.Count | Should -Be $NumberOfExpectedWarnings -Because $ScriptDefinition
- Invoke-Formatter -ScriptDefinition $scriptDefinition -Settings $settings | Should -Be $expected -Because $ScriptDefinition
+ Invoke-Formatter -ScriptDefinition $scriptDefinition -Settings $settings | Should -Be $ExpectedScriptDefinition -Because $ScriptDefinition
# Integration test with all default formatting rules
- Invoke-Formatter -ScriptDefinition $scriptDefinition | Should -Be $expected -Because $ScriptDefinition
+ Invoke-Formatter -ScriptDefinition $scriptDefinition | Should -Be $ExpectedScriptDefinition -Because $ScriptDefinition
}
}
BeforeEach {
@@ -177,6 +177,18 @@ function test {
'@
Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition | Should -Be $idempotentScriptDefinition
}
+
+ It 'Should find violation in script when LParen is first token on a line and is not followed by Newline' {
+ $ScriptDefinition = @'
+ (foo)
+ (bar)
+'@
+ $ExpectedScriptDefinition = @'
+(foo)
+(bar)
+'@
+ Invoke-FormatterAssertion $ScriptDefinition $ExpectedScriptDefinition 2 $settings
+ }
}
Context "When a sub-expression is provided" {
diff --git a/Tests/Rules/UseConsistentParameterSetName.tests.ps1 b/Tests/Rules/UseConsistentParameterSetName.tests.ps1
new file mode 100644
index 000000000..3ca229fb5
--- /dev/null
+++ b/Tests/Rules/UseConsistentParameterSetName.tests.ps1
@@ -0,0 +1,399 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+BeforeAll {
+ $ruleName = 'PSUseConsistentParameterSetName'
+
+ $ruleSettings = @{
+ Enable = $true
+ }
+ $settings = @{
+ IncludeRules = @($ruleName)
+ Rules = @{ $ruleName = $ruleSettings }
+ }
+}
+
+Describe "UseConsistentParameterSetName" {
+ Context "When there are case mismatch violations between DefaultParameterSetName and ParameterSetName" {
+ It "detects case mismatch between DefaultParameterSetName and ParameterSetName" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName='SetOne')]
+ param(
+ [Parameter(ParameterSetName='setone')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='SetTwo')]
+ [string]$Parameter2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Severity | Should -Be 'Warning'
+ $violations[0].Message | Should -Match "DefaultParameterSetName 'SetOne' does not match the case of ParameterSetName 'setone'"
+ }
+
+ It "detects multiple case mismatches with DefaultParameterSetName" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName='SetOne')]
+ param(
+ [Parameter(ParameterSetName='setone')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='SETONE')]
+ [string]$Parameter2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ $violations | ForEach-Object { $_.Severity | Should -Be 'Warning' }
+ }
+ }
+
+ Context "When there are case mismatch violations between ParameterSetName values" {
+ It "detects case mismatch between different ParameterSetName values" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName='SetOne')]
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='setone')]
+ [string]$Parameter2,
+
+ [Parameter(ParameterSetName='SetTwo')]
+ [string]$Parameter3
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Severity | Should -Be 'Warning'
+ $violations[0].Message | Should -Match "ParameterSetName 'setone' does not match the case of 'SetOne'"
+ }
+
+ It "detects multiple case variations of the same parameter set name" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='setone')]
+ [string]$Parameter2,
+
+ [Parameter(ParameterSetName='SETONE')]
+ [string]$Parameter3
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2 # Two mismatches with the first occurrence
+ $violations | ForEach-Object { $_.Severity | Should -Be 'Warning' }
+ }
+ }
+
+ Context "When DefaultParameterSetName is missing" {
+ It "warns when parameter sets are used but DefaultParameterSetName is not specified" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding()]
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='SetTwo')]
+ [string]$Parameter2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Severity | Should -Be 'Warning'
+ $violations[0].Message | Should -Match "uses parameter sets but does not specify a DefaultParameterSetName"
+ }
+ }
+
+ Context "When a parameter is declared multiple times in the same parameter set" {
+ It "detects duplicate parameter declarations in the same parameter set (explicit)" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ $violations | ForEach-Object { $_.Message | Should -Be "Parameter 'Parameter1' is declared in parameter-set 'SetOne' multiple times." }
+ }
+
+ It "detects duplicate parameter declarations in the same parameter set (implicit via omitted ParameterSetName)" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter()]
+ [Parameter()]
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ $violations | ForEach-Object { $_.Message | Should -Be "Parameter 'Parameter1' is declared in parameter-set '__AllParameterSets' multiple times." }
+ }
+
+ It "detects duplicate parameter declarations in explicit and implicit parameter sets" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ParameterSetName='__AllParameterSets')]
+ [Parameter()]
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ $violations | ForEach-Object { $_.Message | Should -Be "Parameter 'Parameter1' is declared in parameter-set '__AllParameterSets' multiple times." }
+ }
+
+
+ }
+
+ Context "When ParameterSetNames contain inadvisable characters" {
+ It "detects ParameterSetName containing a new line" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ParameterSetName="Set`nOne")]
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Match "should not contain new lines"
+ }
+
+ It "detects ParameterSetName containing a carriage return" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ParameterSetName="Set`rOne")]
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Match "should not contain new lines"
+ }
+
+ It "detects ParameterSetName containing mixed newline types" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ParameterSetName="Set`r`nOne")]
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ }
+
+ It "detects DefaultParameterSetName containing a new line" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName="Set`nOne")]
+ param(
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Match "should not contain new lines"
+ }
+
+ It "detects DefaultParameterSetName containing a carriage return" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName="Set`rOne")]
+ param(
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Match "should not contain new lines"
+ }
+
+ # Missing: DefaultParameterSetName with newlines
+ It "detects DefaultParameterSetName containing mixed newline types" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName="Set`r`nOne")]
+ param([string]$Parameter1)
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ }
+
+ }
+
+ Context "When there are no violations" {
+ It "does not flag functions without CmdletBinding" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It "does not flag functions without parameter sets" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding()]
+ param(
+ [string]$Parameter1,
+ [string]$Parameter2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It "does not flag when DefaultParameterSetName and ParameterSetName cases match exactly" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName='SetOne')]
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='SetTwo')]
+ [string]$Parameter2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It "does not flag when all ParameterSetName cases match exactly" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName='SetOne')]
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter2,
+
+ [Parameter(ParameterSetName='SetTwo')]
+ [string]$Parameter3
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ # This could be a case where the function can be run without any parameters
+ # in the default set.
+ It "does not flag when DefaultParameterSetName doesn't match any ParameterSetName" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName='Default')]
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1,
+
+ [Parameter(ParameterSetName='SetTwo')]
+ [string]$Parameter2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It "handles parameters without attributes correctly" {
+ $scriptDefinition = @'
+function Test-Function {
+ [CmdletBinding(DefaultParameterSetName='SetOne')]
+ param(
+ [Parameter(ParameterSetName='SetOne')]
+ [string]$Parameter1,
+
+ [string]$CommonParameter # No Parameter attribute
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context "Real-world scenarios" {
+ It "handles complex parameter set definitions correctly" {
+ $scriptDefinition = @'
+function Test-ComplexFunction {
+ [CmdletBinding(DefaultParameterSetName='ByName')]
+ param(
+ [Parameter(ParameterSetName='ByName', Mandatory)]
+ [string]$Name,
+
+ [Parameter(ParameterSetName='ByName')]
+ [Parameter(ParameterSetName='ByID')]
+ [string]$ComputerName,
+
+ [Parameter(ParameterSetName='ByID', Mandatory)]
+ [int]$ID
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It "detects case issues in complex scenarios" {
+ $scriptDefinition = @'
+function Test-ComplexFunction {
+ [CmdletBinding(DefaultParameterSetName='ByName')]
+ param(
+ [Parameter(ParameterSetName='byname', Mandatory)]
+ [string]$Name,
+
+ [Parameter(ParameterSetName='ByName')]
+ [Parameter(ParameterSetName='ByID')]
+ [string]$ComputerName,
+
+ [Parameter(ParameterSetName='byid', Mandatory)]
+ [int]$ID
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2 # 'byname' and 'byid' case mismatches
+ $violations | ForEach-Object { $_.Severity | Should -Be 'Warning' }
+ }
+ }
+}
diff --git a/Tests/Rules/UseConsistentParametersKind.Tests.ps1 b/Tests/Rules/UseConsistentParametersKind.Tests.ps1
new file mode 100644
index 000000000..1dfae19f2
--- /dev/null
+++ b/Tests/Rules/UseConsistentParametersKind.Tests.ps1
@@ -0,0 +1,428 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+
+Describe 'UseConsistentParametersKind' {
+ Context 'When preferred parameters kind is set to "ParamBlock" explicitly' {
+
+ BeforeAll {
+ $ruleConfiguration = @{
+ Enable = $true
+ ParametersKind = "ParamBlock"
+ }
+ $settings = @{
+ IncludeRules = @("PSUseConsistentParametersKind")
+ Rules = @{
+ PSUseConsistentParametersKind = $ruleConfiguration
+ }
+ }
+ }
+
+ It "Returns no violations for parameters outside function" {
+ $scriptDefinition = @'
+[Parameter()]$FirstParam
+[Parameter()]$SecondParam
+
+$FirstParam | Out-Null
+$SecondParam | Out-Null
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for param() block outside function" {
+ $scriptDefinition = @'
+param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+)
+
+$FirstParam | Out-Null
+$SecondParam | Out-Null
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function without parameters" {
+ $scriptDefinition = @'
+function Test-Function {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with empty param() block" {
+ $scriptDefinition = @'
+function Test-Function {
+ param()
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with non-empty param() block" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+ )
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function() {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with empty inline parameters and non-empty param() block" {
+ $scriptDefinition = @'
+function Test-Function() {
+ param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+ )
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns violations for function with non-empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+) {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ }
+ }
+
+ Context 'When preferred parameters kind is set to "ParamBlock" via default value' {
+
+ BeforeAll {
+ $ruleConfiguration = @{
+ Enable = $true
+ }
+ $settings = @{
+ IncludeRules = @("PSUseConsistentParametersKind")
+ Rules = @{
+ PSUseConsistentParametersKind = $ruleConfiguration
+ }
+ }
+ }
+
+ It "Returns no violations for parameters outside function" {
+ $scriptDefinition = @'
+[Parameter()]$FirstParam
+[Parameter()]$SecondParam
+
+$FirstParam | Out-Null
+$SecondParam | Out-Null
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for param() block outside function" {
+ $scriptDefinition = @'
+param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+)
+
+$FirstParam | Out-Null
+$SecondParam | Out-Null
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function without parameters" {
+ $scriptDefinition = @'
+function Test-Function {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with empty param() block" {
+ $scriptDefinition = @'
+function Test-Function {
+ param()
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with non-empty param() block" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+ )
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function() {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function with empty inline parameters and non-empty param() block" {
+ $scriptDefinition = @'
+function Test-Function() {
+ param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+ )
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns violations for function with non-empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+) {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ }
+ }
+
+ Context 'When preferred parameters kind is set to "Inline" explicitly' {
+
+ BeforeAll {
+ $ruleConfiguration = @{
+ Enable = $true
+ ParametersKind = "Inline"
+ }
+
+ $settings = @{
+ IncludeRules = @("PSUseConsistentParametersKind")
+ Rules = @{
+ PSUseConsistentParametersKind = $ruleConfiguration
+ }
+ }
+ }
+
+ It "Returns no violations for parameters outside function" {
+ $scriptDefinition = @'
+[Parameter()]$FirstParam
+[Parameter()]$SecondParam
+
+$FirstParam | Out-Null
+$SecondParam | Out-Null
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for param() block outside function" {
+ $scriptDefinition = @'
+param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+)
+
+$FirstParam | Out-Null
+$SecondParam | Out-Null
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns no violations for function without parameters" {
+ $scriptDefinition = @'
+function Test-Function {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns violations for function with empty param() block" {
+ $scriptDefinition = @'
+function Test-Function {
+ param()
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ }
+
+ It "Returns violations for function with non-empty param() block" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+ )
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ }
+
+ It "Returns no violations for function with empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function() {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+
+ It "Returns violations for function with empty inline parameters and non-empty param() block" {
+ $scriptDefinition = @'
+function Test-Function() {
+ param(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+ )
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 1
+ }
+
+ It "Returns no violations for function with non-empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+) {
+ return
+}
+'@
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+ }
+
+ Context 'When rule is disabled explicitly' {
+
+ BeforeAll {
+ $ruleConfiguration = @{
+ Enable = $false
+ ParametersKind = "ParamBlock"
+ }
+ $settings = @{
+ IncludeRules = @("PSUseConsistentParametersKind")
+ Rules = @{
+ PSUseConsistentParametersKind = $ruleConfiguration
+ }
+ }
+ }
+
+ It "Returns no violations for function with non-empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+) {
+ return
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+ }
+
+ Context 'When rule is disabled via default "Enable" value' {
+
+ BeforeAll {
+ $ruleConfiguration = @{
+ ParametersKind = "ParamBlock"
+ }
+ $settings = @{
+ IncludeRules = @("PSUseConsistentParametersKind")
+ Rules = @{
+ PSUseConsistentParametersKind = $ruleConfiguration
+ }
+ }
+ }
+
+ It "Returns no violations for function with non-empty inline parameters" {
+ $scriptDefinition = @'
+function Test-Function(
+ [Parameter()]$FirstParam,
+ [Parameter()]$SecondParam
+) {
+ return
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations | Should -BeNullOrEmpty
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Rules/UseConsistentWhitespace.tests.ps1 b/Tests/Rules/UseConsistentWhitespace.tests.ps1
index 30d8cce57..c2013fa31 100644
--- a/Tests/Rules/UseConsistentWhitespace.tests.ps1
+++ b/Tests/Rules/UseConsistentWhitespace.tests.ps1
@@ -212,6 +212,19 @@ $ht = @{
$ruleConfiguration.CheckSeparator = $false
$ruleConfiguration.IgnoreAssignmentOperatorInsideHashTable = $true
}
+
+ It "Should not find violation if assignment operator is in multi-line hash table and a using statement is present" {
+ $def = @'
+using namespace System.IO
+
+$ht = @{
+ variable = 3
+ other = 4
+}
+'@
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
It "Should not find violation if assignment operator is in multi-line hash table" {
$def = @'
$ht = @{
@@ -514,6 +527,48 @@ if ($true) { Get-Item `
}
}
+ Context "CheckSeparator" {
+ BeforeAll {
+ $ruleConfiguration.CheckInnerBrace = $false
+ $ruleConfiguration.CheckOpenBrace = $false
+ $ruleConfiguration.CheckOpenParen = $false
+ $ruleConfiguration.CheckOperator = $false
+ $ruleConfiguration.CheckPipe = $false
+ $ruleConfiguration.CheckSeparator = $true
+ }
+
+ It "Should find a violation if there is no space after a comma" {
+ $def = '$Array = @(1,2)'
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -HaveCount 1
+ }
+
+ It "Should not find a violation if there is a space after a comma" {
+ $def = '$Array = @(1, 2)'
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -Be $null
+ }
+
+ It "Should not find a violation if there is a new-line after a comma" {
+ $def = @'
+$Array = @(
+ 1,
+ 2
+)
+'@
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -Be $null
+ }
+
+ It "Should not find a violation if there is a comment after the separator" {
+ $def = @'
+$Array = @(
+ 'foo', # Comment Line 1
+ 'FizzBuzz' # Comment Line 2
+)
+'@
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ }
+
Context "CheckParameter" {
BeforeAll {
@@ -535,7 +590,7 @@ bar -h i `
Invoke-ScriptAnalyzer -ScriptDefinition "$def" -Settings $settings | Should -Be $null
}
- It "Should not find no violation if there is always 1 space between parameters except when using colon syntax" {
+ It "Should not find a violation if there is always 1 space between parameters except when using colon syntax" {
$def = 'foo -bar $baz @splattedVariable -bat -parameterName:$parameterValue'
Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -Be $null
}
@@ -585,6 +640,42 @@ bar -h i `
Should -Be "$expected"
}
+ It "Should fix script when a parameter value is a script block spanning multiple lines" {
+ $def = {foo {
+ bar
+} -baz}
+
+ $expected = {foo {
+ bar
+} -baz}
+ Invoke-Formatter -ScriptDefinition "$def" -Settings $settings |
+ Should -Be "$expected"
+ }
+
+ It "Should fix script when a parameter value is a hashtable spanning multiple lines" {
+ $def = {foo @{
+ a = 1
+} -baz}
+
+ $expected = {foo @{
+ a = 1
+} -baz}
+ Invoke-Formatter -ScriptDefinition "$def" -Settings $settings |
+ Should -Be "$expected"
+ }
+
+ It "Should fix script when a parameter value is an array spanning multiple lines" {
+ $def = {foo @(
+ 1
+) -baz}
+
+ $expected = {foo @(
+ 1
+) -baz}
+ Invoke-Formatter -ScriptDefinition "$def" -Settings $settings |
+ Should -Be "$expected"
+ }
+
It "Should fix script when redirects are involved and whitespace is not consistent" {
# Related to Issue #2000
$def = 'foo 3>&1 1>$null 2>&1'
@@ -593,4 +684,131 @@ bar -h i `
Should -Be $expected
}
}
+
+ Context "Braced Member Accessor Handling" {
+ BeforeAll {
+ $ruleConfiguration.CheckInnerBrace = $true
+ $ruleConfiguration.CheckOpenBrace = $false
+ $ruleConfiguration.CheckOpenParen = $false
+ $ruleConfiguration.CheckOperator = $false
+ $ruleConfiguration.CheckPipe = $false
+ $ruleConfiguration.CheckSeparator = $false
+ $ruleConfiguration.CheckParameter = $false
+ }
+
+ It 'Should not find a violation for a simple braced member accessor with no whitespace' {
+ $def = '$variable.{Property}'
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a simple braced member accessor with whitespace after dot' {
+ $def = '$object. {Member}'
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a simple braced member accessor with newline after dot' {
+ $def = "`$object.`n{Member}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a simple braced member accessor with inline comment after dot' {
+ $def = "`$object.<#comment#>{Member}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a simple braced member accessor with inline comment before dot' {
+ $def = "`$object<#comment#>.{Member}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a simple braced member accessor with multiple touching inline comment before dot' {
+ $def = "`$object<#a#><#b#><#c#><#d#>.{Member}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for an indexed braced member accessor' {
+ $def = "`$object[0].{Member}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a parenthesized braced member accessor' {
+ $def = "(`$object).{Member}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a string literal braced member accessor' {
+ $def = "'StringLiteral'.{Length}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for an expandable string literal braced member accessor' {
+ $def = "`"StringLiteral`".{Length}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a here-string braced member accessor' {
+ $def = "@'
+string
+'@.{Length}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a doublequoted here-string braced member accessor' {
+ $def = "@`"
+string
+`"@.{Length}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for a type braced member accessor' {
+ $def = "[System.DayOfWeek].{Assembly}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for an braced member accessor on an identifier' {
+ $def = "`$Object.Property.{Prop}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for an braced member accessor with nested braces' {
+ $def = "`$Object.{{Prop}}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ It 'Should not find a violation for an braced member accessor with nested inner dot' {
+ $def = "`$Object.{`$InnerObject.{Prop}}"
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ }
+
+ # Check that dot-sourcing still causes formatting violations
+ It 'Should find violations for dot-sourcing a script (no space after dot)' {
+ $def = '.{5+5}'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations.Count | Should -Be 2
+ }
+
+ It 'Should find violations for dot-sourcing a script (space after dot)' {
+ $def = '. {5+5}'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations.Count | Should -Be 2
+ }
+
+ It 'Should find violations for dot-sourcing a script (Semi-Colon before dot)' {
+ $def = '$a = 4;. {5+5}'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations.Count | Should -Be 2
+ }
+
+ # PS7 Specific behaviour. QuestionDot token.
+ It 'Should not find a violation for a null conditional braced member accessor' {
+ $def = '${Object}?.{Prop}'
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ } -Skip:$($PSVersionTable.PSVersion.Major -lt 7)
+
+ It 'Should not find a violation for a nested null conditional braced member accessor' {
+ $def = '${Object}?.{${InnerObject}?.{Prop}}'
+ Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings | Should -BeNullOrEmpty
+ } -Skip:$($PSVersionTable.PSVersion.Major -lt 7)
+
+ }
}
diff --git a/Tests/Rules/UseConstrainedLanguageMode.tests.ps1 b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1
new file mode 100644
index 000000000..02de6b9ed
--- /dev/null
+++ b/Tests/Rules/UseConstrainedLanguageMode.tests.ps1
@@ -0,0 +1,864 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+# Tests for UseConstrainedLanguageMode rule
+#
+# Some tests are Windows-specific (COM objects, XAML) and will be skipped on non-Windows platforms.
+
+BeforeDiscovery {
+ # Detect OS for platform-specific tests
+ $script:IsWindowsOS = $true
+ $script:IsLinuxOS = $false
+ $script:IsMacOSOS = $false
+
+ if ($PSVersionTable.PSVersion.Major -ge 6) {
+ # PowerShell Core has built-in OS detection variables
+ $script:IsWindowsOS = $IsWindows
+ $script:IsLinuxOS = $IsLinux
+ $script:IsMacOSOS = $IsMacOS
+ }
+}
+BeforeAll {
+ $testRootDirectory = Split-Path -Parent $PSScriptRoot
+ Import-Module (Join-Path $testRootDirectory "PSScriptAnalyzerTestHelper.psm1")
+
+ $violationName = "PSUseConstrainedLanguageMode"
+ $ruleName = $violationName
+
+ # The rule is disabled by default, so we need to enable it
+ $settings = @{
+ IncludeRules = @($ruleName)
+ Rules = @{
+ $ruleName = @{
+ Enable = $true
+ }
+ }
+ }
+}
+
+Describe "UseConstrainedLanguageMode" {
+Context "When Add-Type is used" {
+ It "Should detect Add-Type usage" {
+ $def = @'
+Add-Type -TypeDefinition @"
+ public class TestType {
+ public static string Test() { return "test"; }
+ }
+"@
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].RuleName | Should -Be $violationName
+ $violations[0].Message | Should -BeLike "*Add-Type*"
+ }
+
+ It "Should not flag other commands" {
+ $def = 'Get-Process | Where-Object { $_.Name -eq "powershell" }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+ }
+
+ Context "When New-Object with COM is used" {
+ It "Should detect disallowed New-Object -ComObject usage" -Skip:(-not $script:IsWindowsOS) {
+ $def = 'New-Object -ComObject "Excel.Application"'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 1
+ $matchingViolations[0].Message | Should -BeLike "*COM object*"
+ }
+
+ It "Should NOT flag allowed COM objects - Scripting.Dictionary" -Skip:(-not $script:IsWindowsOS) {
+ $def = 'New-Object -ComObject "Scripting.Dictionary"'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag allowed COM objects - Scripting.FileSystemObject" -Skip:(-not $script:IsWindowsOS) {
+ $def = 'New-Object -ComObject "Scripting.FileSystemObject"'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag allowed COM objects - VBScript.RegExp" -Skip:(-not $script:IsWindowsOS) {
+ $def = 'New-Object -ComObject "VBScript.RegExp"'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag New-Object with allowed TypeName" {
+ $def = 'New-Object -TypeName System.Collections.ArrayList'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should flag New-Object with disallowed TypeName" {
+ $def = 'New-Object -TypeName System.IO.File'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 1
+ $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*"
+ }
+ }
+
+ Context "When XAML is used" {
+ It "Should detect XAML usage" -Skip:(-not $script:IsWindowsOS) {
+ $def = @'
+$xaml = @"
+
+
+
+"@
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 1
+ $matchingViolations[0].Message | Should -BeLike "*XAML*"
+ }
+ }
+
+ Context "When Invoke-Expression is used" {
+ It "Should detect Invoke-Expression usage" {
+ $def = 'Invoke-Expression "Get-Process"'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 1
+ $matchingViolations[0].Message | Should -BeLike "*Invoke-Expression*"
+ }
+ }
+
+ Context "When dot-sourcing is used" {
+ It "Should detect dot-sourcing in unsigned scripts" {
+ $def = '. $PSScriptRoot\Helper.ps1'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*dot*"
+ }
+ }
+
+ Context "When PowerShell classes are used" {
+ It "Should detect class definition" {
+ $def = @'
+class MyClass {
+ [string]$Name
+ [int]$Value
+
+ MyClass([string]$name) {
+ $this.Name = $name
+ }
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 1
+ $matchingViolations[0].Message | Should -BeLike "*class*MyClass*"
+ }
+
+ It "Should detect multiple class definitions" {
+ $def = @'
+class FirstClass {
+ [string]$Name
+}
+
+class SecondClass {
+ [int]$Value
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 2
+ }
+
+ It "Should not flag enum definitions" {
+ $def = @'
+enum MyEnum {
+ Value1
+ Value2
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ # Enums are allowed, so no class-specific violations
+ # (though we may still flag other issues if present)
+ $classViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*class*"
+ }
+ $classViolations | Should -BeNullOrEmpty
+ }
+ }
+
+ Context "When type expressions are used" {
+ It "Should flag static type reference with new()" {
+ $def = '$instance = [System.IO.Directory]::new()'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*System.IO.Directory*"
+ }
+
+ It "Should flag static method call on disallowed type" {
+ $def = '[System.IO.File]::ReadAllText("C:\test.txt")'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*System.IO.File*"
+ }
+
+ It "Should NOT flag static reference to allowed type" {
+ $def = '[string]::Empty'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $typeExprViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*type expression*string*"
+ }
+ $typeExprViolations | Should -BeNullOrEmpty
+ }
+ }
+
+ Context "When module manifests (.psd1) are analyzed" {
+ BeforeAll {
+ $tempPath = Join-Path $TestDrive "TestManifests"
+ New-Item -Path $tempPath -ItemType Directory -Force | Out-Null
+ }
+
+ It "Should flag wildcard in FunctionsToExport" {
+ $manifestPath = Join-Path $tempPath "WildcardFunctions.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ FunctionsToExport = '*'
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*FunctionsToExport*wildcard*"
+ }
+
+ It "Should flag wildcard array in FunctionsToExport" {
+ $manifestPath = Join-Path $tempPath "WildcardFunctions.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ FunctionsToExport = @('*')
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*FunctionsToExport*wildcard*"
+ }
+
+ It "Should flag wildcard in CmdletsToExport" {
+ $manifestPath = Join-Path $tempPath "WildcardCmdlets.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ CmdletsToExport = '*'
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*CmdletsToExport*wildcard*"
+ }
+
+ It "Should NOT flag explicit list of exports" {
+ $manifestPath = Join-Path $tempPath "ExplicitExports.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ FunctionsToExport = @('Get-MyFunction', 'Set-MyFunction')
+ CmdletsToExport = @('Get-MyCmdlet')
+ AliasesToExport = @()
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $wildcardViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*wildcard*"
+ }
+ $wildcardViolations | Should -BeNullOrEmpty
+ }
+
+ It "Should flag .ps1 file in RootModule" {
+ $manifestPath = Join-Path $tempPath "ScriptRootModule.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ RootModule = 'MyModule.ps1'
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*RootModule*MyModule.ps1*"
+ }
+
+ It "Should flag .ps1 file in NestedModules" {
+ $manifestPath = Join-Path $tempPath "ScriptNestedModule.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ NestedModules = @('Helper.ps1', 'Utility.psm1')
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*NestedModules*Helper.ps1*"
+ }
+
+ It "Should NOT flag .psm1 or .dll modules" {
+ $manifestPath = Join-Path $tempPath "BinaryModules.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ RootModule = 'MyModule.psm1'
+ NestedModules = @('Helper.dll', 'Utility.psm1')
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $scriptModuleViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*.ps1*"
+ }
+ $scriptModuleViolations | Should -BeNullOrEmpty
+ }
+
+ It "Should flag .ps1 file in ScriptsToProcess" {
+ $manifestPath = Join-Path $tempPath "ScriptsToProcess.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ ScriptsToProcess = @('Init.ps1', 'Setup.ps1')
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*ScriptsToProcess*Init.ps1*"
+ }
+
+ It "Should use different error message for ScriptsToProcess" {
+ $manifestPath = Join-Path $tempPath "ScriptsToProcessMessage.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ ScriptsToProcess = 'Init.ps1'
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 1
+ # ScriptsToProcess should have a specific message about caller's session state
+ $matchingViolations[0].Message | Should -BeLike "*caller*session state*"
+ $matchingViolations[0].Message | Should -BeLike "*Init.ps1*"
+ }
+
+ It "Should flag single-item array in ScriptsToProcess" {
+ $manifestPath = Join-Path $tempPath "ScriptsToProcessArray.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ ScriptsToProcess = @('Init.ps1')
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 1
+ $matchingViolations[0].Message | Should -BeLike "*ScriptsToProcess*Init.ps1*"
+ }
+
+ It "Should NOT flag .psm1 files in ScriptsToProcess" {
+ $manifestPath = Join-Path $tempPath "ScriptsToProcessPsm1.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ ScriptsToProcess = @('Init.psm1')
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $scriptViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*ScriptsToProcess*"
+ }
+ $scriptViolations | Should -BeNullOrEmpty
+ }
+
+ It "Should flag both wildcard and .ps1 issues in same manifest" {
+ $manifestPath = Join-Path $tempPath "MultipleIssues.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ RootModule = 'MyModule.ps1'
+ FunctionsToExport = '*'
+ CmdletsToExport = '*'
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should have at least 3 violations: RootModule .ps1, FunctionsToExport *, CmdletsToExport *
+ $matchingViolations.Count | Should -BeGreaterOrEqual 3
+ }
+
+ It "Should flag ScriptsToProcess and RootModule with different messages" {
+ $manifestPath = Join-Path $tempPath "MixedScriptFields.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ RootModule = 'MyModule.ps1'
+ ScriptsToProcess = @('Init.ps1')
+}
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 2
+
+ # Check that we have both types of messages
+ $scriptsToProcessMsg = $matchingViolations | Where-Object { $_.Message -like "*caller*session state*" }
+ $rootModuleMsg = $matchingViolations | Where-Object { $_.Message -like "*RootModule*" -and $_.Message -notlike "*caller*session state*" }
+
+ $scriptsToProcessMsg.Count | Should -Be 1
+ $rootModuleMsg.Count | Should -Be 1
+
+ # Verify the specific field names are mentioned
+ $scriptsToProcessMsg[0].Message | Should -BeLike "*Init.ps1*"
+ $rootModuleMsg[0].Message | Should -BeLike "*MyModule.ps1*"
+ }
+ }
+
+ Context "Rule severity" {
+ It "Should have Warning severity" {
+ $def = 'Add-Type -AssemblyName System.Windows.Forms'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations[0].Severity | Should -Be 'Warning'
+ }
+ }
+
+ Context "When type constraints are used" {
+ It "Should flag disallowed type constraint on parameter" {
+ $def = 'function Test { param([System.IO.File]$FileHelper) }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*"
+ }
+
+ It "Should flag disallowed type constraint on variable declaration" {
+ $def = '[System.IO.File]$fileHelper = $null'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*"
+ }
+
+ It "Should flag disallowed type cast on variable assignment" {
+ $def = '$fileHelper = [System.IO.File]$value'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*System.IO.File*not permitted*"
+ }
+
+ It "Should NOT flag allowed type constraint" {
+ $def = 'function Test { param([string]$Name, [int]$Count, [hashtable]$Data) }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag allowed type cast on variable" {
+ $def = '[string]$name = $null'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should flag multiple type issues in same script" {
+ $def = @'
+function Test {
+ param([System.IO.File]$FileHelper)
+ [System.IO.Directory]$dirHelper = $null
+ $pathHelper = [System.IO.Path]::new()
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should flag: 1) param type constraint, 2) variable type constraint, 3) type expression
+ # Note: May also flag member access if methods/properties are called on typed variables
+ $matchingViolations.Count | Should -BeGreaterOrEqual 3
+ }
+ }
+
+ Context "When PSCustomObject type cast is used" {
+ It "Should flag [PSCustomObject]@{} syntax" {
+ $def = '$obj = [PSCustomObject]@{ Name = "Test"; Value = 42 }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -BeGreaterThan 0
+ $matchingViolations[0].Message | Should -BeLike "*PSCustomObject*"
+ }
+
+ It "Should flag multiple [PSCustomObject]@{} instances" {
+ $def = @'
+$obj1 = [PSCustomObject]@{ Name = "Test1" }
+$obj2 = [PSCustomObject]@{ Name = "Test2" }
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ $matchingViolations.Count | Should -Be 2
+ }
+
+ It "Should NOT flag PSCustomObject as parameter type" {
+ $def = 'function Test { param([PSCustomObject]$InputObject) }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag New-Object PSObject" {
+ $def = '$obj = New-Object PSObject -Property @{ Name = "Test" }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag plain hashtables" {
+ $def = '$obj = @{ Name = "Test"; Value = 42 }'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag [PSCustomObject] with variable (not hashtable literal)" {
+ $def = '$hash = @{}; $obj = [PSCustomObject]$hash'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # This is a type cast but not the @{} literal pattern
+ # Since PSCustomObject is in allowed list, this won't be flagged
+ $matchingViolations | Should -BeNullOrEmpty
+ }
+
+ }
+
+ Context "When instance methods are invoked on disallowed types" {
+ It "Should flag method invocation on parameter with disallowed type constraint" {
+ $def = @'
+function Read-File {
+ param([System.IO.File]$FileHelper, [string]$Path)
+ $FileHelper.ReadAllText($Path)
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should flag both the type constraint AND the member access
+ $matchingViolations.Count | Should -BeGreaterThan 1
+ # At least one violation should mention ReadAllText
+ ($matchingViolations.Message | Where-Object { $_ -like "*ReadAllText*" }).Count | Should -BeGreaterThan 0
+ }
+
+ It "Should flag property access on variable with disallowed type constraint" {
+ $def = @'
+function Test {
+ param([System.IO.FileInfo]$FileHelper)
+ $fullPath = $FileHelper.FullName
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should flag both the type constraint AND the member access
+ $matchingViolations.Count | Should -BeGreaterThan 1
+ # At least one violation should mention FullName
+ ($matchingViolations.Message | Where-Object { $_ -like "*FullName*" }).Count | Should -BeGreaterThan 0
+ }
+
+ It "Should flag method invocation on typed variable assignment" {
+ $def = @'
+[System.IO.File]$fileHelper = $null
+$result = $fileHelper.ReadAllText("C:\test.txt")
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should flag both the type constraint AND the member access
+ $matchingViolations.Count | Should -BeGreaterThan 1
+ # At least one violation should mention ReadAllText
+ ($matchingViolations.Message | Where-Object { $_ -like "*ReadAllText*" }).Count | Should -BeGreaterThan 0
+ }
+
+ It "Should NOT flag method invocation on allowed types" {
+ $def = @'
+function Test {
+ param([string]$Text)
+ $upper = $Text.ToUpper()
+ $length = $Text.Length
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $violations | Where-Object { $_.RuleName -eq $violationName } | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag static method calls on disallowed types (already caught by type expression check)" {
+ $def = '[System.IO.File]::Exists("test.txt")'
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should only flag once for the type expression, not for member access
+ $matchingViolations.Count | Should -Be 1
+ $matchingViolations[0].Message | Should -BeLike "*System.IO.File*"
+ }
+
+ It "Should flag chained method calls on disallowed types" {
+ $def = @'
+function Test {
+ param([System.IO.FileInfo]$FileHelper)
+ $result = $FileHelper.OpenText().ReadToEnd()
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should flag the type constraint and at least the first member access
+ $matchingViolations.Count | Should -BeGreaterThan 1
+ }
+
+ It "Should handle complex scenarios with multiple typed variables" {
+ $def = @'
+function Complex-Test {
+ param(
+ [System.IO.File]$FileHelper,
+ [System.IO.Directory]$DirHelper,
+ [string]$SafeString
+ )
+
+ $data = $FileHelper.ReadAllBytes("C:\test.bin")
+ $DirHelper.GetFiles("C:\temp")
+ $upper = $SafeString.ToUpper()
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+ # Should flag: 2 type constraints + 2 method invocations (not SafeString)
+ $matchingViolations.Count | Should -BeGreaterThan 2
+ }
+ }
+
+ Context "When scripts are digitally signed" {
+ BeforeAll {
+ $tempPath = Join-Path $TestDrive "SignedScripts"
+ New-Item -Path $tempPath -ItemType Directory -Force | Out-Null
+ }
+
+ It "Should NOT flag Add-Type in signed scripts" {
+ $scriptPath = Join-Path $tempPath "SignedWithAddType.ps1"
+ $scriptContent = @'
+Add-Type -TypeDefinition "public class Test { }"
+
+# SIG # Begin signature block
+# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
+# SIG # End signature block
+'@
+ Set-Content -Path $scriptPath -Value $scriptContent
+ $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings
+ $addTypeViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*Add-Type*"
+ }
+ $addTypeViolations | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag disallowed types in signed scripts" {
+ $scriptPath = Join-Path $tempPath "SignedWithDisallowedType.ps1"
+ $scriptContent = @'
+$fileHelper = New-Object System.IO.FileInfo("C:\test.txt")
+$data = $fileHelper.OpenText()
+
+# SIG # Begin signature block
+# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
+# SIG # End signature block
+'@
+ Set-Content -Path $scriptPath -Value $scriptContent
+ $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings
+ $typeViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*FileInfo*type*"
+ }
+ $typeViolations | Should -BeNullOrEmpty
+ }
+
+ It "Should NOT flag classes in signed scripts" {
+ $scriptPath = Join-Path $tempPath "SignedWithClass.ps1"
+ $scriptContent = @'
+class MyClass {
+ [string]$Name
+}
+
+# SIG # Begin signature block
+# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
+# SIG # End signature block
+'@
+ Set-Content -Path $scriptPath -Value $scriptContent
+ $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings
+ $classViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*class*MyClass*"
+ }
+ $classViolations | Should -BeNullOrEmpty
+ }
+
+ It "Should STILL flag dot-sourcing in signed scripts" {
+ $scriptPath = Join-Path $tempPath "SignedWithDotSource.ps1"
+ $scriptContent = @'
+. .\Helper.ps1
+. .\U tility.ps1
+
+# SIG # Begin signature block
+# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
+# SIG # End signature block
+'@
+ Set-Content -Path $scriptPath -Value $scriptContent
+ $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings
+ $dotSourceViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*dot*"
+ }
+ # Dot-sourcing should still be flagged even in signed scripts
+ $dotSourceViolations.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should STILL flag disallowed parameter types in signed scripts" {
+ $scriptPath = Join-Path $tempPath "SignedWithBadParam.ps1"
+ $scriptContent = @'
+function Test {
+ param([System.IO.File]$FileHelper)
+ Write-Output "Test"
+}
+
+# SIG # Begin signature block
+# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
+# SIG # End signature block
+'@
+ Set-Content -Path $scriptPath -Value $scriptContent
+ $violations = Invoke-ScriptAnalyzer -Path $scriptPath -Settings $settings
+ $paramViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*File*"
+ }
+ # Parameter type constraints should still be flagged
+ $paramViolations.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should STILL flag wildcard exports in signed manifests" {
+ $manifestPath = Join-Path $tempPath "SignedManifest.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ FunctionsToExport = '*'
+}
+
+# SIG # Begin signature block
+# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
+# SIG # End signature block
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $wildcardViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*wildcard*"
+ }
+ # Wildcard exports should still be flagged
+ $wildcardViolations.Count | Should -BeGreaterThan 0
+ }
+
+ It "Should STILL flag .ps1 modules in signed manifests" {
+ $manifestPath = Join-Path $tempPath "SignedManifestWithScript.psd1"
+ $manifestContent = @'
+@{
+ ModuleVersion = '1.0.0'
+ GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
+ RootModule = 'MyModule.ps1'
+}
+
+# SIG # Begin signature block
+# MIIFFAYJKoZIhvcNAQcCoIIFBTCCBQECAQExCzAJ...
+# SIG # End signature block
+'@
+ Set-Content -Path $manifestPath -Value $manifestContent
+ $violations = Invoke-ScriptAnalyzer -Path $manifestPath -Settings $settings
+ $scriptModuleViolations = $violations | Where-Object {
+ $_.RuleName -eq $violationName -and $_.Message -like "*.ps1*"
+ }
+ # Script modules should still be flagged
+ $scriptModuleViolations.Count | Should -BeGreaterThan 0
+ }
+ }
+
+ Context "Performance with large scripts" {
+ It "Should handle scripts with many typed variables and member invocations efficiently" {
+ # This test verifies the O(N+M) cache optimization
+ # Without caching, this would be O(N*M) and very slow
+
+ # Build a script with many typed variables and member invocations
+ $scriptBuilder = [System.Text.StringBuilder]::new()
+ [void]$scriptBuilder.AppendLine('function Test-Performance {')
+ [void]$scriptBuilder.AppendLine(' param([string]$Path)')
+
+ # Add 30 typed variable assignments
+ for ($i = 1; $i -le 30; $i++) {
+ [void]$scriptBuilder.AppendLine(" [System.IO.File]`$file$i = `$null")
+ }
+
+ # Add 50 member invocations (testing cache reuse)
+ for ($i = 1; $i -le 50; $i++) {
+ $varNum = ($i % 30) + 1
+ [void]$scriptBuilder.AppendLine(" `$result$i = `$file$varNum.ReadAllText(`$Path)")
+ }
+
+ [void]$scriptBuilder.AppendLine('}')
+ $def = $scriptBuilder.ToString()
+
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+
+ # Should detect violations (30 type constraints + 50 member accesses = 80+)
+ $matchingViolations.Count | Should -BeGreaterThan 50
+ }
+
+ It "Should cache results per scope correctly" {
+ # Test that cache is scoped properly and doesn't leak between functions
+ $def = @'
+function Function1 {
+ [System.IO.File]$file1 = $null
+ $result1 = $file1.ReadAllText("C:\test1.txt")
+}
+
+function Function2 {
+ [System.IO.Directory]$file1 = $null
+ $result2 = $file1.GetFiles("C:\temp")
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $def -Settings $settings
+ $matchingViolations = $violations | Where-Object { $_.RuleName -eq $violationName }
+
+ # Should detect violations in both functions
+ # Each function has: 1 type constraint + 1 member access = 2 violations each
+ $matchingViolations.Count | Should -BeGreaterOrEqual 4
+
+ # Verify both File and Directory are mentioned
+ $messages = $matchingViolations.Message -join ' '
+ $messages | Should -BeLike "*File*"
+ $messages | Should -BeLike "*Directory*"
+ }
+ }
+}
diff --git a/Tests/Rules/UseCorrectCasing.tests.ps1 b/Tests/Rules/UseCorrectCasing.tests.ps1
index e22f5308f..7caafe4e0 100644
--- a/Tests/Rules/UseCorrectCasing.tests.ps1
+++ b/Tests/Rules/UseCorrectCasing.tests.ps1
@@ -3,11 +3,11 @@
Describe "UseCorrectCasing" {
It "corrects case of simple cmdlet" {
- Invoke-Formatter 'get-childitem' | Should -Be 'Get-ChildItem'
+ Invoke-Formatter 'get-childitem' | Should -BeExactly 'Get-ChildItem'
}
It "corrects case of fully qualified cmdlet" {
- Invoke-Formatter 'Microsoft.PowerShell.management\get-childitem' | Should -Be 'Microsoft.PowerShell.Management\Get-ChildItem'
+ Invoke-Formatter 'Microsoft.PowerShell.management\get-childitem' | Should -BeExactly 'Microsoft.PowerShell.Management\Get-ChildItem'
}
It "corrects case of of cmdlet inside interpolated string" {
@@ -15,18 +15,18 @@ Describe "UseCorrectCasing" {
}
It "Corrects alias correctly" {
- Invoke-Formatter 'Gci' | Should -Be 'gci'
- Invoke-Formatter '?' | Should -Be '?'
+ Invoke-Formatter 'Gci' | Should -BeExactly 'gci'
+ Invoke-Formatter '?' | Should -BeExactly '?'
}
It "Does not corrects applications on the PATH" -Skip:($IsLinux -or $IsMacOS) {
- Invoke-Formatter 'Cmd' | Should -Be 'Cmd'
- Invoke-Formatter 'MORE' | Should -Be 'MORE'
+ Invoke-Formatter 'Git' | Should -BeExactly 'Git'
+ Invoke-Formatter 'SSH' | Should -BeExactly 'SSH'
}
It "Preserves extension of applications on Windows" -Skip:($IsLinux -or $IsMacOS) {
- Invoke-Formatter 'cmd.exe' | Should -Be 'cmd.exe'
- Invoke-Formatter 'more.com' | Should -Be 'more.com'
+ Invoke-Formatter 'cmd.exe' | Should -BeExactly 'cmd.exe'
+ Invoke-Formatter 'more.com' | Should -BeExactly 'more.com'
}
It "Preserves full application path" {
@@ -36,37 +36,38 @@ Describe "UseCorrectCasing" {
else {
$applicationPath = "${env:WINDIR}\System32\cmd.exe"
}
- Invoke-Formatter ". $applicationPath" | Should -Be ". $applicationPath"
+ Invoke-Formatter ". $applicationPath" | Should -BeExactly ". $applicationPath"
}
- It "Corrects case of script function" {
- function Invoke-DummyFunction { }
- Invoke-Formatter 'invoke-dummyFunction' | Should -Be 'Invoke-DummyFunction'
+ # TODO: Can we make this work?
+ # There is a limitation in the Helper's CommandCache: it doesn't see commands that are (only temporarily) defined in the current scope
+ It "Corrects case of script function" -Skip {
+ function global:Invoke-DummyFunction { }
+ Invoke-Formatter 'invoke-dummyFunction' | Should -BeExactly 'Invoke-DummyFunction'
}
It "Preserves script path" {
$path = Join-Path $TestDrive "$([guid]::NewGuid()).ps1"
New-Item -ItemType File -Path $path
$scriptDefinition = ". $path"
- Invoke-Formatter $scriptDefinition | Should -Be $scriptDefinition
+ Invoke-Formatter $scriptDefinition | Should -BeExactly $scriptDefinition
}
It "Preserves UNC script path" -Skip:($IsLinux -or $IsMacOS) {
$uncPath = [System.IO.Path]::Combine("\\$(HOSTNAME.EXE)\C$\", $TestDrive, "$([guid]::NewGuid()).ps1")
New-Item -ItemType File -Path $uncPath
$scriptDefinition = ". $uncPath"
- Invoke-Formatter $scriptDefinition | Should -Be $scriptDefinition
+ Invoke-Formatter $scriptDefinition | Should -BeExactly $scriptDefinition
}
It "Corrects parameter casing" {
- function Invoke-DummyFunction ($ParameterName) { }
-
- Invoke-Formatter 'Invoke-DummyFunction -parametername $parameterValue' |
- Should -Be 'Invoke-DummyFunction -ParameterName $parameterValue'
- Invoke-Formatter 'Invoke-DummyFunction -parametername:$parameterValue' |
- Should -Be 'Invoke-DummyFunction -ParameterName:$parameterValue'
- Invoke-Formatter 'Invoke-DummyFunction -parametername: $parameterValue' |
- Should -Be 'Invoke-DummyFunction -ParameterName: $parameterValue'
+ # Without messing up the spacing or use of semicolons
+ Invoke-Formatter 'Get-ChildItem -literalpath $parameterValue' |
+ Should -BeExactly 'Get-ChildItem -LiteralPath $parameterValue'
+ Invoke-Formatter 'Get-ChildItem -literalpath:$parameterValue' |
+ Should -BeExactly 'Get-ChildItem -LiteralPath:$parameterValue'
+ Invoke-Formatter 'Get-ChildItem -literalpath: $parameterValue' |
+ Should -BeExactly 'Get-ChildItem -LiteralPath: $parameterValue'
}
It "Should not throw when using parameter name that does not exist" {
@@ -75,11 +76,58 @@ Describe "UseCorrectCasing" {
It "Does not throw when correcting certain cmdlets (issue 1516)" {
$scriptDefinition = 'Get-Content;Test-Path;Get-ChildItem;Get-Content;Test-Path;Get-ChildItem'
- $settings = @{ 'Rules' = @{ 'PSUseCorrectCasing' = @{ 'Enable' = $true } } }
+ $settings = @{ 'Rules' = @{ 'PSUseCorrectCasing' = @{ 'Enable' = $true; CheckCommands = $true; CheckKeywords = $true; CheckOperators = $true } } }
{
1..100 |
ForEach-Object { $null = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings -ErrorAction Stop }
} |
Should -Not -Throw
}
+
+ It "Corrects uppercase operators" {
+ Invoke-Formatter '$ENV:PATH -SPLIT ";"' |
+ Should -BeExactly '$ENV:PATH -split ";"'
+ }
+
+ It "Corrects mixed case operators" {
+ Invoke-Formatter '$ENV:PATH -Split ";" -Join ":"' |
+ Should -BeExactly '$ENV:PATH -split ";" -join ":"'
+ }
+
+ It "Corrects unary operators" {
+ Invoke-Formatter '-Split "Hello World"' |
+ Should -BeExactly '-split "Hello World"'
+ }
+ It "Does not break PlusPlus or MinusMinus" {
+ Invoke-Formatter '$A++; $B--' |
+ Should -BeExactly '$A++; $B--'
+ }
+
+ It "Shows relevant diagnostic message for function/command name casing" {
+ $settings = @{ 'Rules' = @{ 'PSUseCorrectCasing' = @{ 'Enable' = $true; CheckCommands = $true; CheckKeywords = $true; CheckOperators = $true } } }
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'WHERE-OBJECT Name -EQ "Value"' -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Be "Function/Cmdlet 'WHERE-OBJECT' does not match its exact casing 'Where-Object'."
+ }
+
+ It "Shows relevant diagnostic message for parameter casing" {
+ $settings = @{ 'Rules' = @{ 'PSUseCorrectCasing' = @{ 'Enable' = $true; CheckCommands = $true; CheckKeywords = $true; CheckOperators = $true } } }
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'Where-Object Name -eq "Value"' -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Be "Parameter '-eq' of function/cmdlet 'Where-Object' does not match its exact casing 'EQ'."
+ }
+
+ It "Shows relevant diagnostic message for operator casing" {
+ $settings = @{ 'Rules' = @{ 'PSUseCorrectCasing' = @{ 'Enable' = $true; CheckCommands = $true; CheckKeywords = $true; CheckOperators = $true } } }
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition '$a -EQ 1' -Settings $settings
+ $violations.Count | Should -Be 1
+ $violations[0].Message | Should -Be "Operator '-EQ' does not match the expected case '-eq'."
+ }
+
+ Context "Inconsistent Keywords" {
+ It "Corrects keyword case" {
+ Invoke-Formatter 'ForEach ($x IN $y) { $x }' |
+ Should -BeExactly 'foreach ($x in $y) { $x }'
+ }
+ }
}
diff --git a/Tests/Rules/UseDSCResourceFunctions.tests.ps1 b/Tests/Rules/UseDSCResourceFunctions.tests.ps1
index 2efc16ec0..9112d6e22 100644
--- a/Tests/Rules/UseDSCResourceFunctions.tests.ps1
+++ b/Tests/Rules/UseDSCResourceFunctions.tests.ps1
@@ -7,12 +7,8 @@ BeforeAll {
$violationName = "PSDSCStandardDSCFunctionsInResource"
$violations = Invoke-ScriptAnalyzer $PSScriptRoot\DSCResourceModule\DSCResources\MSFT_WaitForAll\MSFT_WaitForAll.psm1 | Where-Object {$_.RuleName -eq $violationName}
$noViolations = Invoke-ScriptAnalyzer $PSScriptRoot\DSCResourceModule\DSCResources\MSFT_WaitForAny\MSFT_WaitForAny.psm1 | Where-Object {$_.RuleName -eq $violationName}
-
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0')
- {
- $classViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\BadDscResource\BadDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
- $noClassViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
- }
+ $classViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\BadDscResource\BadDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
+ $noClassViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
}
@@ -34,7 +30,7 @@ Describe "StandardDSCFunctionsInResource" {
}
}
-Describe "StandardDSCFunctionsInClass" -Skip:($PSVersionTable.PSVersion -lt '5.0') {
+Describe "StandardDSCFunctionsInClass" {
Context "When there are violations" {
It "has 1 missing standard DSC functions violation" {
$classViolations.Count | Should -Be 1
diff --git a/Tests/Rules/UseDeclaredVarsMoreThanAssignments.tests.ps1 b/Tests/Rules/UseDeclaredVarsMoreThanAssignments.tests.ps1
index 823334afb..113563796 100644
--- a/Tests/Rules/UseDeclaredVarsMoreThanAssignments.tests.ps1
+++ b/Tests/Rules/UseDeclaredVarsMoreThanAssignments.tests.ps1
@@ -58,6 +58,54 @@ function MyFunc2() {
Should -Be 0
}
+ It "does not flag `$PSNativeCommandArgumentPassing variable" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$PSNativeCommandArgumentPassing=None' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 0
+ }
+
+ It "does not flag global variable" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$global:x=$null' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 0
+ }
+
+ It "does not flag global variable in block" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$global:x=$null;{$global:x=$null}' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 0
+ }
+
+ It "does not flag env variable" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$env:x=$null' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 0
+ }
+
+ It "does not flag env variable in block" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$env:x=$null;{$env:x=$null}' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 0
+ }
+
+ It "does not flag script variable" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$script:x=$null' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 0
+ }
+
+ It "does not flag script variable in block" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$script:x=$null;{$script:x=$null}' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 0
+ }
+
+ It "flags private variable" {
+ Invoke-ScriptAnalyzer -ScriptDefinition '$private:x=$null' -IncludeRule $violationName | `
+ Get-Count | `
+ Should -Be 1
+ }
+
It "flags a variable that is defined twice but never used" {
Invoke-ScriptAnalyzer -ScriptDefinition '$myvar=1;$myvar=2' -IncludeRule $violationName | `
Get-Count | `
diff --git a/Tests/Rules/UseIdenticalParametersDSC.tests.ps1 b/Tests/Rules/UseIdenticalParametersDSC.tests.ps1
index c47a2bf56..622105087 100644
--- a/Tests/Rules/UseIdenticalParametersDSC.tests.ps1
+++ b/Tests/Rules/UseIdenticalParametersDSC.tests.ps1
@@ -6,11 +6,7 @@ BeforeAll {
$violationName = "PSDSCUseIdenticalParametersForDSC"
$violations = Invoke-ScriptAnalyzer $PSScriptRoot\DSCResourceModule\DSCResources\MSFT_WaitForAll\MSFT_WaitForAll.psm1 | Where-Object {$_.RuleName -eq $violationName}
$noViolations = Invoke-ScriptAnalyzer $PSScriptRoot\DSCResourceModule\DSCResources\MSFT_WaitForAny\MSFT_WaitForAny.psm1 | Where-Object {$_.RuleName -eq $violationName}
-
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0')
- {
- $noClassViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
- }
+ $noClassViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
}
@@ -30,12 +26,8 @@ Describe "UseIdenticalParametersDSC" {
$noViolations.Count | Should -Be 0
}
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0')
- {
-
- It "returns no violations for DSC Classes" {
- $noClassViolations.Count | Should -Be 0
- }
+ It "returns no violations for DSC Classes" {
+ $noClassViolations.Count | Should -Be 0
}
}
}
diff --git a/Tests/Rules/UseOutputTypeCorrectly.tests.ps1 b/Tests/Rules/UseOutputTypeCorrectly.tests.ps1
index 8cc657e0b..fa1087847 100644
--- a/Tests/Rules/UseOutputTypeCorrectly.tests.ps1
+++ b/Tests/Rules/UseOutputTypeCorrectly.tests.ps1
@@ -5,10 +5,7 @@ BeforeAll {
$violationMessage = "The cmdlet 'Verb-Files' returns an object of type 'System.Collections.Hashtable' but this type is not declared in the OutputType attribute."
$violationName = "PSUseOutputTypeCorrectly"
$violations = Invoke-ScriptAnalyzer $PSScriptRoot\BadCmdlet.ps1 | Where-Object {$_.RuleName -eq $violationName}
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0')
- {
- $dscViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
- }
+ $dscViolations = Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue $PSScriptRoot\DSCResourceModule\DSCResources\MyDscResource\MyDscResource.psm1 | Where-Object {$_.RuleName -eq $violationName}
$noViolations = Invoke-ScriptAnalyzer $PSScriptRoot\GoodCmdlet.ps1 | Where-Object {$_.RuleName -eq $violationName}
}
@@ -23,10 +20,8 @@ Describe "UseOutputTypeCorrectly" {
$violations[1].Message | Should -Match $violationMessage
}
- if ($PSVersionTable.PSVersion -ge [Version]'5.0.0') {
- It "Does not count violation in DSC class" {
- $dscViolations.Count | Should -Be 0
- }
+ It "Does not count violation in DSC class" {
+ $dscViolations.Count | Should -Be 0
}
}
diff --git a/Tests/Rules/UseShouldProcessCorrectly.tests.ps1 b/Tests/Rules/UseShouldProcessCorrectly.tests.ps1
index 2c0314f70..ed5b5e084 100644
--- a/Tests/Rules/UseShouldProcessCorrectly.tests.ps1
+++ b/Tests/Rules/UseShouldProcessCorrectly.tests.ps1
@@ -1,8 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
-$IsV3OrV4 = ($PSVersionTable.PSVersion.Major -eq 3) -or ($PSVersionTable.PSVersion.Major -eq 4)
-
BeforeAll {
$violationMessage = "'Verb-Files' has the ShouldProcess attribute but does not call ShouldProcess/ShouldContinue."
$violationName = "PSShouldProcess"
@@ -179,7 +177,7 @@ function Remove-Foo {
}
# Install-Module is present by default only on PSv5 and above
- It "finds no violation when caller declares SupportsShouldProcess and callee is a function with ShouldProcess" -Skip:$IsV3OrV4 {
+ It "finds no violation when caller declares SupportsShouldProcess and callee is a function with ShouldProcess" {
$scriptDef = @'
function Install-Foo {
[CmdletBinding(SupportsShouldProcess)]
@@ -231,7 +229,7 @@ function Install-ModuleWithDeps {
}
# Install-Module is present by default only on PSv5 and above
- It "finds no violation for a function with self reference and implicit call to ShouldProcess" -Skip:$IsV3OrV4 {
+ It "finds no violation for a function with self reference and implicit call to ShouldProcess" {
$scriptDef = @'
function Install-ModuleWithDeps {
[CmdletBinding(SupportsShouldProcess)]
diff --git a/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1 b/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1
new file mode 100644
index 000000000..945130b9e
--- /dev/null
+++ b/Tests/Rules/UseSingleValueFromPipelineParameter.Tests.ps1
@@ -0,0 +1,335 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+BeforeAll {
+ $ruleName = 'PSUseSingleValueFromPipelineParameter'
+
+ $settings = @{
+ IncludeRules = @($ruleName)
+ Rules = @{
+ $ruleName = @{
+ Enable = $true
+ }
+ }
+ }
+}
+
+Describe 'UseSingleValueFromPipelineParameter' {
+
+ Context 'When multiple parameters have ValueFromPipeline in same parameter set' {
+
+ It "Should flag explicit ValueFromPipeline=`$true in default parameter set" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ $InputObject,
+
+ [Parameter(ValueFromPipeline=$true)]
+ $AnotherParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ $violations[0].Message | Should -Match "Multiple parameters \(InputObject, AnotherParam\) in parameter set 'default'"
+ }
+
+ It 'Should flag implicit ValueFromPipeline in default parameter set' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline)]
+ $InputObject,
+
+ [Parameter(ValueFromPipeline)]
+ $SecondParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ }
+
+ It 'Should flag mixed explicit and implicit ValueFromPipeline' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ $InputObject,
+
+ [Parameter(ValueFromPipeline)]
+ $SecondParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ }
+
+ It 'Should flag multiple parameters in named parameter set' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')]
+ $InputObject,
+
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')]
+ $SecondParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ $violations[0].Message | Should -Match "parameter set 'MySet'"
+ }
+
+ It 'Should flag three parameters in same parameter set' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ $First,
+
+ [Parameter(ValueFromPipeline=$true)]
+ $Second,
+
+ [Parameter(ValueFromPipeline=$true)]
+ $Third
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 3
+ $violations[0].Message | Should -Match 'Multiple parameters \(First, Second, Third\)'
+ }
+ }
+
+ Context 'When parameters are in different parameter sets' {
+
+ It 'Should not flag parameters in different parameter sets' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')]
+ $InputObject1,
+
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')]
+ $InputObject2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It 'Should handle mix of named and default parameter sets correctly' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ $DefaultSetParam,
+
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='NamedSet')]
+ $NamedSetParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context 'When only one parameter has ValueFromPipeline' {
+
+ It 'Should not flag single ValueFromPipeline parameter' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ $InputObject,
+
+ [Parameter()]
+ $OtherParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context 'When ValueFromPipeline is explicitly set to false' {
+
+ It "Should not flag parameters with ValueFromPipeline=`$false" {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$false)]
+ $InputObject,
+
+ [Parameter(ValueFromPipeline=$false)]
+ $AnotherParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It 'Should only flag the true ValueFromPipeline parameter' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ $TrueParam,
+
+ [Parameter(ValueFromPipeline=$false)]
+ $FalseParam,
+
+ [Parameter()]
+ $NoValueParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context 'When non-Parameter attributes have ValueFromPipeline property' {
+
+ It 'Should not flag custom attributes with ValueFromPipeline property' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ [CustomAttribute(ValueFromPipeline=$true)]
+ $InputObject,
+
+ [CustomAttribute(ValueFromPipeline=$true)]
+ $NonPipelineParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It 'Should not flag ValidateSet with ValueFromPipeline property' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true)]
+ [ValidateSet('Value1', 'Value2', ValueFromPipeline=$true)]
+ $InputObject,
+
+ [ValidateSet('Value1', 'Value2', ValueFromPipeline=$true)]
+ $NonPipelineParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context 'When there are no Parameter attributes' {
+
+ It 'Should not flag functions without Parameter attributes' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ $InputObject,
+ $AnotherParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+
+ It 'Should not flag functions with only non-ValueFromPipeline Parameter attributes' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(Mandatory=$true)]
+ $InputObject,
+
+ [Parameter(Position=0)]
+ $AnotherParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+ }
+
+ Context 'Complex parameter set scenarios' {
+
+ It 'Should flag violations in multiple parameter sets independently' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')]
+ $Set1Param1,
+
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='Set1')]
+ $Set1Param2,
+
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')]
+ $Set2Param1,
+
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='Set2')]
+ $Set2Param2
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 4 # 2 violations per parameter set, each parameter gets flagged
+
+ # Check that both parameter sets are mentioned in violations
+ $violationMessages = $violations.Message -join ' '
+ $violationMessages | Should -Match "parameter set 'Set1'"
+ $violationMessages | Should -Match "parameter set 'Set2'"
+ }
+
+ It 'Should handle __AllParameterSets parameter set name correctly' {
+ $scriptDefinition = @'
+function Test-Function {
+ param(
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='__AllParameterSets')]
+ $ExplicitAllSets,
+
+ [Parameter(ValueFromPipeline=$true)]
+ $ImplicitAllSets
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 2
+ $violations[0].Message | Should -Match "parameter set 'default'"
+ }
+ }
+
+ Context 'Suppression scenarios' {
+
+ It 'Should be suppressible by parameter set name' {
+ $scriptDefinition = @'
+function Test-Function {
+ [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'MySet')]
+ param(
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')]
+ $InputObject,
+
+ [Parameter(ValueFromPipeline=$true, ParameterSetName='MySet')]
+ $AnotherParam
+ )
+}
+'@
+ $violations = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $settings
+ $violations.Count | Should -Be 0
+ }
+ }
+}
\ No newline at end of file
diff --git a/build.ps1 b/build.ps1
index bbc6c505a..5dade48fe 100644
--- a/build.ps1
+++ b/build.ps1
@@ -7,7 +7,7 @@ param(
[switch]$All,
[Parameter(ParameterSetName="BuildOne")]
- [ValidateSet(3, 4, 5, 7)]
+ [ValidateSet(5, 7)]
[int]$PSVersion = $PSVersionTable.PSVersion.Major,
[Parameter(ParameterSetName="BuildOne")]
@@ -32,6 +32,7 @@ param(
[Parameter(ParameterSetName='Test')]
[switch] $InProcess,
+ [string] $WithPowerShell,
[Parameter(ParameterSetName='BuildAll')]
[switch] $Catalog,
@@ -85,7 +86,12 @@ END {
Start-CreatePackage
}
"Test" {
- Test-ScriptAnalyzer -InProcess:$InProcess
+ $testArgs = @{
+ InProcess = $InProcess
+ WithPowerShell = $WithPowerShell
+ Verbose = $verboseWanted
+ }
+ Test-ScriptAnalyzer @testArgs
return
}
default {
diff --git a/build.psm1 b/build.psm1
index 6e3ad2edf..041b207a9 100644
--- a/build.psm1
+++ b/build.psm1
@@ -88,7 +88,7 @@ function Start-ScriptAnalyzerBuild
param (
[switch]$All,
- [ValidateSet(3, 4, 5, 7)]
+ [ValidateSet(5, 7)]
[int]$PSVersion = $PSVersionTable.PSVersion.Major,
[ValidateSet("Debug", "Release")]
@@ -124,7 +124,7 @@ function Start-ScriptAnalyzerBuild
if ( $All )
{
# Build all the versions of the analyzer
- foreach ($psVersion in 3, 4, 5, 7) {
+ foreach ($psVersion in 5, 7) {
Write-Verbose -Verbose -Message "Configuration: $Configuration PSVersion: $psVersion"
Start-ScriptAnalyzerBuild -Configuration $Configuration -PSVersion $psVersion -Verbose:$verboseWanted
}
@@ -144,13 +144,7 @@ function Start-ScriptAnalyzerBuild
$framework = 'net462'
if ($PSVersion -eq 7) {
- $framework = 'net6'
- }
-
- # build the appropriate assembly
- if ($PSVersion -match "[34]" -and $Framework -ne "net462")
- {
- throw ("ScriptAnalyzer for PS version '{0}' is not applicable to {1} framework" -f $PSVersion,$Framework)
+ $framework = 'net8'
}
Push-Location -Path $projectRoot
@@ -176,14 +170,6 @@ function Start-ScriptAnalyzerBuild
switch ($PSVersion)
{
- 3
- {
- $destinationDirBinaries = "$script:destinationDir\PSv3"
- }
- 4
- {
- $destinationDirBinaries = "$script:destinationDir\PSv4"
- }
5
{
$destinationDirBinaries = "$script:destinationDir"
@@ -199,7 +185,7 @@ function Start-ScriptAnalyzerBuild
}
$buildConfiguration = $Configuration
- if ((3, 4, 7) -contains $PSVersion) {
+ if ($PSVersion -eq 7) {
$buildConfiguration = "PSV${PSVersion}${Configuration}"
}
@@ -308,7 +294,10 @@ function New-Catalog
function Test-ScriptAnalyzer
{
[CmdletBinding()]
- param ( [switch] $InProcess )
+ param (
+ [switch] $InProcess,
+ [string] $WithPowerShell
+ )
END {
# versions 3 and 4 don't understand versioned module paths, so we need to rename the directory of the version to
@@ -328,37 +317,31 @@ function Test-ScriptAnalyzer
# and ".../out/PSScriptAnalyzer" is added to env:PSModulePath
#
#
- $major = $PSVersionTable.PSVersion.Major
- if ( $major -lt 5 ) {
- # get the directory name of the destination, we need to change it
- $versionDirectoryRoot = Split-Path $script:destinationDir
- $testModulePath = Join-Path $versionDirectoryRoot $analyzerName
- }
- else {
- $testModulePath = Join-Path "${projectRoot}" -ChildPath out
- }
+ $testModulePath = Join-Path "${projectRoot}" -ChildPath out
$testScripts = "'${projectRoot}\Tests\Build','${projectRoot}\Tests\Engine','${projectRoot}\Tests\Rules','${projectRoot}\Tests\Documentation'"
try {
- if ( $major -lt 5 ) {
- Rename-Item $script:destinationDir ${testModulePath}
- }
$savedModulePath = $env:PSModulePath
$env:PSModulePath = "${testModulePath}{0}${env:PSModulePath}" -f [System.IO.Path]::PathSeparator
$analyzerPsd1Path = Join-Path -Path $script:destinationDir -ChildPath "$analyzerName.psd1"
$scriptBlock = [scriptblock]::Create("Import-Module '$analyzerPsd1Path'; Invoke-Pester -Path $testScripts -CI")
if ( $InProcess ) {
+ Write-Verbose "Testing with PowerShell $($PSVersionTable.PSVersion)"
& $scriptBlock
}
+ elseif ( $WithPowerShell ) {
+ $pwshVersion = & $WithPowerShell --version
+ Write-Verbose "Testing with $pwshVersion"
+ & $WithPowerShell -Command $scriptBlock
+ }
else {
$powershell = (Get-Process -id $PID).MainModule.FileName
- & ${powershell} -Command $scriptBlock
+ $pwshVersion = & $powershell --version
+ Write-Verbose "Testing with $pwshVersion"
+ & $powershell -NoProfile -Command $scriptBlock
}
}
finally {
$env:PSModulePath = $savedModulePath
- if ( $major -lt 5 ) {
- Rename-Item ${testModulePath} ${script:destinationDir}
- }
}
}
}
@@ -555,6 +538,13 @@ function Get-DotnetExe
$script:DotnetExe = $dotnetHuntPath
return $dotnetHuntPath
}
+
+ $dotnetHuntPath = "C:\Program Files\dotnet\dotnet.exe"
+ Write-Verbose -Verbose "checking Windows $dotnetHuntPath"
+ if ( test-path $dotnetHuntPath ) {
+ $script:DotnetExe = $dotnetHuntPath
+ return $dotnetHuntPath
+ }
}
else {
$dotnetHuntPath = "$HOME/.dotnet/dotnet"
@@ -563,6 +553,13 @@ function Get-DotnetExe
$script:DotnetExe = $dotnetHuntPath
return $dotnetHuntPath
}
+
+ $dotnetHuntPath = "/usr/share/dotnet/dotnet"
+ Write-Verbose -Verbose "checking non-Windows $dotnetHuntPath"
+ if ( test-path $dotnetHuntPath ) {
+ $script:DotnetExe = $dotnetHuntPath
+ return $dotnetHuntPath
+ }
}
Write-Warning "Could not find dotnet executable"
diff --git a/docs/Cmdlets/Get-ScriptAnalyzerRule.md b/docs/Cmdlets/Get-ScriptAnalyzerRule.md
index a86d7d301..3d815b2c3 100644
--- a/docs/Cmdlets/Get-ScriptAnalyzerRule.md
+++ b/docs/Cmdlets/Get-ScriptAnalyzerRule.md
@@ -1,7 +1,7 @@
---
external help file: Microsoft.Windows.PowerShell.ScriptAnalyzer.dll-Help.xml
Module Name: PSScriptAnalyzer
-ms.date: 10/07/2021
+ms.date: 12/12/2024
online version: https://learn.microsoft.com/powershell/module/psscriptanalyzer/get-scriptanalyzerrule?view=ps-modules&wt.mc_id=ps-gethelp
schema: 2.0.0
---
@@ -92,7 +92,7 @@ one value, but wildcards are supported. To get rules in subdirectories of the pa
**RecurseCustomRulePath** parameter.
You can create custom rules using a .NET assembly or a PowerShell module, such as the
-[Community Analyzer Rules](https://github.com/PowerShell/PSScriptAnalyzer/blob/development/Tests/Engine/CommunityAnalyzerRules/CommunityAnalyzerRules.psm1)
+[Community Analyzer Rules](https://github.com/PowerShell/PSScriptAnalyzer/tree/main/Tests/Engine/CommunityAnalyzerRules)
in the GitHub repository.
```yaml
diff --git a/docs/Cmdlets/Invoke-ScriptAnalyzer.md b/docs/Cmdlets/Invoke-ScriptAnalyzer.md
index 4eb1bff5f..b3e72a337 100644
--- a/docs/Cmdlets/Invoke-ScriptAnalyzer.md
+++ b/docs/Cmdlets/Invoke-ScriptAnalyzer.md
@@ -192,7 +192,7 @@ value of the **Profile** parameter is the path to the Script Analyzer profile.
ExcludeRules = '*WriteHost'
}
-Invoke-ScriptAnalyzer -Path $pshome\Modules\BitLocker -Profile .\ScriptAnalyzerProfile.txt
+Invoke-ScriptAnalyzer -Path $pshome\Modules\BitLocker -Settings .\ScriptAnalyzerProfile.txt
```
If you include a conflicting parameter in the `Invoke-ScriptAnalyzer` command, such as
diff --git a/docs/Cmdlets/PSScriptAnalyzer.md b/docs/Cmdlets/PSScriptAnalyzer.md
index df190238e..3a5bc1d0a 100644
--- a/docs/Cmdlets/PSScriptAnalyzer.md
+++ b/docs/Cmdlets/PSScriptAnalyzer.md
@@ -1,6 +1,6 @@
---
Download Help Link: https://aka.ms/ps-modules-help
-Help Version: 1.23.0
+Help Version: 1.25.0
Locale: en-US
Module Guid: d6245802-193d-4068-a631-8863a4342a18
Module Name: PSScriptAnalyzer
@@ -21,10 +21,13 @@ checks the quality of PowerShell code by running a set of rules.
## PSScriptAnalyzer Cmdlets
### [Get-ScriptAnalyzerRule](Get-ScriptAnalyzerRule.md)
+
Gets the script analyzer rules on the local computer.
### [Invoke-Formatter](Invoke-Formatter.md)
+
Formats a script text based on the input settings or default settings.
### [Invoke-ScriptAnalyzer](Invoke-ScriptAnalyzer.md)
+
Evaluates a script or module based on selected best practice rules
diff --git a/docs/Rules/AlignAssignmentStatement.md b/docs/Rules/AlignAssignmentStatement.md
index c2709ac1a..573b54b68 100644
--- a/docs/Rules/AlignAssignmentStatement.md
+++ b/docs/Rules/AlignAssignmentStatement.md
@@ -1,6 +1,6 @@
---
description: Align assignment statement
-ms.date: 06/28/2023
+ms.date: 03/20/2026
ms.topic: reference
title: AlignAssignmentStatement
---
@@ -10,38 +10,54 @@ title: AlignAssignmentStatement
## Description
-Consecutive assignment statements are more readable if they are aligned. By aligned, we imply that
-the `equal` sign for all the assignment statements should be in the same column.
+Consecutive assignment statements are more readable when they're aligned. Assignments are considered
+aligned when their `equals` signs line up vertically.
-The rule looks for key (property) value pairs in a hashtable (DSC configuration) to check if they
-are aligned or not. Consider the following example in which the key value pairs are not aligned.
+This rule looks at the key-value pairs in hashtables (including DSC configurations) as well as enum
+definitions.
+
+Consider the following example with a hashtable and enum that isn't aligned.
```powershell
$hashtable = @{
- property1 = 'value'
+ property = 'value'
anotherProperty = 'another value'
}
+
+enum Enum {
+ member = 1
+ anotherMember = 2
+}
```
Alignment in this case would look like the following.
```powershell
$hashtable = @{
- property1 = 'value'
+ property = 'value'
anotherProperty = 'another value'
}
+
+enum Enum {
+ member = 1
+ anotherMember = 2
+}
```
-The rule ignores hashtables in which the assignment statements are on the same line. For example,
-the rule ignores `$h = {a = 1; b = 2}`.
+The rule ignores any assignments within hashtables and enums which are on the same line as others.
+For example, the rule ignores `$h = @{a = 1; b = 2}`.
## Configuration
```powershell
Rules = @{
PSAlignAssignmentStatement = @{
- Enable = $true
- CheckHashtable = $true
+ Enable = $true
+ CheckHashtable = $true
+ AlignHashtableKvpWithInterveningComment = $true
+ CheckEnum = $true
+ AlignEnumMemberWithInterveningComment = $true
+ IncludeValuelessEnumMembers = $true
}
}
```
@@ -52,8 +68,118 @@ Rules = @{
Enable or disable the rule during ScriptAnalyzer invocation.
-#### CheckHashtable: bool (Default value is `$false`)
+#### CheckHashtable: bool (Default value is `$true`)
Enforce alignment of assignment statements in a hashtable and in a DSC Configuration. There is only
-one switch for hasthable and DSC configuration because the property value pairs in a DSC
+one setting for hashtable and DSC configuration because the property value pairs in a DSC
configuration are parsed as key-value pairs of a hashtable.
+
+#### AlignHashtableKvpWithInterveningComment: bool (Default value is `$true`)
+
+Include key-value pairs in the alignment that have an intervening comment - that is to say a comment
+between the key name and the equals sign.
+
+Consider the following:
+
+```powershell
+$hashtable = @{
+ property = 'value'
+ anotherProperty <#A Comment#> = 'another value'
+ anotherDifferentProperty = 'yet another value'
+}
+```
+
+With this setting disabled, the line with the comment is ignored, and it would be aligned like so:
+
+```powershell
+$hashtable = @{
+ property = 'value'
+ anotherProperty <#A Comment#> = 'another value'
+ anotherDifferentProperty = 'yet another value'
+}
+```
+
+With it enabled, the comment line is included in alignment:
+
+```powershell
+$hashtable = @{
+ property = 'value'
+ anotherProperty <#A Comment#> = 'another value'
+ anotherDifferentProperty = 'yet another value'
+}
+```
+
+#### CheckEnum: bool (Default value is `$true`)
+
+Enforce alignment of assignment statements of an Enum definition.
+
+#### AlignEnumMemberWithInterveningComment: bool (Default value is `$true`)
+
+Include enum members in the alignment that have an intervening comment - that is to say a comment
+between the member name and the equals sign.
+
+Consider the following:
+
+```powershell
+enum Enum {
+ member = 1
+ anotherMember <#A Comment#> = 2
+ anotherDifferentMember = 3
+}
+```
+
+With this setting disabled, the line with the comment is ignored, and it would be aligned like so:
+
+```powershell
+enum Enum {
+ member = 1
+ anotherMember <#A Comment#> = 2
+ anotherDifferentMember = 3
+}
+```
+
+With it enabled, the comment line is included in alignment:
+
+```powershell
+enum Enum {
+ member = 1
+ anotherMember <#A Comment#> = 2
+ anotherDifferentMember = 3
+}
+```
+
+#### IncludeValuelessEnumMembers: bool (Default value is `$true`)
+
+Include enum members in the alignment that don't have an explicitly assigned value. Enums don't
+need to be given a value when they're defined.
+
+Consider the following:
+
+```powershell
+enum Enum {
+ member = 1
+ anotherMember = 2
+ anotherDifferentMember
+}
+```
+
+With this setting disabled, the third line, which has no value, isn't considered when choosing where
+to align assignments. It would be aligned like so:
+
+```powershell
+enum Enum {
+ member = 1
+ anotherMember = 2
+ anotherDifferentMember
+}
+```
+
+With it enabled, the valueless member is included in alignment as if it had a value:
+
+```powershell
+enum Enum {
+ member = 1
+ anotherMember = 2
+ anotherDifferentMember
+}
+```
\ No newline at end of file
diff --git a/docs/Rules/AvoidAssignmentToAutomaticVariable.md b/docs/Rules/AvoidAssignmentToAutomaticVariable.md
index f8203dc8e..60d520d07 100644
--- a/docs/Rules/AvoidAssignmentToAutomaticVariable.md
+++ b/docs/Rules/AvoidAssignmentToAutomaticVariable.md
@@ -16,6 +16,11 @@ only be assigned in certain special cases to achieve a certain effect as a speci
To understand more about automatic variables, see `Get-Help about_Automatic_Variables`.
+
+
## How
Use variable names in functions or their parameters that do not conflict with automatic variables.
diff --git a/docs/Rules/AvoidDefaultValueSwitchParameter.md b/docs/Rules/AvoidDefaultValueSwitchParameter.md
index 7cfbbc212..9cc1ba855 100644
--- a/docs/Rules/AvoidDefaultValueSwitchParameter.md
+++ b/docs/Rules/AvoidDefaultValueSwitchParameter.md
@@ -1,6 +1,6 @@
---
description: Switch Parameters Should Not Default To True
-ms.date: 06/28/2023
+ms.date: 12/05/2024
ms.topic: reference
title: AvoidDefaultValueSwitchParameter
---
@@ -10,11 +10,19 @@ title: AvoidDefaultValueSwitchParameter
## Description
-Switch parameters for commands should default to false.
+If your parameter takes only `true` and `false`, define the parameter as type `[Switch]`. PowerShell
+treats a switch parameter as `true` when it's used with a command. If the parameter isn't included
+with the command, PowerShell considers the parameter to be false. Don't define `[Boolean]`
+parameters.
+
+You shouldn't define a switch parameter with a default value of `$true` because this isn't the
+expected behavior of a switch parameter.
## How
-Change the default value of the switch parameter to be false.
+Change the default value of the switch parameter to be `$false` or don't provide a default value.
+Write the logic of the script to assume that the switch parameter default value is `$false` or not
+provided.
## Example
@@ -48,8 +56,22 @@ function Test-Script
$Param1,
[switch]
- $Switch=$False
+ $Switch
)
+
+ begin {
+ # Ensure that the $Switch is set to false if not provided
+ if (-not $PSBoundParameters.ContainsKey('Switch')) {
+ $Switch = $false
+ }
+ }
...
}
```
+
+## More information
+
+- [Strongly Encouraged Development Guidelines][01]
+
+
+[01]: https://learn.microsoft.com/powershell/scripting/developer/cmdlet/strongly-encouraged-development-guidelines#parameters-that-take-true-and-false
diff --git a/docs/Rules/AvoidGlobalFunctions.md b/docs/Rules/AvoidGlobalFunctions.md
index f74b094cb..929466cb6 100644
--- a/docs/Rules/AvoidGlobalFunctions.md
+++ b/docs/Rules/AvoidGlobalFunctions.md
@@ -13,7 +13,6 @@ title: AvoidGlobalFunctions
Globally scoped functions override existing functions within the sessions with matching names. This
name collision can cause difficult to debug issues for consumers of modules.
-
To understand more about scoping, see `Get-Help about_Scopes`.
## How
diff --git a/docs/Rules/AvoidLongLines.md b/docs/Rules/AvoidLongLines.md
index cc2603c51..89474c8b8 100644
--- a/docs/Rules/AvoidLongLines.md
+++ b/docs/Rules/AvoidLongLines.md
@@ -1,6 +1,6 @@
---
description: Avoid long lines
-ms.date: 06/28/2023
+ms.date: 03/20/2026
ms.topic: reference
title: AvoidLongLines
---
@@ -10,10 +10,11 @@ title: AvoidLongLines
## Description
-Lines should be no longer than a configured number of characters (default: 120), including leading
-whitespace (indentation).
+The length of lines, including leading spaces (indentation), should be less than the configured
+number of characters. The default length is 120 characters.
-**Note**: This rule is not enabled by default. The user needs to enable it through settings.
+> [!NOTE]
+> This rule isn't enabled by default. The user needs to enable it through settings.
## Configuration
@@ -26,12 +27,12 @@ Rules = @{
}
```
-### Parameters
+## Parameters
-#### Enable: bool (Default value is `$false`)
+### `Enable`: bool (Default value is `$false`)
Enable or disable the rule during ScriptAnalyzer invocation.
-#### MaximumLineLength: int (Default value is 120)
+### `MaximumLineLength`: int (Default value is 120)
Optional parameter to override the default maximum line length.
diff --git a/docs/Rules/AvoidOverwritingBuiltInCmdlets.md b/docs/Rules/AvoidOverwritingBuiltInCmdlets.md
index 1d94a618a..10e1ad30a 100644
--- a/docs/Rules/AvoidOverwritingBuiltInCmdlets.md
+++ b/docs/Rules/AvoidOverwritingBuiltInCmdlets.md
@@ -1,6 +1,6 @@
---
description: Avoid overwriting built in cmdlets
-ms.date: 06/28/2023
+ms.date: 12/12/2024
ms.topic: reference
title: AvoidOverwritingBuiltInCmdlets
---
@@ -14,7 +14,7 @@ This rule flags cmdlets that are available in a given edition/version of PowerSh
operating system which are overwritten by a function declaration. It works by comparing function
declarations against a set of allowlists that ship with PSScriptAnalyzer. These allowlist files are
used by other PSScriptAnalyzer rules. More information can be found in the documentation for the
-[UseCompatibleCmdlets](./UseCompatibleCmdlets.md) rule.
+[UseCompatibleCmdlets][01] rule.
## Configuration
@@ -37,14 +37,17 @@ following your settings file.
The parameter `PowerShellVersion` is a list of allowlists that ship with PSScriptAnalyzer.
-**Note**: The default value for `PowerShellVersion` is `core-6.1.0-windows` if PowerShell 6 or
-later is installed, and `desktop-5.1.14393.206-windows` if it is not.
+> [!NOTE]
+> The default value for `PowerShellVersion` is `core-6.1.0-windows` if PowerShell 6 or
+> later is installed, and `desktop-5.1.14393.206-windows` if it's not.
Usually, patched versions of PowerShell have the same cmdlet data, therefore only settings of major
and minor versions of PowerShell are supplied. One can also create a custom settings file as well
-with the
-[New-CommandDataFile.ps1](https://github.com/PowerShell/PSScriptAnalyzer/blob/development/Utils/New-CommandDataFile.ps1)
-script and use it by placing the created `JSON` into the `Settings` folder of the `PSScriptAnalyzer`
-module installation folder, then the `PowerShellVersion` parameter is just its file name (that can
-also be changed if desired). Note that the `core-6.0.2-*` files were removed in PSScriptAnalyzer
-1.18 since PowerShell 6.0 reached end of life.
+with the [New-CommandDataFile.ps1][02] script and use it by placing the created `JSON` into the
+`Settings` folder of the `PSScriptAnalyzer` module installation folder, then the `PowerShellVersion`
+parameter is just its filename (that can also be changed if desired). Note that the `core-6.0.2-*`
+files were removed in PSScriptAnalyzer 1.18 since PowerShell 6.0 reached end of life.
+
+
+[01]: ./UseCompatibleCmdlets.md
+[02]: https://github.com/PowerShell/PSScriptAnalyzer/blob/main/Utils/New-CommandDataFile.ps1
diff --git a/docs/Rules/AvoidReservedWordsAsFunctionNames.md b/docs/Rules/AvoidReservedWordsAsFunctionNames.md
new file mode 100644
index 000000000..769cbb85f
--- /dev/null
+++ b/docs/Rules/AvoidReservedWordsAsFunctionNames.md
@@ -0,0 +1,44 @@
+---
+description: Avoid reserved words as function names
+ms.date: 08/31/2025
+ms.topic: reference
+title: AvoidReservedWordsAsFunctionNames
+---
+# AvoidReservedWordsAsFunctionNames
+
+**Severity Level: Warning**
+
+## Description
+
+Avoid using reserved words as function names. Using reserved words as function names can cause
+errors or unexpected behavior in scripts.
+
+## How to Fix
+
+Avoid using any of the reserved words as function names. Choose a different name that's not a
+reserved word.
+
+See [about_Reserved_Words][01] for a list of reserved words in PowerShell.
+
+## Example
+
+### Wrong
+
+```powershell
+# Function is a reserved word
+function function {
+ Write-Host "Hello, World!"
+}
+```
+
+### Correct
+
+```powershell
+# myFunction is not a reserved word
+function myFunction {
+ Write-Host "Hello, World!"
+}
+```
+
+
+[01]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_reserved_words
diff --git a/docs/Rules/AvoidUsingCmdletAliases.md b/docs/Rules/AvoidUsingCmdletAliases.md
index 48c914eec..9a33149ad 100644
--- a/docs/Rules/AvoidUsingCmdletAliases.md
+++ b/docs/Rules/AvoidUsingCmdletAliases.md
@@ -20,7 +20,7 @@ There are also implicit aliases. When PowerShell cannot find the cmdlet name, it
Every PowerShell author learns the actual command names, but different authors learn and use
different aliases. Aliases can make code difficult to read, understand and impact availability.
-Using the full command name makes it eaiser to maintain your scripts in the the future.
+Using the full command name makes it easier to maintain your scripts in the the future.
Using the full command names also allows for syntax highlighting in sites and applications like
GitHub and Visual Studio Code.
diff --git a/docs/Rules/AvoidUsingConvertToSecureStringWithPlainText.md b/docs/Rules/AvoidUsingConvertToSecureStringWithPlainText.md
index 5a94d89a3..d25fce124 100644
--- a/docs/Rules/AvoidUsingConvertToSecureStringWithPlainText.md
+++ b/docs/Rules/AvoidUsingConvertToSecureStringWithPlainText.md
@@ -1,6 +1,6 @@
---
description: Avoid Using SecureString With Plain Text
-ms.date: 06/28/2023
+ms.date: 01/28/2025
ms.topic: reference
title: AvoidUsingConvertToSecureStringWithPlainText
---
@@ -37,6 +37,4 @@ $EncryptedInput = ConvertTo-SecureString -String $UserInput -AsPlainText -Force
```powershell
$SecureUserInput = Read-Host 'Please enter your secure code' -AsSecureString
-$EncryptedInput = ConvertFrom-SecureString -String $SecureUserInput
-$SecureString = ConvertTo-SecureString -String $EncryptedInput
```
diff --git a/docs/Rules/AvoidUsingWriteHost.md b/docs/Rules/AvoidUsingWriteHost.md
index 561914168..a02571c79 100644
--- a/docs/Rules/AvoidUsingWriteHost.md
+++ b/docs/Rules/AvoidUsingWriteHost.md
@@ -1,6 +1,6 @@
---
description: Avoid Using Write-Host
-ms.date: 06/28/2023
+ms.date: 12/05/2024
ms.topic: reference
title: AvoidUsingWriteHost
---
@@ -10,10 +10,15 @@ title: AvoidUsingWriteHost
## Description
-The use of `Write-Host` is greatly discouraged unless in the use of commands with the `Show` verb.
-The `Show` verb explicitly means 'show on the screen, with no other possibilities'.
+The primary purpose of the `Write-Host` cmdlet is to produce display-only output in the host. For
+example: printing colored text or prompting the user for input when combined with `Read-Host`.
+`Write-Host` uses the `ToString()` method to write the output. The particular result depends on the
+program that's hosting PowerShell. The output from `Write-Host` isn't sent to the pipeline. To
+output data to the pipeline, use `Write-Output` or implicit output.
-Commands with the `Show` verb do not have this check applied.
+The use of `Write-Host` in a function is discouraged unless the function uses the `Show` verb. The
+`Show` verb explicitly means _display information to the user_. This rule doesn't apply to functions
+with the `Show` verb.
## How
@@ -27,22 +32,22 @@ logging or returning one or more objects.
```powershell
function Get-MeaningOfLife
{
- ...
Write-Host 'Computing the answer to the ultimate question of life, the universe and everything'
- ...
Write-Host 42
}
```
### Correct
+Use `Write-Verbose` for informational messages. The user can decide whether to see the message by
+providing the **Verbose** parameter.
+
```powershell
function Get-MeaningOfLife
{
- [CmdletBinding()]Param() # to make it possible to set the VerbosePreference when calling the function
- ...
+ [CmdletBinding()]Param() # makes it possible to support Verbose output
+
Write-Verbose 'Computing the answer to the ultimate question of life, the universe and everything'
- ...
Write-Output 42
}
@@ -51,3 +56,7 @@ function Show-Something
Write-Host 'show something on screen'
}
```
+
+## More information
+
+[Write-Host](xref:Microsoft.PowerShell.Utility.Write-Host)
diff --git a/docs/Rules/PlaceOpenBrace.md b/docs/Rules/PlaceOpenBrace.md
index faa6d4c5d..a523ec4e8 100644
--- a/docs/Rules/PlaceOpenBrace.md
+++ b/docs/Rules/PlaceOpenBrace.md
@@ -45,5 +45,5 @@ Enforce a new line character after an open brace. The default value is true.
#### IgnoreOneLineBlock: bool (Default value is `$true`)
Indicates if open braces in a one line block should be ignored or not. For example,
-` $x = if ($true) { 'blah' } else { 'blah blah' }`, if the property is set to true then the rule
+`$x = if ($true) { 'blah' } else { 'blah blah' }`, if the property is set to true then the rule
doesn't fire a violation.
diff --git a/docs/Rules/PossibleIncorrectComparisonWithNull.md b/docs/Rules/PossibleIncorrectComparisonWithNull.md
index 28c9c7075..9a28646f4 100644
--- a/docs/Rules/PossibleIncorrectComparisonWithNull.md
+++ b/docs/Rules/PossibleIncorrectComparisonWithNull.md
@@ -1,6 +1,6 @@
---
description: Null Comparison
-ms.date: 06/28/2023
+ms.date: 12/03/2024
ms.topic: reference
title: PossibleIncorrectComparisonWithNull
---
@@ -18,8 +18,8 @@ There are multiple reasons why this occurs:
- `$null` is a scalar value. When the value on the left side of an operator is a scalar, comparison
operators return a **Boolean** value. When the value is a collection, the comparison operators
return any matching values or an empty array if there are no matches in the collection.
-- PowerShell performs type casting left to right, resulting in incorrect comparisons when `$null` is
- cast to other scalar types.
+- PowerShell performs type casting on the right-hand operand, resulting in incorrect comparisons
+ when `$null` is cast to other scalar types.
The only way to reliably check if a value is `$null` is to place `$null` on the left side of the
operator so that a scalar comparison is performed.
@@ -55,10 +55,10 @@ function Test-CompareWithNull
## Try it Yourself
```powershell
-# Both expressions below return 'false' because the comparison does not return an
-# object and therefore the if statement always falls through:
+# This example returns 'false' because the comparison does not return any objects from the array
if (@() -eq $null) { 'true' } else { 'false' }
-if (@() -ne $null) { 'true' } else { 'false' }
+# This example returns 'true' because the array is empty
+if ($null -ne @()) { 'true' } else { 'false' }
```
This is how the comparison operator works by-design. But, as demonstrated, this can lead
diff --git a/docs/Rules/PossibleIncorrectUsageOfAssignmentOperator.md b/docs/Rules/PossibleIncorrectUsageOfAssignmentOperator.md
index bbaf437b8..11c5d23f1 100644
--- a/docs/Rules/PossibleIncorrectUsageOfAssignmentOperator.md
+++ b/docs/Rules/PossibleIncorrectUsageOfAssignmentOperator.md
@@ -52,7 +52,7 @@ if ($a = Get-Something) # Only execute action if command returns something and a
}
```
-## Implicit suppresion using Clang style
+## Implicit suppression using Clang style
There are some rare cases where assignment of variable inside an `if` statement is by design.
Instead of suppressing the rule, one can also signal that assignment was intentional by wrapping the
diff --git a/docs/Rules/ProvideCommentHelp.md b/docs/Rules/ProvideCommentHelp.md
index 8c642419f..19e83681a 100644
--- a/docs/Rules/ProvideCommentHelp.md
+++ b/docs/Rules/ProvideCommentHelp.md
@@ -36,7 +36,7 @@ Rules = @{
### Parameters
-- `Enable`: **bool** (Default valus is `$true`)
+- `Enable`: **bool** (Default value is `$true`)
Enable or disable the rule during ScriptAnalyzer invocation.
diff --git a/docs/Rules/README.md b/docs/Rules/README.md
index 06f27d2da..fca031e33 100644
--- a/docs/Rules/README.md
+++ b/docs/Rules/README.md
@@ -1,6 +1,6 @@
---
description: List of PSScriptAnalyzer rules
-ms.date: 03/27/2024
+ms.date: 03/20/2026
ms.topic: reference
title: List of PSScriptAnalyzer rules
---
@@ -23,6 +23,7 @@ The PSScriptAnalyzer contains the following rule definitions.
| [AvoidMultipleTypeAttributes1](./AvoidMultipleTypeAttributes.md) | Warning | Yes | |
| [AvoidNullOrEmptyHelpMessageAttribute](./AvoidNullOrEmptyHelpMessageAttribute.md) | Warning | Yes | |
| [AvoidOverwritingBuiltInCmdlets](./AvoidOverwritingBuiltInCmdlets.md) | Warning | Yes | Yes |
+| [AvoidReservedWordsAsFunctionNames](./AvoidReservedWordsAsFunctionNames.md) | Warning | Yes | |
| [AvoidSemicolonsAsLineTerminators](./AvoidSemicolonsAsLineTerminators.md) | Warning | No | |
| [AvoidShouldContinueWithoutForce](./AvoidShouldContinueWithoutForce.md) | Warning | Yes | |
| [AvoidTrailingWhitespace](./AvoidTrailingWhitespace.md) | Warning | Yes | |
@@ -67,7 +68,10 @@ The PSScriptAnalyzer contains the following rule definitions.
| [UseCompatibleSyntax](./UseCompatibleSyntax.md) | Warning | No | Yes |
| [UseCompatibleTypes](./UseCompatibleTypes.md) | Warning | No | Yes |
| [UseConsistentIndentation](./UseConsistentIndentation.md) | Warning | No | Yes |
+| [UseConsistentParameterSetName](./UseConsistentParameterSetName.md) | Warning | No | |
+| [UseConsistentParametersKind](./UseConsistentParametersKind.md) | Warning | No | Yes |
| [UseConsistentWhitespace](./UseConsistentWhitespace.md) | Warning | No | Yes |
+| [UseConstrainedLanguageMode](./UseConstrainedLanguageMode.md) | Warning | No | Yes |
| [UseCorrectCasing](./UseCorrectCasing.md) | Information | No | Yes |
| [UseDeclaredVarsMoreThanAssignments](./UseDeclaredVarsMoreThanAssignments.md) | Warning | Yes | |
| [UseLiteralInitializerForHashtable](./UseLiteralInitializerForHashtable.md) | Warning | Yes | |
@@ -75,6 +79,7 @@ The PSScriptAnalyzer contains the following rule definitions.
| [UseProcessBlockForPipelineCommand](./UseProcessBlockForPipelineCommand.md) | Warning | Yes | |
| [UsePSCredentialType](./UsePSCredentialType.md) | Warning | Yes | |
| [UseShouldProcessForStateChangingFunctions](./UseShouldProcessForStateChangingFunctions.md) | Warning | Yes | |
+| [UseSingleValueFromPipelineParameter](./UseSingleValueFromPipelineParameter.md) | Warning | No | |
| [UseSingularNouns](./UseSingularNouns.md) | Warning | Yes | Yes |
| [UseSupportsShouldProcess](./UseSupportsShouldProcess.md) | Warning | Yes | |
| [UseToExportFieldsInManifest](./UseToExportFieldsInManifest.md) | Warning | Yes | |
diff --git a/docs/Rules/UseBOMForUnicodeEncodedFile.md b/docs/Rules/UseBOMForUnicodeEncodedFile.md
index 6fffaa7fe..e0a46b1e2 100644
--- a/docs/Rules/UseBOMForUnicodeEncodedFile.md
+++ b/docs/Rules/UseBOMForUnicodeEncodedFile.md
@@ -1,6 +1,6 @@
---
description: Use BOM encoding for non-ASCII files
-ms.date: 06/28/2023
+ms.date: 01/07/2025
ms.topic: reference
title: UseBOMForUnicodeEncodedFile
---
@@ -13,6 +13,30 @@ title: UseBOMForUnicodeEncodedFile
For a file encoded with a format other than ASCII, ensure Byte Order Mark (BOM) is present to ensure
that any application consuming this file can interpret it correctly.
+You can use this rule to test any arbitrary text file, but the intent is to ensure that PowerShell
+scripts are saved with a BOM when using a Unicode encoding.
+
## How
-Ensure that the file is encoded with BOM present.
+For PowerShell commands that write to files, ensure that you set the encoding parameter to a value
+that produces a BOM. In PowerShell 7 and higher, the following values of the **Encoding** parameter
+produce a BOM:
+
+- `bigendianunicode`
+- `bigendianutf32`
+- `oem`
+- `unicode`
+- `utf32`
+- `utf8BOM`
+
+When you create a script file using a text editor, ensure that the editor is configured to save the
+file with a BOM. Consult the documentation for your text editor for instructions on how to save
+files with a BOM.
+
+## Further reading
+
+For more information, see the following articles:
+
+- [about_Character_Encoding](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_character_encoding)
+- [Set-Content](https://learn.microsoft.com/powershell/module/microsoft.powershell.management/set-content)
+- [Understanding file encoding in VS Code and PowerShell](https://learn.microsoft.com/powershell/scripting/dev-cross-plat/vscode/understanding-file-encoding)
diff --git a/docs/Rules/UseCompatibleCmdlets.md b/docs/Rules/UseCompatibleCmdlets.md
index 1fc9520d4..4cd52340e 100644
--- a/docs/Rules/UseCompatibleCmdlets.md
+++ b/docs/Rules/UseCompatibleCmdlets.md
@@ -1,6 +1,6 @@
---
description: Use compatible cmdlets
-ms.date: 06/28/2023
+ms.date: 12/12/2024
ms.topic: reference
title: UseCompatibleCmdlets
---
@@ -10,8 +10,8 @@ title: UseCompatibleCmdlets
## Description
-This rule flags cmdlets that are not available in a given Edition/Version of PowerShell on a given
-Operating System. It works by comparing a cmdlet against a set of allowlists which ship with
+This rule flags cmdlets that aren't available in a given Edition and Version of PowerShell on a
+given Operating System. It works by comparing a cmdlet against a set of allowlists which ship with
PSScriptAnalyzer. They can be found at `/path/to/PSScriptAnalyzerModule/Settings`. These files are
of the form, `--.json` where `` can be either `Core` or
`Desktop`, `` can be either `Windows`, `Linux` or `MacOS`, and `` is the PowerShell
@@ -41,7 +41,10 @@ The parameter `compatibility` is a list that contain any of the following
Usually, patched versions of PowerShell have the same cmdlet data, therefore only settings of major
and minor versions of PowerShell are supplied. You can also create a custom settings file with the
-[New-CommandDataFile.ps1](https://github.com/PowerShell/PSScriptAnalyzer/blob/development/Utils/New-CommandDataFile.ps1)
-script. Place the created `.json` file in the `Settings` folder of the `PSScriptAnalyzer` module
-folder. Then the `compatibility` parameter values is just the filename. Note that the `core-6.0.2-*`
-files were removed in PSScriptAnalyzer 1.18 since PowerShell 6.0 reached it's end of life.
+[New-CommandDataFile.ps1][01] script. Place the created `.json` file in the `Settings` folder of the
+`PSScriptAnalyzer` module folder. Then the `compatibility` parameter values is just the filename.
+Note that the `core-6.0.2-*` files were removed in PSScriptAnalyzer 1.18 since PowerShell 6.0
+reached it's end of life.
+
+
+[01]: https://github.com/PowerShell/PSScriptAnalyzer/blob/main/Utils/New-CommandDataFile.ps1
diff --git a/docs/Rules/UseCompatibleCommands.md b/docs/Rules/UseCompatibleCommands.md
index 00b768ba3..ae74862ba 100644
--- a/docs/Rules/UseCompatibleCommands.md
+++ b/docs/Rules/UseCompatibleCommands.md
@@ -1,6 +1,6 @@
---
description: Use compatible commands
-ms.date: 06/28/2023
+ms.date: 12/12/2024
ms.topic: reference
title: UseCompatibleCommands
---
@@ -46,35 +46,33 @@ your configuration.
Platforms bundled by default are:
-| PowerShell Version | Operating System | ID |
-| ------------------ | --------------------- | --------------------------------------------------------------------- |
-| 3.0 | Windows Server 2012 | `win-8_x64_6.2.9200.0_3.0_x64_4.0.30319.42000_framework` |
-| 4.0 | Windows Server 2012R2 | `win-8_x64_6.3.9600.0_4.0_x64_4.0.30319.42000_framework` |
-| 5.1 | Windows Server 2016 | `win-8_x64_10.0.14393.0_5.1.14393.2791_x64_4.0.30319.42000_framework` |
-| 5.1 | Windows Server 2019 | `win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
-| 5.1 | Windows 10 1809 (RS5) | `win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
-| 6.2 | Windows Server 2016 | `win-8_x64_10.0.14393.0_6.2.4_x64_4.0.30319.42000_core` |
-| 6.2 | Windows Server 2019 | `win-8_x64_10.0.17763.0_6.2.4_x64_4.0.30319.42000_core` |
-| 6.2 | Windows 10 1809 (RS5) | `win-4_x64_10.0.17763.0_6.2.4_x64_4.0.30319.42000_core` |
-| 6.2 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_6.2.4_x64_4.0.30319.42000_core` |
-| 7.0 | Windows Server 2016 | `win-8_x64_10.0.14393.0_7.0.0_x64_3.1.2_core` |
-| 7.0 | Windows Server 2019 | `win-8_x64_10.0.17763.0_7.0.0_x64_3.1.2_core` |
-| 7.0 | Windows 10 1809 (RS5) | `win-4_x64_10.0.17763.0_6.2.4_x64_3.1.2_core` |
-| 7.0 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_6.2.4_x64_3.1.2_core` |
-
-Other profiles can be found in the
-[GitHub repo](https://github.com/PowerShell/PSScriptAnalyzer/tree/development/PSCompatibilityCollector/optional_profiles).
-
-You can also generate your own platform profile using the
-[PSCompatibilityCollector module](https://github.com/PowerShell/PSScriptAnalyzer/tree/development/PSCompatibilityCollector).
+| PowerShell Version | Operating System | ID |
+| :----------------: | ---------------------- | --------------------------------------------------------------------- |
+| 3.0 | Windows Server 2012 | `win-8_x64_6.2.9200.0_3.0_x64_4.0.30319.42000_framework` |
+| 4.0 | Windows Server 2012 R2 | `win-8_x64_6.3.9600.0_4.0_x64_4.0.30319.42000_framework` |
+| 5.1 | Windows Server 2016 | `win-8_x64_10.0.14393.0_5.1.14393.2791_x64_4.0.30319.42000_framework` |
+| 5.1 | Windows Server 2019 | `win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
+| 5.1 | Windows 10 Pro | `win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
+| 6.2 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_6.2.4_x64_4.0.30319.42000_core` |
+| 6.2 | Windows 10.0.14393 | `win-8_x64_10.0.14393.0_6.2.4_x64_4.0.30319.42000_core` |
+| 6.2 | Windows 10.0.17763 | `win-8_x64_10.0.17763.0_6.2.4_x64_4.0.30319.42000_core` |
+| 6.2 | Windows 10.0.18362 | `win-4_x64_10.0.18362.0_6.2.4_x64_4.0.30319.42000_core` |
+| 7.0 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_7.0.0_x64_3.1.2_core` |
+| 7.0 | Windows 10.0.14393 | `win-8_x64_10.0.14393.0_7.0.0_x64_3.1.2_core` |
+| 7.0 | Windows 10.0.17763 | `win-8_x64_10.0.17763.0_7.0.0_x64_3.1.2_core` |
+| 7.0 | Windows 10.0.18362 | `win-4_x64_10.0.18362.0_7.0.0_x64_3.1.2_core` |
+
+Other profiles can be found in the [GitHub repo][02].
+
+You can also generate your own platform profile using the [PSCompatibilityCollector module][01].
The compatibility profile settings takes a list of platforms to target under `TargetProfiles`. A
platform can be specified as:
- A platform name (like `ubuntu_x64_18.04_6.1.1_x64_4.0.30319.42000_core`), which will have `.json`
added to the end and is searched for in the default profile directory.
-- A filename (like `my_custom_platform.json`), which will be searched for the in the default
- profile directory.
+- A filename (like `my_custom_platform.json`), which will be searched for the in the default profile
+ directory.
- An absolute path to a file (like `D:\PowerShellProfiles\TargetMachine.json`).
The default profile directory is under the PSScriptAnalzyer module at
@@ -82,7 +80,7 @@ The default profile directory is under the PSScriptAnalzyer module at
containing `PSScriptAnalyzer.psd1`).
The compatibility analysis compares a command used to both a target profile and a 'union' profile
-(containing all commands available in *any* profile in the profile dir). If a command is not present
+(containing all commands available in _any_ profile in the profile dir). If a command is not present
in the union profile, it is assumed to be locally created and ignored. Otherwise, if a command is
present in the union profile but not present in a target, it is deemed to be incompatible with that
target.
@@ -131,11 +129,17 @@ scriptblock as with other rules.
The rule can also be suppressed only for particular commands:
```powershell
-[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands', 'Start-Service')]
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands',
+ 'Start-Service')]
```
And also suppressed only for parameters:
```powershell
-[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands', 'Import-Module/FullyQualifiedName')]
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands',
+ 'Import-Module/FullyQualifiedName')]
```
+
+
+[01]: https://github.com/PowerShell/PSScriptAnalyzer/tree/main/PSCompatibilityCollector
+[02]: https://github.com/PowerShell/PSScriptAnalyzer/tree/main/PSCompatibilityCollector/optional_profiles
diff --git a/docs/Rules/UseCompatibleTypes.md b/docs/Rules/UseCompatibleTypes.md
index 355f35bed..9bff5fa76 100644
--- a/docs/Rules/UseCompatibleTypes.md
+++ b/docs/Rules/UseCompatibleTypes.md
@@ -1,6 +1,6 @@
---
description: Use compatible types
-ms.date: 06/28/2023
+ms.date: 12/12/2024
ms.topic: reference
title: UseCompatibleTypes
---
@@ -47,27 +47,25 @@ your configuration.
Platforms bundled by default are:
-| PowerShell Version | Operating System | ID |
-| ------------------ | --------------------- | --------------------------------------------------------------------- |
-| 3.0 | Windows Server 2012 | `win-8_x64_6.2.9200.0_3.0_x64_4.0.30319.42000_framework` |
-| 4.0 | Windows Server 2012R2 | `win-8_x64_6.3.9600.0_4.0_x64_4.0.30319.42000_framework` |
-| 5.1 | Windows Server 2016 | `win-8_x64_10.0.14393.0_5.1.14393.2791_x64_4.0.30319.42000_framework` |
-| 5.1 | Windows Server 2019 | `win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
-| 5.1 | Windows 10 1809 (RS5) | `win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
-| 6.2 | Windows Server 2016 | `win-8_x64_10.0.14393.0_6.2.4_x64_4.0.30319.42000_core` |
-| 6.2 | Windows Server 2019 | `win-8_x64_10.0.17763.0_6.2.4_x64_4.0.30319.42000_core` |
-| 6.2 | Windows 10 1809 (RS5) | `win-4_x64_10.0.17763.0_6.2.4_x64_4.0.30319.42000_core` |
-| 6.2 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_6.2.4_x64_4.0.30319.42000_core` |
-| 7.0 | Windows Server 2016 | `win-8_x64_10.0.14393.0_7.0.0_x64_3.1.2_core` |
-| 7.0 | Windows Server 2019 | `win-8_x64_10.0.17763.0_7.0.0_x64_3.1.2_core` |
-| 7.0 | Windows 10 1809 (RS5) | `win-4_x64_10.0.17763.0_6.2.4_x64_3.1.2_core` |
-| 7.0 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_6.2.4_x64_3.1.2_core` |
-
-Other profiles can be found in the
-[GitHub repo](https://github.com/PowerShell/PSScriptAnalyzer/tree/development/PSCompatibilityCollector/optional_profiles).
-
-You can also generate your own platform profile using the
-[PSCompatibilityCollector module](https://github.com/PowerShell/PSScriptAnalyzer/tree/development/PSCompatibilityCollector).
+| PowerShell Version | Operating System | ID |
+| :----------------: | ---------------------- | --------------------------------------------------------------------- |
+| 3.0 | Windows Server 2012 | `win-8_x64_6.2.9200.0_3.0_x64_4.0.30319.42000_framework` |
+| 4.0 | Windows Server 2012 R2 | `win-8_x64_6.3.9600.0_4.0_x64_4.0.30319.42000_framework` |
+| 5.1 | Windows Server 2016 | `win-8_x64_10.0.14393.0_5.1.14393.2791_x64_4.0.30319.42000_framework` |
+| 5.1 | Windows Server 2019 | `win-8_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
+| 5.1 | Windows 10 Pro | `win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` |
+| 6.2 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_6.2.4_x64_4.0.30319.42000_core` |
+| 6.2 | Windows 10.0.14393 | `win-8_x64_10.0.14393.0_6.2.4_x64_4.0.30319.42000_core` |
+| 6.2 | Windows 10.0.17763 | `win-8_x64_10.0.17763.0_6.2.4_x64_4.0.30319.42000_core` |
+| 6.2 | Windows 10.0.18362 | `win-4_x64_10.0.18362.0_6.2.4_x64_4.0.30319.42000_core` |
+| 7.0 | Ubuntu 18.04 LTS | `ubuntu_x64_18.04_7.0.0_x64_3.1.2_core` |
+| 7.0 | Windows 10.0.14393 | `win-8_x64_10.0.14393.0_7.0.0_x64_3.1.2_core` |
+| 7.0 | Windows 10.0.17763 | `win-8_x64_10.0.17763.0_7.0.0_x64_3.1.2_core` |
+| 7.0 | Windows 10.0.18362 | `win-4_x64_10.0.18362.0_7.0.0_x64_3.1.2_core` |
+
+Other profiles can be found in the [GitHub repo][02].
+
+You can also generate your own platform profile using the [PSCompatibilityCollector module][01].
The compatibility profile settings takes a list of platforms to target under `TargetProfiles`. A
platform can be specified as:
@@ -130,7 +128,7 @@ PS> $settings = @{
}
}
}
-PS> Invoke-ScriptAnalyzer -Settings $settings -ScriptDefinition '[System.Management.Automation.SemanticVersion]'1.18.0-rc1''
+PS> Invoke-ScriptAnalyzer -Settings $settings -ScriptDefinition "[System.Management.Automation.SemanticVersion]'1.18.0-rc1'"
RuleName Severity ScriptName Line Message
-------- -------- ---------- ---- -------
@@ -151,11 +149,17 @@ scriptblock as with other rules.
The rule can also be suppressed only for particular types:
```powershell
-[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleTypes', 'System.Management.Automation.Security.SystemPolicy')]
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleTypes',
+ 'System.Management.Automation.Security.SystemPolicy')]
```
And also suppressed only for type members:
```powershell
-[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands', 'System.Management.Automation.LanguagePrimitives/ConvertTypeNameToPSTypeName')]
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands',
+ 'System.Management.Automation.LanguagePrimitives/ConvertTypeNameToPSTypeName')]
```
+
+
+[01]: https://github.com/PowerShell/PSScriptAnalyzer/tree/main/PSCompatibilityCollector
+[02]: https://github.com/PowerShell/PSScriptAnalyzer/tree/main/PSCompatibilityCollector/optional_profiles
diff --git a/docs/Rules/UseConsistentParameterSetName.md b/docs/Rules/UseConsistentParameterSetName.md
new file mode 100644
index 000000000..6e9a4598f
--- /dev/null
+++ b/docs/Rules/UseConsistentParameterSetName.md
@@ -0,0 +1,144 @@
+---
+description: Use consistent parameter set names and proper parameter set configuration.
+ms.date: 03/20/2026
+ms.topic: reference
+title: UseConsistentParameterSetName
+---
+
+# UseConsistentParameterSetName
+
+**Severity Level: Warning**
+
+## Description
+
+Parameter set names in PowerShell are case-sensitive, unlike most other PowerShell elements. This
+rule ensures consistent casing and proper configuration of parameter sets to avoid runtime errors
+and improve code clarity.
+
+The rule performs five different checks:
+
+1. **Missing DefaultParameterSetName** - Warns when parameter sets are used but no default is
+ specified
+1. **Multiple parameter declarations** - Detects when a parameter is declared multiple times in the
+ same parameter set. This is ultimately a runtime exception - this check helps catch it sooner.
+1. **Case mismatch between DefaultParameterSetName and ParameterSetName** - Ensures consistent
+ casing
+1. **Case mismatch between different ParameterSetName values** - Ensures all references to the same
+ parameter set use identical casing
+1. **Parameter set names containing newlines** - Warns against using newline characters in parameter
+ set names
+
+> [!NOTE]
+> This rule isn't enabled by default. The user needs to enable it through settings.
+
+## How
+
+- Use a `DefaultParameterSetName` when defining multiple parameter sets
+- Ensure consistent casing between `DefaultParameterSetName` and `ParameterSetName` values
+- Use identical casing for all references to the same parameter set name
+- Avoid declaring the same parameter multiple times in a single parameter set
+- Do not use newline characters in parameter set names
+
+## Example
+
+### Wrong
+
+```powershell
+# Missing DefaultParameterSetName
+function Get-Data {
+ [CmdletBinding()]
+ param(
+ [Parameter(ParameterSetName='ByName')]
+ [string]$Name,
+
+ [Parameter(ParameterSetName='ByID')]
+ [int]$ID
+ )
+}
+
+# Case mismatch between DefaultParameterSetName and ParameterSetName
+function Get-Data {
+ [CmdletBinding(DefaultParameterSetName='ByName')]
+ param(
+ [Parameter(ParameterSetName='byname')]
+ [string]$Name,
+
+ [Parameter(ParameterSetName='ByID')]
+ [int]$ID
+ )
+}
+
+# Inconsistent casing between ParameterSetName values
+function Get-Data {
+ [CmdletBinding(DefaultParameterSetName='ByName')]
+ param(
+ [Parameter(ParameterSetName='ByName')]
+ [string]$Name,
+
+ [Parameter(ParameterSetName='byname')]
+ [string]$DisplayName
+ )
+}
+
+# Multiple parameter declarations in same set
+function Get-Data {
+ param(
+ [Parameter(ParameterSetName='ByName')]
+ [Parameter(ParameterSetName='ByName')]
+ [string]$Name
+ )
+}
+
+# Parameter set name with newline
+function Get-Data {
+ param(
+ [Parameter(ParameterSetName="Set`nOne")]
+ [string]$Name
+ )
+}
+```
+
+### Correct
+
+```powershell
+# Proper parameter set configuration
+function Get-Data {
+ [CmdletBinding(DefaultParameterSetName='ByName')]
+ param(
+ [Parameter(ParameterSetName='ByName', Mandatory)]
+ [string]$Name,
+
+ [Parameter(ParameterSetName='ByName')]
+ [Parameter(ParameterSetName='ByID')]
+ [string]$ComputerName,
+
+ [Parameter(ParameterSetName='ByID', Mandatory)]
+ [int]$ID
+ )
+}
+```
+
+## Configuration
+
+```powershell
+Rules = @{
+ PSUseConsistentParameterSetName = @{
+ Enable = $true
+ }
+}
+```
+
+### Parameters
+
+- `Enable`: **bool** (Default value is `$false`)
+
+ Enable or disable the rule during ScriptAnalyzer invocation.
+
+## Notes
+
+- Parameter set names are case-sensitive in PowerShell, making this different from most other
+ PowerShell elements
+- The first occurrence of a parameter set name in your code is treated as the canonical casing
+- Parameters without `[Parameter()]` attributes are automatically part of all parameter sets
+- It's a PowerShell best practice to always specify a `DefaultParameterSetName` when using parameter
+ sets
\ No newline at end of file
diff --git a/docs/Rules/UseConsistentParametersKind.md b/docs/Rules/UseConsistentParametersKind.md
new file mode 100644
index 000000000..20a470f30
--- /dev/null
+++ b/docs/Rules/UseConsistentParametersKind.md
@@ -0,0 +1,69 @@
+---
+description: Use the same pattern when defining parameters.
+ms.date: 03/20/2026
+ms.topic: reference
+title: UseConsistentParametersKind
+---
+# UseConsistentParametersKind
+
+**Severity Level: Warning**
+
+## Description
+
+All functions should use the same pattern when defining parameters. Possible pattern types are:
+
+1. `Inline`
+
+ ```powershell
+ function f([Parameter()]$FirstParam) {
+ return
+ }
+ ```
+
+1. `ParamBlock`
+
+ ```powershell
+ function f {
+ param([Parameter()]$FirstParam)
+ return
+ }
+ ```
+
+In simple scenarios, both function definitions shown are considered to be equal. The purpose of this
+rule is to enforce consistent code style across the codebase.
+
+## How to Fix
+
+Rewrite function so it defines parameters as specified in the rule
+
+## Example
+
+When the rule sets parameters definition kind to `Inline`:
+
+```powershell
+# Correct
+function f([Parameter()]$FirstParam) {
+ return
+}
+
+# Incorrect
+function g {
+ param([Parameter()]$FirstParam)
+ return
+}
+```
+
+When the rule sets parameters definition kind to `ParamBlock`:
+
+```powershell
+# Incorrect
+function f([Parameter()]$FirstParam) {
+ return
+}
+
+# Correct
+function g {
+ param([Parameter()]$FirstParam)
+ return
+}
+```
\ No newline at end of file
diff --git a/docs/Rules/UseConstrainedLanguageMode.md b/docs/Rules/UseConstrainedLanguageMode.md
new file mode 100644
index 000000000..e12476531
--- /dev/null
+++ b/docs/Rules/UseConstrainedLanguageMode.md
@@ -0,0 +1,414 @@
+---
+description: Use patterns compatible with Constrained Language Mode
+ms.date: 03/20/2026
+ms.topic: reference
+title: UseConstrainedLanguageMode
+---
+# UseConstrainedLanguageMode
+
+**Severity Level: Warning**
+
+## Description
+
+This rule identifies PowerShell patterns that are restricted or not permitted in Constrained
+Language Mode (CLM).
+
+Constrained Language Mode is a PowerShell security feature that restricts:
+
+- .NET types that can be used
+- COM objects that can be instantiated
+- Commands that can be executed
+- Language features that can be used
+
+CLM is commonly used in:
+
+- Application Control environments (Application Control for Business, AppLocker)
+- Just Enough Administration (JEA) endpoints
+- Secure environments requiring additional PowerShell restrictions
+
+Digitally signed scripts from trusted publishers execute in Full Language Mode (FLM) even in CLM
+environments. The rule detects signature blocks (`# SIG # Begin signature block`) and adjusts checks
+accordingly. Most restrictions don't apply to signed scripts, but certain checks (dot-sourcing,
+parameter types, manifest best practices) are always enforced.
+
+> [!IMPORTANT]
+> The rule performs a simple text check for signature blocks and does NOT validate signature
+> authenticity or certificate trust. Actual signature validation is performed by PowerShell at
+> runtime.
+
+## Constrained Language Mode Restrictions
+
+### Unsigned Scripts (Full CLM Checking)
+
+The following are flagged for unsigned scripts:
+
+1. **Add-Type** - Code compilation not permitted
+1. **Disallowed COM Objects** - Only Scripting.Dictionary, Scripting.FileSystemObject,
+ VBScript.RegExp allowed
+1. **Disallowed .NET Types** - Only ~70 allowed types (string, int, hashtable, pscredential, etc.)
+1. **Type Constraints** - On parameters and variables
+1. **Type Expressions** - Static type references like `[Type]::Method()`
+1. **Type Casts** - Converting to disallowed types
+1. **Member Invocations** - Methods/properties on disallowed types
+1. **PowerShell Classes** - `class` keyword not permitted
+1. **XAML/WPF** - Not permitted
+1. **Invoke-Expression** - Restricted
+1. **Dot-Sourcing** - May be restricted depending on the file being sourced
+1. **Module Manifest Wildcards** - Wildcard exports not recommended
+1. **Module Manifest .ps1 Files** - Script modules ending with .ps1 not allowed
+
+Always enforced, even for signed scripts
+
+### Signed Scripts (Selective Checking)
+
+For scripts with signature blocks, only these are checked:
+
+- Dot-sourcing
+- Parameter type constraints
+- Module manifest wildcards (.psd1 files)
+- Module manifest script modules (.psd1 files)
+
+## Configuration
+
+### Basic Configuration
+
+```powershell
+@{
+ Rules = @{
+ PSUseConstrainedLanguageMode = @{
+ Enable = $true
+ }
+ }
+}
+```
+
+### Parameters
+
+#### Enable: bool (Default value is `$false`)
+
+Enable or disable the rule during ScriptAnalyzer invocation. This rule is disabled by default
+because not all scripts need CLM compatibility.
+
+#### IgnoreSignatures: bool (Default value is `$false`)
+
+Control signature detection behavior:
+
+- `$false` (default): Automatically detect signatures. Signed scripts get selective checking,
+ unsigned get full checking.
+- `$true`: Bypass signature detection. ALL scripts get full CLM checking regardless of signature
+ status.
+
+```powershell
+@{
+ Rules = @{
+ PSUseConstrainedLanguageMode = @{
+ Enable = $true
+ IgnoreSignatures = $true # Enforce full CLM compliance for all scripts
+ }
+ }
+}
+```
+
+Use `IgnoreSignatures = $true` when:
+
+- Auditing signed scripts for complete CLM compatibility
+- Preparing scripts for untrusted environments
+- Enforcing strict CLM compliance organization-wide
+- Development/testing to see all potential issues
+
+## How to Fix
+
+### Replace Add-Type
+
+Use allowed cmdlets or pre-compile assemblies.
+
+### Replace Disallowed COM Objects
+
+Use only allowed COM objects (Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp) or
+PowerShell cmdlets.
+
+### Replace Disallowed Types
+
+Use allowed type accelerators (`[string]`, `[int]`, `[hashtable]`, etc.) or allowed cmdlets instead
+of disallowed .NET types.
+
+### Replace PowerShell Classes
+
+Use `New-Object PSObject` with `Add-Member` or hashtables instead of classes.
+
+> [!IMPORTANT]
+> `[PSCustomObject]@{}` syntax is NOT allowed in CLM because it uses type casting.
+
+### Avoid XAML
+
+Don't use WPF/XAML in CLM-compatible scripts.
+
+### Replace Invoke-Expression
+
+Use direct execution (`&`) or safer alternatives.
+
+### Replace Dot-Sourcing
+
+Use modules with Import-Module instead of dot-sourcing when possible.
+
+### Fix Module Manifests
+
+- Replace wildcard exports (`*`) with explicit lists.
+- Use `.psm1` or `.dll` instead of `.ps1` for RootModule/NestedModules.
+- Don't use `ScriptsToProcess`. These scripts are loaded in the caller's scope and are blocked.
+
+## Examples
+
+### Example 1: Add-Type
+
+#### Wrong
+
+```powershell
+Add-Type -TypeDefinition @"
+ public class Helper {
+ public static string DoWork() { return "Done"; }
+ }
+"@
+```
+
+#### Correct
+
+```powershell
+ # Code sign your scripts/modules using proper signing tools
+ # (for example, Set-AuthenticodeSignature or external signing processes)
+ # Use allowed cmdlets instead of Add-Type-defined types where possible
+ # Or pre-compile, sign, and load the assembly (for example, via Add-Type -Path)
+```
+
+### Example 2: COM Objects
+
+#### Wrong
+
+```powershell
+$excel = New-Object -ComObject Excel.Application
+```
+
+#### Correct
+
+```powershell
+# Use allowed COM object
+$dict = New-Object -ComObject Scripting.Dictionary
+
+# Or use PowerShell cmdlets
+Import-Excel -Path $file # From ImportExcel module
+```
+
+### Example 3: Disallowed Types
+
+#### Wrong
+
+```powershell
+# Type constraint and member invocation flagged
+function Download-File {
+ param([System.Net.WebClient]$Client)
+ $Client.DownloadString($url)
+}
+
+# Type cast and method call flagged
+[System.Net.WebClient]$client = New-Object System.Net.WebClient
+$data = $client.DownloadData($url)
+```
+
+#### Correct
+
+```powershell
+# Use allowed cmdlets
+function Download-File {
+ param([string]$Url)
+ Invoke-WebRequest -Uri $Url
+}
+
+# Use allowed types
+function Process-Text {
+ param([string]$Text)
+ $upper = $Text.ToUpper() # String methods are allowed
+}
+```
+
+### Example 4: PowerShell Classes
+
+#### Wrong
+
+```powershell
+class MyClass {
+ [string]$Name
+
+ [string]GetInfo() {
+ return $this.Name
+ }
+}
+
+# Also wrong - uses type cast
+$obj = [PSCustomObject]@{
+ Name = "Test"
+}
+```
+
+#### Correct
+
+```powershell
+# Option 1: New-Object PSObject with Add-Member
+$obj = New-Object PSObject -Property @{
+ Name = "Test"
+}
+
+$obj | Add-Member -MemberType ScriptMethod -Name GetInfo -Value {
+ return $this.Name
+}
+
+Add-Member -InputObject $obj -NotePropertyMembers @{"Number" = 42}
+
+# Option 2: Hashtable
+$obj = @{
+ Name = "Test"
+ Number = 42
+}
+```
+
+### Example 5: Module Manifests
+
+#### Wrong
+
+```powershell
+@{
+ ModuleVersion = '1.0.0'
+ RootModule = 'MyModule.ps1' # .ps1 not recommended
+ FunctionsToExport = '*' # Wildcard not recommended
+ CmdletsToExport = '*'
+}
+```
+
+#### Correct
+
+```powershell
+@{
+ ModuleVersion = '1.0.0'
+ RootModule = 'MyModule.psm1' # Use .psm1 or .dll
+ FunctionsToExport = @( # Explicit list
+ 'Get-MyFunction'
+ 'Set-MyFunction'
+ )
+ CmdletsToExport = @()
+}
+```
+
+### Example 6: Array Types
+
+#### Wrong
+
+```powershell
+# Disallowed type in array
+param([System.Net.WebClient[]]$Clients)
+```
+
+#### Correct
+
+```powershell
+# Allowed types in arrays are fine
+param([string[]]$Names)
+param([int[]]$Numbers)
+param([hashtable[]]$Configuration)
+```
+
+## Detailed Restrictions
+
+### 1. Add-Type
+
+`Add-Type` allows compiling arbitrary C# code and isn't permitted in CLM.
+
+**Enforced For**: Unsigned scripts only
+
+### 2. COM Objects
+
+Only three COM objects are allowed:
+
+- `Scripting.Dictionary`
+- `Scripting.FileSystemObject`
+- `VBScript.RegExp`
+
+All others (Excel.Application, WScript.Shell, etc.) are flagged.
+
+**Enforced For**: Unsigned scripts only
+
+### 3. .NET Types
+
+Only ~70 allowed types including:
+
+- Primitives: `string`, `int`, `bool`, `byte`, `char`, `datetime`, `decimal`, `double`, etc.
+- Collections: `hashtable`, `array`, `arraylist`
+- PowerShell: `pscredential`, `psobject`, `securestring`
+- Utilities: `regex`, `guid`, `version`, `uri`, `xml`
+- Arrays: `string[]`, `int[][]`, etc. (array of any allowed type)
+
+The rule checks type usage in:
+
+- Parameter type constraints (**always enforced, even for signed scripts**)
+- Variable type constraints
+- New-Object -TypeName
+- Type expressions (`[Type]::Method()`)
+- Type casts (`[Type]$variable`)
+- Member invocations on typed variables
+
+**Enforced For**: Parameter constraints always; others unsigned only
+
+### 4. PowerShell Classes
+
+The `class` keyword is not permitted. Use `New-Object PSObject` with `Add-Member` or hashtables.
+
+**Note**: `[PSCustomObject]@{}` is also not allowed because it uses type casting.
+
+**Enforced For**: Unsigned scripts only
+
+### 5. XAML/WPF
+
+XAML and WPF are not permitted in CLM.
+
+**Enforced For**: Unsigned scripts only
+
+### 6. Invoke-Expression
+
+`Invoke-Expression` is restricted in CLM.
+
+**Enforced For**: Unsigned scripts only
+
+### 7. Dot-Sourcing
+
+Dot-sourcing (`. $PSScriptRoot\script.ps1`) may be restricted depending on source location.
+
+**Enforced For**: ALL scripts (unsigned and signed)
+
+### 8. Module Manifest Best Practices
+
+#### Wildcard Exports
+
+Don't use `*` in: `FunctionsToExport`, `CmdletsToExport`, `AliasesToExport`, `VariablesToExport`
+
+Use explicit lists for security and clarity.
+
+**Enforced For**: ALL .psd1 files (unsigned and signed)
+
+#### Script Module Files
+
+Don't use `.ps1` files in: `RootModule`, `ModuleToProcess`, `NestedModules`
+
+Use `.psm1` (script modules) or `.dll` (binary modules) for better performance and compatibility.
+
+**Enforced For**: ALL .psd1 files (unsigned and signed)
+
+## More Information
+
+- [About Language Modes][01]
+- [PowerShell Constrained Language Mode][03]
+- [PowerShell Module Function Export in Constrained Language][04]
+- [PowerShell Constrained Language Mode and the Dot-Source Operator][02]
+
+
+[01]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_language_modes
+[02]: https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode-and-the-dot-source-operator/
+[03]: https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/
+[04]: https://devblogs.microsoft.com/powershell/powershell-module-function-export-in-constrained-language/
diff --git a/docs/Rules/UseCorrectCasing.md b/docs/Rules/UseCorrectCasing.md
index f64a7888d..b73df6415 100644
--- a/docs/Rules/UseCorrectCasing.md
+++ b/docs/Rules/UseCorrectCasing.md
@@ -1,6 +1,6 @@
---
description: Use exact casing of cmdlet/function/parameter name.
-ms.date: 06/28/2023
+ms.date: 03/19/2025
ms.topic: reference
title: UseCorrectCasing
---
@@ -10,26 +10,66 @@ title: UseCorrectCasing
## Description
-This is a style/formatting rule. PowerShell is case insensitive where applicable. The casing of
-cmdlet names or parameters does not matter but this rule ensures that the casing matches for
-consistency and also because most cmdlets/parameters start with an upper case and using that
-improves readability to the human eye.
+This is a style/formatting rule. PowerShell is case insensitive wherever possible, so the casing of
+cmdlet names, parameters, keywords and operators doesn't matter. This rule nonetheless ensures
+consistent casing for clarity and readability. Using lowercase keywords helps distinguish them from
+commands. Using lowercase operators helps distinguish them from parameters.
## How
-Use exact casing of the cmdlet and its parameters, e.g.
-`Invoke-Command { 'foo' } -RunAsAdministrator`.
+- Use exact casing for type names.
+- Use exact casing of the cmdlet and its parameters.
+- Use lowercase for language keywords and operators.
-## Example
+## Configuration
-### Wrong
+```powershell
+Rules = @{
+ PSUseCorrectCasing = @{
+ Enable = $true
+ CheckCommands = $true
+ CheckKeyword = $true
+ CheckOperator = $true
+ }
+}
+```
+
+## Parameters
+
+### Enable: bool (Default value is `$false`)
+
+Enable or disable the rule during ScriptAnalyzer invocation.
+
+### CheckCommands: bool (Default value is `$true`)
+
+If true, require the case of all command and parameter names to match their canonical casing.
+
+### CheckKeyword: bool (Default value is `$true`)
+
+If true, require the case of all keywords to be lowercase.
+
+### CheckOperator: bool (Default value is `$true`)
+
+If true, require the case of all operators to be lowercase. For example: `-eq`, `-ne`, `-gt`
+
+## Examples
+
+### Wrong way
```powershell
+ForEach ($file in Get-childitem -Recurse) {
+ $file.Extension -EQ '.txt'
+}
+
invoke-command { 'foo' } -runasadministrator
```
-### Correct
+### Correct way
```powershell
+foreach ($file in Get-ChildItem -Recurse) {
+ $file.Extension -eq '.txt'
+}
+
Invoke-Command { 'foo' } -RunAsAdministrator
```
diff --git a/docs/Rules/UseShouldProcessForStateChangingFunctions.md b/docs/Rules/UseShouldProcessForStateChangingFunctions.md
index f0e102da3..97bb97767 100644
--- a/docs/Rules/UseShouldProcessForStateChangingFunctions.md
+++ b/docs/Rules/UseShouldProcessForStateChangingFunctions.md
@@ -1,6 +1,6 @@
---
description: Use ShouldProcess For State Changing Functions
-ms.date: 06/28/2023
+ms.date: 12/05/2024
ms.topic: reference
title: UseShouldProcessForStateChangingFunctions
---
@@ -10,7 +10,12 @@ title: UseShouldProcessForStateChangingFunctions
## Description
-Functions whose verbs change system state should support `ShouldProcess`.
+Functions whose verbs change system state should support `ShouldProcess`. To enable the
+`ShouldProcess` feature, set the `SupportsShouldProcess` argument in the `CmdletBinding` attribute.
+The `SupportsShouldProcess` argument adds **Confirm** and **WhatIf** parameters to the function. The
+**Confirm** parameter prompts the user before it runs the command on each object in the pipeline.
+The **WhatIf** parameter lists the changes that the command would make, instead of running the
+command.
Verbs that should support `ShouldProcess`:
@@ -58,3 +63,16 @@ function Set-ServiceObject
...
}
```
+
+## More information
+
+- [about_Functions_CmdletBindingAttribute][01]
+- [Everything you wanted to know about ShouldProcess][04]
+- [Required Development Guidelines][03]
+- [Requesting Confirmation from Cmdlets][02]
+
+
+[01]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_functions_cmdletbindingattribute
+[02]: https://learn.microsoft.com/powershell/scripting/developer/cmdlet/requesting-confirmation-from-cmdlets
+[03]: https://learn.microsoft.com/powershell/scripting/developer/cmdlet/required-development-guidelines#support-confirmation-requests-rd04
+[04]: https://learn.microsoft.com/powershell/scripting/learn/deep-dives/everything-about-shouldprocess
diff --git a/docs/Rules/UseSingleValueFromPipelineParameter.md b/docs/Rules/UseSingleValueFromPipelineParameter.md
new file mode 100644
index 000000000..92c350eb4
--- /dev/null
+++ b/docs/Rules/UseSingleValueFromPipelineParameter.md
@@ -0,0 +1,99 @@
+---
+description: Use at most a single ValueFromPipeline parameter per parameter set.
+ms.date: 03/20/2026
+ms.topic: reference
+title: UseSingleValueFromPipelineParameter
+---
+# UseSingleValueFromPipelineParameter
+
+**Severity Level: Warning**
+
+## Description
+
+Parameter sets should have at most one parameter marked as `ValueFromPipeline = true`.
+
+This rule identifies functions where multiple parameters within the same parameter set have
+`ValueFromPipeline` set to `true` (either explicitly or implicitly).
+
+## How
+
+Ensure that only one parameter per parameter set accepts pipeline input by value. If you need
+multiple parameters to accept different types of pipeline input, use separate parameter sets.
+
+## Example
+
+### Wrong
+
+```powershell
+function Process-Data {
+ [CmdletBinding()]
+ param(
+ [Parameter(ValueFromPipeline)]
+ [string] $InputData,
+
+ [Parameter(ValueFromPipeline)]
+ [string] $ProcessingMode
+ )
+
+ process {
+ Write-Output "$ProcessingMode`: $InputData"
+ }
+}
+```
+
+
+### Correct
+
+```powershell
+function Process-Data {
+ [CmdletBinding()]
+ param(
+ [Parameter(ValueFromPipeline)]
+ [string] $InputData,
+
+ [Parameter(Mandatory)]
+ [string] $ProcessingMode
+ )
+ process {
+ Write-Output "$ProcessingMode`: $InputData"
+ }
+}
+```
+
+## Suppression
+
+To suppress this rule for a specific parameter set, use the `SuppressMessage` attribute with the
+parameter set name:
+
+```powershell
+function Process-Data {
+ [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'MyParameterSet')]
+ [CmdletBinding()]
+ param(
+ [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')]
+ [string] $InputData,
+
+ [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')]
+ [string] $ProcessingMode
+ )
+ process {
+ Write-Output "$ProcessingMode`: $InputData"
+ }
+}
+```
+
+For the default parameter set, use `'default'` as the suppression target:
+
+```powershell
+[Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingleValueFromPipelineParameter', 'default')]
+```
+
+## Notes
+
+- This rule applies to both explicit `ValueFromPipeline = $true` and implicit `ValueFromPipeline`
+ (which is the same as using `= $true`)
+- Parameters with `ValueFromPipeline=$false` are not flagged by this rule
+- The rule correctly handles the default parameter set (`__AllParameterSets`) and named parameter
+ sets
+- Different parameter sets can each have their own single `ValueFromPipeline` parameter without
+ triggering this rule
diff --git a/docs/Rules/UseSupportsShouldProcess.md b/docs/Rules/UseSupportsShouldProcess.md
index 04ca2bfe7..904ad0773 100644
--- a/docs/Rules/UseSupportsShouldProcess.md
+++ b/docs/Rules/UseSupportsShouldProcess.md
@@ -18,7 +18,7 @@ authors to provide the desired interactive experience while using the cmdlet.
## Example
-### Wrong:
+### Wrong
```powershell
function foo {
@@ -30,7 +30,7 @@ function foo {
}
```
-### Correct:
+### Correct
```powershell
function foo {
diff --git a/docs/Rules/UseUTF8EncodingForHelpFile.md b/docs/Rules/UseUTF8EncodingForHelpFile.md
index 31c525db6..6d8e0f3c2 100644
--- a/docs/Rules/UseUTF8EncodingForHelpFile.md
+++ b/docs/Rules/UseUTF8EncodingForHelpFile.md
@@ -1,6 +1,6 @@
---
description: Use UTF8 Encoding For Help File
-ms.date: 06/28/2023
+ms.date: 01/07/2025
ms.topic: reference
title: UseUTF8EncodingForHelpFile
---
@@ -10,4 +10,24 @@ title: UseUTF8EncodingForHelpFile
## Description
-Check if help file uses UTF-8 encoding.
+Check that an `about_` help file uses UTF-8 encoding. The filename must start with `about_` and end
+with `.help.txt`. The rule uses the **CurrentEncoding** property of the **StreamReader** class to
+determine the encoding of the file.
+
+## How
+
+For PowerShell commands that write to files, ensure that you set the encoding parameter to `utf8`,
+`utf8BOM`, or `utf8NoBOM`.
+
+When you create a help file using a text editor, ensure that the editor is configured to save the
+file in a UTF8 format. Consult the documentation for your text editor for instructions on how to
+save files with a specific encoding.
+
+## Further reading
+
+For more information, see the following articles:
+
+- [System.IO.StreamReader](https://learn.microsoft.com/dotnet/api/system.io.streamreader.currentencoding)
+- [about_Character_Encoding](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_character_encoding)
+- [Set-Content](https://learn.microsoft.com/powershell/module/microsoft.powershell.management/set-content)
+- [Understanding file encoding in VS Code and PowerShell](https://learn.microsoft.com/powershell/scripting/dev-cross-plat/vscode/understanding-file-encoding)
diff --git a/es-metadata.yml b/es-metadata.yml
new file mode 100644
index 000000000..e4dc3924a
--- /dev/null
+++ b/es-metadata.yml
@@ -0,0 +1,12 @@
+schemaVersion: 1.0.0
+providers:
+- provider: InventoryAsCode
+ version: 1.0.0
+ metadata:
+ isProduction: true
+ accountableOwners:
+ service: cef1de07-99d6-45df-b907-77d0066032ec
+ routing:
+ defaultAreaPath:
+ org: msazure
+ path: One\MGMT\Compute\Powershell\Powershell
diff --git a/global.json b/global.json
index 6e6e5445c..de3e16bd7 100644
--- a/global.json
+++ b/global.json
@@ -1,5 +1,6 @@
{
"sdk": {
- "version": "6.0.427"
+ "version": "8.0.419",
+ "rollForward": "latestFeature"
}
}
diff --git a/tools/installPSResources.ps1 b/tools/installPSResources.ps1
index 506d93a35..48ab81bdd 100644
--- a/tools/installPSResources.ps1
+++ b/tools/installPSResources.ps1
@@ -6,8 +6,19 @@ param(
)
if ($PSRepository -eq "CFS" -and -not (Get-PSResourceRepository -Name CFS -ErrorAction SilentlyContinue)) {
- Register-PSResourceRepository -Name CFS -Uri "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/powershell/nuget/v3/index.json"
+ Register-PSResourceRepository -Name CFS -Uri "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/PowerShellGalleryMirror/nuget/v3/index.json"
}
-Install-PSResource -Repository $PSRepository -TrustRepository -Name platyPS
-Install-PSResource -Repository $PSRepository -TrustRepository -Name Pester
+# NOTE: Due to a bug in Install-PSResource with upstream feeds, we have to
+# request an exact version. Otherwise, if a newer version is available in the
+# upstream feed, it will fail to install any version at all.
+Install-PSResource -Verbose -TrustRepository -RequiredResource @{
+ platyPS = @{
+ version = "0.14.2"
+ repository = $PSRepository
+ }
+ Pester = @{
+ version = "5.7.1"
+ repository = $PSRepository
+ }
+}