diff --git a/Engine/PSScriptAnalyzer.psd1 b/Engine/PSScriptAnalyzer.psd1 index 49fb93227..993677254 100644 --- a/Engine/PSScriptAnalyzer.psd1 +++ b/Engine/PSScriptAnalyzer.psd1 @@ -20,7 +20,7 @@ GUID = 'd6245802-193d-4068-a631-8863a4342a18' CompanyName = 'Microsoft Corporation' # Copyright statement for this module -Copyright = '(c) Microsoft Corporation 2025. 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.' diff --git a/Rules/UseConsistentIndentation.cs b/Rules/UseConsistentIndentation.cs index 41aa4ef4d..2c77787c6 100644 --- a/Rules/UseConsistentIndentation.cs +++ b/Rules/UseConsistentIndentation.cs @@ -130,16 +130,27 @@ public override IEnumerable AnalyzeScript(Ast ast, string file var tokens = Helper.Instance.Tokens; var diagnosticRecords = new List(); var indentationLevel = 0; - var currentIndenationLevelIncreaseDueToPipelines = 0; var onNewLine = true; var pipelineAsts = ast.FindAll(testAst => testAst is PipelineAst && (testAst as PipelineAst).PipelineElements.Count > 1, true).ToList(); - /* - When an LParen and LBrace are on the same line, it can lead to too much de-indentation. - In order to prevent the RParen code from de-indenting too much, we keep a stack of when we skipped the indentation - caused by tokens that require a closing RParen (which are LParen, AtParen and DollarParen). - */ - var lParenSkippedIndentation = new Stack(); - + // Sort by end position so that inner (nested) pipelines appear before outer ones. + // This is required by MatchingPipelineAstEnd, whose early-break optimization + // would otherwise skip nested pipelines that end before their outer pipeline. + pipelineAsts.Sort((a, b) => + { + int lineCmp = a.Extent.EndScriptPosition.LineNumber.CompareTo(b.Extent.EndScriptPosition.LineNumber); + return lineCmp != 0 ? lineCmp : a.Extent.EndScriptPosition.ColumnNumber.CompareTo(b.Extent.EndScriptPosition.ColumnNumber); + }); + // Track pipeline indentation increases per PipelineAst instead of as a single + // flat counter. A flat counter caused all accumulated pipeline indentation to be + // subtracted when *any* pipeline ended, instead of only the contribution from + // that specific pipeline - leading to runaway indentation with nested pipelines. + var pipelineIndentationIncreases = new Dictionary(); + // When multiple openers appear on the same line (e.g. ({ or @(@{), + // only the last unclosed opener should affect indentation. We + // track, for every opener, whether its indentation increment was + // skipped so that the matching closer knows not to decrement. + var openerSkippedIndentation = new Stack(); + for (int tokenIndex = 0; tokenIndex < tokens.Length; tokenIndex++) { var token = tokens[tokenIndex]; @@ -153,27 +164,39 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do { case TokenKind.AtCurly: case TokenKind.LCurly: - AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); - break; - case TokenKind.DollarParen: case TokenKind.AtParen: - lParenSkippedIndentation.Push(false); - AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); + AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); + if (HasUnclosedOpenerBeforeLineEnd(tokens, tokenIndex)) + { + openerSkippedIndentation.Push(true); + } + else + { + indentationLevel++; + openerSkippedIndentation.Push(false); + } 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. + // When a line starts with a parenthesis and it is not the + // last non-comment token of that line, indentation does + // not need to be increased. if ((tokenIndex == 0 || tokens[tokenIndex - 1].Kind == TokenKind.NewLine) && NextTokenIgnoringComments(tokens, tokenIndex)?.Kind != TokenKind.NewLine) { - onNewLine = false; - lParenSkippedIndentation.Push(true); + openerSkippedIndentation.Push(true); break; } - lParenSkippedIndentation.Push(false); + // General case: skip when another opener follows so that + // only the last unclosed opener on a line is indent-affecting. + if (HasUnclosedOpenerBeforeLineEnd(tokens, tokenIndex)) + { + openerSkippedIndentation.Push(true); + break; + } + openerSkippedIndentation.Push(false); indentationLevel++; break; @@ -188,40 +211,50 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do if (pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationAfterEveryPipeline) { AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); - currentIndenationLevelIncreaseDueToPipelines++; + // Attribute this increase to the innermost pipeline containing + // this pipe token so it is only reversed when that specific + // pipeline ends, not when an unrelated outer pipeline ends. + PipelineAst containingPipeline = FindInnermostContainingPipeline(pipelineAsts, token); + if (containingPipeline != null) + { + if (!pipelineIndentationIncreases.ContainsKey(containingPipeline)) + pipelineIndentationIncreases[containingPipeline] = 0; + pipelineIndentationIncreases[containingPipeline]++; + } break; } if (pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationForFirstPipeline) { - bool isFirstPipeInPipeline = pipelineAsts.Any(pipelineAst => - PositionIsEqual(LastPipeOnFirstLineWithPipeUsage((PipelineAst)pipelineAst).Extent.EndScriptPosition, - tokens[tokenIndex - 1].Extent.EndScriptPosition)); - if (isFirstPipeInPipeline) + // Capture which specific PipelineAst this is the first pipe for, + // so the indentation increase is attributed to that pipeline only. + PipelineAst firstPipePipeline = pipelineAsts + .Cast() + .FirstOrDefault(pipelineAst => + PositionIsEqual(LastPipeOnFirstLineWithPipeUsage(pipelineAst).Extent.EndScriptPosition, + tokens[tokenIndex - 1].Extent.EndScriptPosition)); + if (firstPipePipeline != null) { AddViolation(token, indentationLevel++, diagnosticRecords, ref onNewLine); - currentIndenationLevelIncreaseDueToPipelines++; + if (!pipelineIndentationIncreases.ContainsKey(firstPipePipeline)) + pipelineIndentationIncreases[firstPipePipeline] = 0; + pipelineIndentationIncreases[firstPipePipeline]++; } } break; case TokenKind.RParen: - bool matchingLParenIncreasedIndentation = false; - if (lParenSkippedIndentation.Count > 0) + case TokenKind.RCurly: + if (openerSkippedIndentation.Count > 0 && openerSkippedIndentation.Pop()) { - matchingLParenIncreasedIndentation = lParenSkippedIndentation.Pop(); + // The matching opener skipped its increment, so we + // skip the decrement but still enforce indentation. + AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); } - if (matchingLParenIncreasedIndentation) + else { - onNewLine = false; - break; + indentationLevel = ClipNegative(indentationLevel - 1); + AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); } - indentationLevel = ClipNegative(indentationLevel - 1); - AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); - break; - - case TokenKind.RCurly: - indentationLevel = ClipNegative(indentationLevel - 1); - AddViolation(token, indentationLevel, diagnosticRecords, ref onNewLine); break; case TokenKind.NewLine: @@ -290,14 +323,62 @@ caused by tokens that require a closing RParen (which are LParen, AtParen and Do if (pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationForFirstPipeline || pipelineIndentationStyle == PipelineIndentationStyle.IncreaseIndentationAfterEveryPipeline) { - indentationLevel = ClipNegative(indentationLevel - currentIndenationLevelIncreaseDueToPipelines); - currentIndenationLevelIncreaseDueToPipelines = 0; + // Only subtract the indentation contributed by this specific pipeline, + // leaving contributions from outer/unrelated pipelines intact. + if (pipelineIndentationIncreases.TryGetValue(matchingPipeLineAstEnd, out int contribution)) + { + indentationLevel = ClipNegative(indentationLevel - contribution); + pipelineIndentationIncreases.Remove(matchingPipeLineAstEnd); + } } } return diagnosticRecords; } + /// + /// Scans forward from the current opener to the end of the line. + /// Returns true if there is at least one unclosed opener when + /// the line ends, meaning the current opener should skip its + /// indentation increment. If the current opener's own closer + /// is found on the same line (depth drops below zero), returns + /// false so that it indents normally. + /// + private static bool HasUnclosedOpenerBeforeLineEnd(Token[] tokens, int currentIndex) + { + int depth = 0; + for (int i = currentIndex + 1; i < tokens.Length; i++) + { + switch (tokens[i].Kind) + { + case TokenKind.NewLine: + case TokenKind.LineContinuation: + case TokenKind.EndOfInput: + return depth > 0; + + case TokenKind.LCurly: + case TokenKind.AtCurly: + case TokenKind.LParen: + case TokenKind.AtParen: + case TokenKind.DollarParen: + depth++; + break; + + case TokenKind.RCurly: + case TokenKind.RParen: + depth--; + if (depth < 0) + { + // Our own closer was found on this line. + return false; + } + break; + } + } + + return depth > 0; + } + private static Token NextTokenIgnoringComments(Token[] tokens, int startIndex) { if (startIndex >= tokens.Length - 1) @@ -432,6 +513,32 @@ private static PipelineAst MatchingPipelineAstEnd(List pipelineAsts, Token return matchingPipeLineAstEnd; } + /// + /// Finds the innermost (smallest) PipelineAst whose extent fully contains the given token. + /// Used to attribute pipeline indentation increases to the correct pipeline when + /// using IncreaseIndentationAfterEveryPipeline. + /// + private static PipelineAst FindInnermostContainingPipeline(List pipelineAsts, Token token) + { + PipelineAst best = null; + int bestSize = int.MaxValue; + foreach (var ast in pipelineAsts) + { + var pipeline = (PipelineAst)ast; + int pipelineStart = pipeline.Extent.StartOffset; + int pipelineEnd = pipeline.Extent.EndOffset; + int pipelineSize = pipelineEnd - pipelineStart; + if (pipelineStart <= token.Extent.StartOffset && + token.Extent.EndOffset <= pipelineEnd && + pipelineSize < bestSize) + { + best = pipeline; + bestSize = pipelineSize; + } + } + return best; + } + private static bool PositionIsEqual(IScriptPosition position1, IScriptPosition position2) { return position1.ColumnNumber == position2.ColumnNumber && diff --git a/Tests/Rules/UseConsistentIndentation.tests.ps1 b/Tests/Rules/UseConsistentIndentation.tests.ps1 index 0d26ff39d..6bf241116 100644 --- a/Tests/Rules/UseConsistentIndentation.tests.ps1 +++ b/Tests/Rules/UseConsistentIndentation.tests.ps1 @@ -549,6 +549,486 @@ foo | } } + Context "When a nested multi-line pipeline is inside a pipelined script block" { + + It "Should preserve indentation with nested pipeline using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$Test | +ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should recover indentation after nested pipeline block using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +function foo { + $Test | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $thisLineShouldBeAtOneIndent +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle multiple sequential nested pipeline blocks using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +function foo { + $a | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + $stillCorrect +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle inner pipeline with 3+ elements using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 + } +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$Test | +ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$Test | + ForEach-Object { + Get-Process | + Where-Object Path | + Select-Object -Last 1 +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle outer pipeline on same line as command using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | + Select-Object -Last 1 +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle deeply nested pipelines (3 levels) using " -TestCases @( + @{ + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IdempotentScriptDefinition = @' +$a | + ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + } +'@ + } + @{ + PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' + IdempotentScriptDefinition = @' +$a | + ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } + } +'@ + } + @{ + PipelineIndentation = 'NoIndentation' + IdempotentScriptDefinition = @' +$a | +ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +} +'@ + } + @{ + PipelineIndentation = 'None' + IdempotentScriptDefinition = @' +$a | + ForEach-Object { + $b | + ForEach-Object { + Get-Process | + Select-Object -Last 1 + } +} +'@ + } + ) { + param ($PipelineIndentation, $IdempotentScriptDefinition) + + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + + It "Should handle single-line inner pipeline inside multi-line outer pipeline using " -TestCases @( + @{ PipelineIndentation = 'IncreaseIndentationForFirstPipeline' } + @{ PipelineIndentation = 'IncreaseIndentationAfterEveryPipeline' } + @{ PipelineIndentation = 'NoIndentation' } + @{ PipelineIndentation = 'None' } + ) { + param ($PipelineIndentation) + + $idempotentScriptDefinition = @' +$Test | ForEach-Object { + Get-Process | Select-Object -Last 1 +} +'@ + $settings.Rules.PSUseConsistentIndentation.PipelineIndentation = $PipelineIndentation + Invoke-Formatter -ScriptDefinition $IdempotentScriptDefinition -Settings $settings | Should -Be $IdempotentScriptDefinition + } + } + + Context "When multiple openers appear on the same line" { + It "Should not double-indent for paren-then-brace: .foreach({" { + $def = @' +@('a', 'b').foreach({ + $_.ToUpper() + }) +'@ + $expected = @' +@('a', 'b').foreach({ + $_.ToUpper() +}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should not double-indent for brace-then-paren: {(" { + $def = @' +@('a', 'b').foreach({( + $_.ToUpper() + )}) +'@ + $expected = @' +@('a', 'b').foreach({( + $_.ToUpper() +)}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should not double-indent for array-then-hashtable on same line: @(@{" { + $idempotentScriptDefinition = @' +$x = @(@{ + key = 'value' +}) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + + It "Should not double-indent when non-opener tokens separate openers: ([PSCustomObject]@{" { + $def = @' +$list.Add([PSCustomObject]@{ + Name = "Test" + Value = 123 + }) +'@ + $expected = @' +$list.Add([PSCustomObject]@{ + Name = "Test" + Value = 123 +}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should indent normally when all openers are closed on the same line" { + $idempotentScriptDefinition = @' +$list.Add([PSCustomObject]@{Name = "Test"; Value = 123}) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + + It "Should handle closing brace and paren on separate lines" { + $def = @' +@('a', 'b').foreach({ + $_.ToUpper() + } + ) +'@ + $expected = @' +@('a', 'b').foreach({ + $_.ToUpper() +} +) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should handle nested .foreach({ }) calls" { + $def = @' +@(1, 2).foreach({ +@('a', 'b').foreach({ +"$_ and $_" +}) +}) +'@ + $expected = @' +@(1, 2).foreach({ + @('a', 'b').foreach({ + "$_ and $_" + }) +}) +'@ + Invoke-Formatter -ScriptDefinition $def -Settings $settings | Should -Be $expected + } + + It "Should still indent each opener separately when on different lines" { + $idempotentScriptDefinition = @' +$x = @( + @{ + key = 'value' + } +) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + + It "Should still indent normally for sub-expressions" { + $idempotentScriptDefinition = @' +$( + Get-Process +) +'@ + Invoke-Formatter -ScriptDefinition $idempotentScriptDefinition -Settings $settings | Should -Be $idempotentScriptDefinition + } + } + Context "When tabs instead of spaces are used for indentation" { BeforeEach { $settings.Rules.PSUseConsistentIndentation.Kind = 'tab' diff --git a/docs/Rules/AlignAssignmentStatement.md b/docs/Rules/AlignAssignmentStatement.md index 28ca9f47f..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,14 +10,13 @@ title: AlignAssignmentStatement ## Description -Consecutive assignment statements are more readable when they're aligned. -Assignments are considered aligned when their `equals` signs line up vertically. +Consecutive assignment statements are more readable when they're aligned. Assignments are considered +aligned when their `equals` signs line up vertically. -This rule looks at the key-value pairs in hashtables (including DSC -configurations) as well as enum definitions. +This rule looks at the key-value pairs in hashtables (including DSC configurations) as well as enum +definitions. -Consider the following example which has a hashtable and enum which are not -aligned. +Consider the following example with a hashtable and enum that isn't aligned. ```powershell $hashtable = @{ @@ -45,8 +44,8 @@ enum Enum { } ``` -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}`. +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 @@ -71,15 +70,14 @@ Enable or disable the rule during ScriptAnalyzer invocation. #### CheckHashtable: bool (Default value is `$true`) -Enforce alignment of assignment statements in a hashtable and in a DSC -Configuration. There is only 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. +Enforce alignment of assignment statements in a hashtable and in a DSC Configuration. There is only +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. +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: @@ -91,8 +89,7 @@ $hashtable = @{ } ``` -With this setting disabled, the line with the comment is ignored, and it would -be aligned like so: +With this setting disabled, the line with the comment is ignored, and it would be aligned like so: ```powershell $hashtable = @{ @@ -118,8 +115,8 @@ 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. +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: @@ -131,8 +128,7 @@ enum Enum { } ``` -With this setting disabled, the line with the comment is ignored, and it would -be aligned like so: +With this setting disabled, the line with the comment is ignored, and it would be aligned like so: ```powershell enum Enum { @@ -154,9 +150,8 @@ enum Enum { #### IncludeValuelessEnumMembers: bool (Default value is `$true`) -Include enum members in the alignment that don't have an initial value - that -is to say they don't have an equals sign. Enum's don't need to be given a value -when they're defined. +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: @@ -168,8 +163,8 @@ enum Enum { } ``` -With this setting disabled the third line which has no value is not considered -when choosing where to align assignments. It would be aligned like so: +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 { @@ -179,8 +174,7 @@ enum Enum { } ``` -With it enabled, the valueless member is included in alignment as if it had a -value: +With it enabled, the valueless member is included in alignment as if it had a value: ```powershell enum Enum { diff --git a/docs/Rules/AvoidLongLines.md b/docs/Rules/AvoidLongLines.md index c36daaa86..89474c8b8 100644 --- a/docs/Rules/AvoidLongLines.md +++ b/docs/Rules/AvoidLongLines.md @@ -1,6 +1,6 @@ --- description: Avoid long lines -ms.date: 04/29/2025 +ms.date: 03/20/2026 ms.topic: reference title: AvoidLongLines --- @@ -10,8 +10,8 @@ title: AvoidLongLines ## Description -The length of lines, including leading spaces (indentation), should be less than the configured number -of characters. The default length is 120 characters. +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 isn't enabled by default. The user needs to enable it through settings. diff --git a/docs/Rules/README.md b/docs/Rules/README.md index 5df834708..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 --- diff --git a/docs/Rules/UseConsistentParameterSetName.md b/docs/Rules/UseConsistentParameterSetName.md index 5ad33eb22..6e9a4598f 100644 --- a/docs/Rules/UseConsistentParameterSetName.md +++ b/docs/Rules/UseConsistentParameterSetName.md @@ -1,6 +1,6 @@ --- description: Use consistent parameter set names and proper parameter set configuration. -ms.date: 08/19/2025 +ms.date: 03/20/2026 ms.topic: reference title: UseConsistentParameterSetName --- @@ -11,18 +11,25 @@ title: UseConsistentParameterSetName ## 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. +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 -2. **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. -3. **Case mismatch between DefaultParameterSetName and ParameterSetName** - Ensures consistent casing -4. **Case mismatch between different ParameterSetName values** - Ensures all references to the same parameter set use identical casing -5. **Parameter set names containing newlines** - Warns against using newline characters in parameter set names +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 is not enabled by default. The user needs to enable it through settings. +> This rule isn't enabled by default. The user needs to enable it through settings. ## How @@ -43,7 +50,7 @@ function Get-Data { param( [Parameter(ParameterSetName='ByName')] [string]$Name, - + [Parameter(ParameterSetName='ByID')] [int]$ID ) @@ -55,7 +62,7 @@ function Get-Data { param( [Parameter(ParameterSetName='byname')] [string]$Name, - + [Parameter(ParameterSetName='ByID')] [int]$ID ) @@ -67,7 +74,7 @@ function Get-Data { param( [Parameter(ParameterSetName='ByName')] [string]$Name, - + [Parameter(ParameterSetName='byname')] [string]$DisplayName ) @@ -100,11 +107,11 @@ function Get-Data { param( [Parameter(ParameterSetName='ByName', Mandatory)] [string]$Name, - + [Parameter(ParameterSetName='ByName')] [Parameter(ParameterSetName='ByID')] [string]$ComputerName, - + [Parameter(ParameterSetName='ByID', Mandatory)] [int]$ID ) @@ -129,7 +136,9 @@ Rules = @{ ## Notes -- Parameter set names are case-sensitive in PowerShell, making this different from most other PowerShell elements +- 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 +- 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 index 04a323b3d..20a470f30 100644 --- a/docs/Rules/UseConsistentParametersKind.md +++ b/docs/Rules/UseConsistentParametersKind.md @@ -1,26 +1,36 @@ +--- +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 have same parameters definition kind specified in the rule. -Possible kinds are: -1. `Inline`, i.e.: -```PowerShell -function f([Parameter()]$FirstParam) { - return -} -``` -2. `ParamBlock`, i.e.: -```PowerShell -function f { - param([Parameter()]$FirstParam) - return -} -``` +All functions should use the same pattern when defining parameters. Possible pattern types are: -* For information: in simple scenarios both function definitions above may be considered as equal. Using this rule as-is is more for consistent code-style than functional, but it can be useful in combination with other rules. +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 @@ -28,8 +38,9 @@ Rewrite function so it defines parameters as specified in the rule ## Example -### When the rule sets parameters definition kind to 'Inline': -```PowerShell +When the rule sets parameters definition kind to `Inline`: + +```powershell # Correct function f([Parameter()]$FirstParam) { return @@ -42,9 +53,10 @@ function g { } ``` -### When the rule sets parameters definition kind to 'ParamBlock': -```PowerShell -# Inorrect +When the rule sets parameters definition kind to `ParamBlock`: + +```powershell +# Incorrect function f([Parameter()]$FirstParam) { return } diff --git a/docs/Rules/UseConstrainedLanguageMode.md b/docs/Rules/UseConstrainedLanguageMode.md index 50ccfd0e2..e12476531 100644 --- a/docs/Rules/UseConstrainedLanguageMode.md +++ b/docs/Rules/UseConstrainedLanguageMode.md @@ -1,6 +1,6 @@ --- description: Use patterns compatible with Constrained Language Mode -ms.date: 03/17/2026 +ms.date: 03/20/2026 ms.topic: reference title: UseConstrainedLanguageMode --- @@ -10,22 +10,31 @@ title: UseConstrainedLanguageMode ## Description -This rule identifies PowerShell patterns that are restricted or not permitted in Constrained Language Mode (CLM). +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 -**Signed Script Behavior**: 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. +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. +> [!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 @@ -34,24 +43,26 @@ CLM is commonly used in: The following are flagged for unsigned scripts: 1. **Add-Type** - Code compilation not permitted -2. **Disallowed COM Objects** - Only Scripting.Dictionary, Scripting.FileSystemObject, VBScript.RegExp allowed -3. **Disallowed .NET Types** - Only ~70 allowed types (string, int, hashtable, pscredential, etc.) -4. **Type Constraints** - On parameters and variables -5. **Type Expressions** - Static type references like `[Type]::Method()` -6. **Type Casts** - Converting to disallowed types -7. **Member Invocations** - Methods/properties on disallowed types -8. **PowerShell Classes** - `class` keyword not permitted -9. **XAML/WPF** - Not permitted -10. **Invoke-Expression** - Restricted -11. **Dot-Sourcing** - May be restricted depending on the file being sourced -12. **Module Manifest Wildcards** - Wildcard exports not recommended -13. **Module Manifest .ps1 Files** - Script modules ending with .ps1 not allowed +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) @@ -75,14 +86,17 @@ For scripts with signature blocks, only these are checked: #### 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. +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. +- `$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 @{ @@ -95,7 +109,8 @@ Control signature detection behavior: } ``` -**Use `IgnoreSignatures = $true` when:** +Use `IgnoreSignatures = $true` when: + - Auditing signed scripts for complete CLM compatibility - Preparing scripts for untrusted environments - Enforcing strict CLM compliance organization-wide @@ -109,17 +124,20 @@ 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. +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. +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. +> [!IMPORTANT] +> `[PSCustomObject]@{}` syntax is NOT allowed in CLM because it uses type casting. ### Avoid XAML @@ -135,9 +153,9 @@ 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 as it loads in the caller's scope and will be blocked. +- 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 @@ -219,7 +237,7 @@ function Process-Text { ```powershell class MyClass { [string]$Name - + [string]GetInfo() { return $this.Name } @@ -300,12 +318,15 @@ param([hashtable[]]$Configuration) ## Detailed Restrictions ### 1. Add-Type -`Add-Type` allows compiling arbitrary C# code and is not permitted in CLM. + +`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` @@ -315,7 +336,9 @@ 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` @@ -323,6 +346,7 @@ Only ~70 allowed types including: - 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 @@ -333,6 +357,7 @@ The rule checks type usage in: **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. @@ -340,16 +365,19 @@ The `class` keyword is not permitted. Use `New-Object PSObject` with `Add-Member **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) @@ -357,6 +385,7 @@ Dot-sourcing (`. $PSScriptRoot\script.ps1`) may be restricted depending on sourc ### 8. Module Manifest Best Practices #### Wildcard Exports + Don't use `*` in: `FunctionsToExport`, `CmdletsToExport`, `AliasesToExport`, `VariablesToExport` Use explicit lists for security and clarity. @@ -364,6 +393,7 @@ 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. @@ -372,7 +402,13 @@ Use `.psm1` (script modules) or `.dll` (binary modules) for better performance a ## More Information -- [About Language Modes](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_language_modes) -- [PowerShell Constrained Language Mode](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode/) -- [PowerShell Module Function Export in Constrained Language](https://devblogs.microsoft.com/powershell/powershell-module-function-export-in-constrained-language/) -- [PowerShell Constrained Language Mode and the Dot-Source Operator](https://devblogs.microsoft.com/powershell/powershell-constrained-language-mode-and-the-dot-source-operator/) +- [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/UseSingleValueFromPipelineParameter.md b/docs/Rules/UseSingleValueFromPipelineParameter.md index bfaa3fe6a..92c350eb4 100644 --- a/docs/Rules/UseSingleValueFromPipelineParameter.md +++ b/docs/Rules/UseSingleValueFromPipelineParameter.md @@ -1,6 +1,6 @@ --- description: Use at most a single ValueFromPipeline parameter per parameter set. -ms.date: 08/08/2025 +ms.date: 03/20/2026 ms.topic: reference title: UseSingleValueFromPipelineParameter --- @@ -10,18 +10,15 @@ title: UseSingleValueFromPipelineParameter ## Description -Parameter sets should have at most one parameter marked as -`ValueFromPipeline = true`. +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). +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. +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 @@ -33,11 +30,11 @@ function Process-Data { param( [Parameter(ValueFromPipeline)] [string] $InputData, - + [Parameter(ValueFromPipeline)] [string] $ProcessingMode ) - + process { Write-Output "$ProcessingMode`: $InputData" } @@ -53,7 +50,7 @@ function Process-Data { param( [Parameter(ValueFromPipeline)] [string] $InputData, - + [Parameter(Mandatory)] [string] $ProcessingMode ) @@ -62,10 +59,11 @@ function Process-Data { } } ``` + ## Suppression -To suppress this rule for a specific parameter set, use the `SuppressMessage` -attribute with the parameter set name: +To suppress this rule for a specific parameter set, use the `SuppressMessage` attribute with the +parameter set name: ```powershell function Process-Data { @@ -74,7 +72,7 @@ function Process-Data { param( [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] [string] $InputData, - + [Parameter(ValueFromPipeline, ParameterSetName='MyParameterSet')] [string] $ProcessingMode ) @@ -92,10 +90,10 @@ For the default parameter set, use `'default'` as the suppression target: ## Notes -- This rule applies to both explicit `ValueFromPipeline = $true` and implicit - `ValueFromPipeline` (which is the same as using `= $true`) +- 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 +- 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