From 784620ad1bebf8e19e8e1df5eb24834d2b48b93d Mon Sep 17 00:00:00 2001 From: bergmeister <9250262+bergmeister@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:25:42 +0000 Subject: [PATCH 01/27] Distinguish script-defined functions from DSC resources Resolve visible function declarations from the AST when classifying DSC resource syntax and checking implicit Get-prefix aliases. Preserve analysis of executable function arguments for both brace layouts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Rules/AvoidAlias.cs | 41 +++++++++++---- Tests/Rules/AvoidUsingAlias.tests.ps1 | 74 +++++++++++++++++++++++++++ docs/Rules/AvoidUsingCmdletAliases.md | 4 ++ 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index c6435833d..6e91484e0 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -140,7 +140,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) } // If we find match of any kind, do not continue with the Get-{commandname} check - if ( Helper.Instance.GetCommandInfo(commandName) != null ) { + if (Helper.Instance.GetCommandInfo(commandName) != null || IsScriptDefinedFunction(cmdAst)) { continue; } @@ -214,7 +214,8 @@ private bool IsDscResourceDeclaration(CommandAst cmdAst) } } - if (!hasConfigurationAncestor || GetDscResourceBody(cmdAst) == null) + if (!hasConfigurationAncestor || GetDscResourceBody(cmdAst) == null + || !IsDscResourceCommand(cmdAst)) { return false; } @@ -249,9 +250,7 @@ private bool IsDscResourceDeclaration(CommandAst cmdAst) if (scriptBlock.Parent is CommandAst enclosingCommand) { if (GetDscResourceBody(enclosingCommand) != scriptBlock - || !String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(enclosingCommand.GetCommandName())) - || (!String.Equals(enclosingCommand.GetCommandName(), "Node", StringComparison.OrdinalIgnoreCase) - && Helper.Instance.GetCommandInfo(enclosingCommand.GetCommandName()) != null)) + || !IsDscResourceCommand(enclosingCommand)) { return false; } @@ -297,11 +296,35 @@ private bool IsFollowingLineDscResourceBody(ScriptBlockExpressionAst body) return false; } - string declarationCommandName = declaration.GetCommandName(); return GetDscResourceBody(declaration) == body - && String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(declarationCommandName)) - && (String.Equals(declarationCommandName, "Node", StringComparison.OrdinalIgnoreCase) - || Helper.Instance.GetCommandInfo(declarationCommandName) == null); + && IsDscResourceCommand(declaration); + } + + return false; + } + + private bool IsDscResourceCommand(CommandAst cmdAst) + { + string commandName = cmdAst.GetCommandName(); + return !IsScriptDefinedFunction(cmdAst) + && String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(commandName)) + && (String.Equals(commandName, "Node", StringComparison.OrdinalIgnoreCase) + || Helper.Instance.GetCommandInfo(commandName) == null); + } + + private bool IsScriptDefinedFunction(CommandAst cmdAst) + { + string commandName = cmdAst.GetCommandName(); + // Command discovery runs in a separate runspace and cannot see script-local functions. + for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst scope + && scope.Find(candidate => candidate is FunctionDefinitionAst function + && String.Equals(function.Name, commandName, StringComparison.OrdinalIgnoreCase), + searchNestedScriptBlocks: false) != null) + { + return true; + } } return false; diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 814574525..961ea75ea 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -63,6 +63,17 @@ gci -Path C:\ $noViolations.Count | Should -Be 0 } + It "does not add the Get prefix to script-defined functions or skip their script block arguments" { + $target = 'function vErB {}; Verb Instance { ChildItem . {} }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'ChildItem' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('ChildItem', 'Get-ChildItem')) + } + It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { $target = @' Configuration MyDscConfiguration { @@ -97,6 +108,69 @@ Configuration PackageTest { } Context "DSC resource ASTs" { + It "recognizes functions declared in the scope as executable commands" -TestCases @( + @{ Scope = 'script' } + @{ Scope = 'configuration' } + ) { + param($Scope) + + $scriptFunction = if ($Scope -eq 'script') { 'function vErB {}' } + $configurationFunction = if ($Scope -eq 'configuration') { 'function vErB {}' } + $target = @" +$scriptFunction +{ + $configurationFunction + Verb SameLine { ChildItem . {} } + Verb FollowingLine + { + ChildItem . {} + } + Package PackageExample {} +} +"@ + $ast = [System.Management.Automation.Language.Parser]::ParseInput($target, [ref]$null, [ref]$null) + $body = $ast.EndBlock.Statements[-1].PipelineElements[0].Expression.Copy() + $configuration = [System.Management.Automation.Language.ConfigurationDefinitionAst]::new( + $ast.Extent, $body, 'Resource', + [System.Management.Automation.Language.StringConstantExpressionAst]::new($ast.Extent, 'Test', 'BareWord')) + [System.Management.Automation.Language.StatementAst[]]$statements = if ($Scope -eq 'script') { + @($ast.EndBlock.Statements[0].Copy(), $configuration) + } else { + @($configuration) + } + $scriptAst = [System.Management.Automation.Language.ScriptBlockAst]::new( + $ast.Extent, $null, + [System.Management.Automation.Language.StatementBlockAst]::new($ast.Extent, $statements, $null), $false) + $rule = [Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.AvoidAlias]::new() + + $violations = @($rule.AnalyzeScript($scriptAst, $null)) + + $violations.Count | Should -Be 2 + $violations | ForEach-Object { + $_.Extent.Text | Should -Be 'ChildItem' + $_.SuggestedCorrections[0].Text | Should -Be 'Get-ChildItem' + } + } + + It "does not treat functions declared in unrelated scopes as executable commands" { + $target = @' +{ + function Get-Example { + function Verb {} + } + Verb ResourceExample { ChildItem NestedResource {} } +} +'@ + $ast = [System.Management.Automation.Language.Parser]::ParseInput($target, [ref]$null, [ref]$null) + $body = $ast.EndBlock.Statements[0].PipelineElements[0].Expression.Copy() + $configuration = [System.Management.Automation.Language.ConfigurationDefinitionAst]::new( + $ast.Extent, $body, 'Resource', + [System.Management.Automation.Language.StringConstantExpressionAst]::new($ast.Extent, 'Test', 'BareWord')) + $rule = [Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.AvoidAlias]::new() + + @($rule.AnalyzeScript($configuration, $null)).Count | Should -Be 0 + } + It "does not expand nested resource names in a same-line Node declaration when node is discoverable" -skip:(!(Get-Command node -CommandType Application -ErrorAction Ignore)) { $target = @' { diff --git a/docs/Rules/AvoidUsingCmdletAliases.md b/docs/Rules/AvoidUsingCmdletAliases.md index 2204a2190..b73a7c1ee 100644 --- a/docs/Rules/AvoidUsingCmdletAliases.md +++ b/docs/Rules/AvoidUsingCmdletAliases.md @@ -20,6 +20,10 @@ name, doing so reduces code readability and maintainability. PowerShell also supports implicit aliases. When a cmdlet name isn't found, PowerShell appends `Get-` to the command as a fallback. For example, typing `verb` executes `Get-Verb`. +Functions declared in the script are not implicit aliases. In DSC configurations, resource +declarations are also excluded from implicit alias correction, while executable script blocks +passed to script-defined functions are still analyzed. + Aliases create inconsistency across codebases because different authors learn and use different aliases. Aliases can potentially make code difficult to read and understand in collaborative environments. It can also cause issues with script portability and availability. From 8f6c37e63f3a297632256d17ccafbd10ae434999 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:14:03 +0000 Subject: [PATCH 02/27] Index scoped functions in alias analysis Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 124 ++++++++++++++++++++++---- Tests/Rules/AvoidUsingAlias.tests.ps1 | 13 ++- 2 files changed, 118 insertions(+), 19 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 6e91484e0..c3b00b109 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -97,6 +97,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) } // Finds all CommandAsts. IEnumerable foundAsts = ast.FindAll(testAst => testAst is CommandAst, true); + Dictionary> functionNameIndex = GetFunctionNameIndex(ast); // Iterates all CommandAsts and check the command name. foreach (CommandAst cmdAst in foundAsts) @@ -119,7 +120,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) } string cmdletNameIfCommandNameWasAlias = Helper.Instance.GetCmdletNameFromAlias(commandName); - bool isDscResourceDeclaration = IsDscResourceDeclaration(cmdAst); + bool isDscResourceDeclaration = IsDscResourceDeclaration(cmdAst, functionNameIndex); if (isDscResourceDeclaration && String.IsNullOrEmpty(cmdletNameIfCommandNameWasAlias)) { continue; @@ -140,7 +141,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) } // If we find match of any kind, do not continue with the Get-{commandname} check - if (Helper.Instance.GetCommandInfo(commandName) != null || IsScriptDefinedFunction(cmdAst)) { + if (Helper.Instance.GetCommandInfo(commandName) != null || IsScriptDefinedFunction(cmdAst, functionNameIndex)) { continue; } @@ -192,7 +193,9 @@ private bool IgnoreCommandast(CommandAst cmdAst) return false; } - private bool IsDscResourceDeclaration(CommandAst cmdAst) + private bool IsDscResourceDeclaration( + CommandAst cmdAst, + Dictionary> functionNameIndex) { bool hasConfigurationAncestor = false; for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) @@ -215,7 +218,7 @@ private bool IsDscResourceDeclaration(CommandAst cmdAst) } if (!hasConfigurationAncestor || GetDscResourceBody(cmdAst) == null - || !IsDscResourceCommand(cmdAst)) + || !IsDscResourceCommand(cmdAst, functionNameIndex)) { return false; } @@ -239,7 +242,7 @@ private bool IsDscResourceDeclaration(CommandAst cmdAst) if (parent is CommandExpressionAst commandExpression && (!(commandExpression.Expression is ScriptBlockExpressionAst body) - || !IsFollowingLineDscResourceBody(body))) + || !IsFollowingLineDscResourceBody(body, functionNameIndex))) { return false; } @@ -250,7 +253,7 @@ private bool IsDscResourceDeclaration(CommandAst cmdAst) if (scriptBlock.Parent is CommandAst enclosingCommand) { if (GetDscResourceBody(enclosingCommand) != scriptBlock - || !IsDscResourceCommand(enclosingCommand)) + || !IsDscResourceCommand(enclosingCommand, functionNameIndex)) { return false; } @@ -267,7 +270,9 @@ private bool IsDscResourceDeclaration(CommandAst cmdAst) return false; } - private bool IsFollowingLineDscResourceBody(ScriptBlockExpressionAst body) + private bool IsFollowingLineDscResourceBody( + ScriptBlockExpressionAst body, + Dictionary> functionNameIndex) { if (!(body.Parent is CommandExpressionAst bodyExpression) || !(bodyExpression.Parent is PipelineAst bodyPipeline) @@ -297,39 +302,124 @@ private bool IsFollowingLineDscResourceBody(ScriptBlockExpressionAst body) } return GetDscResourceBody(declaration) == body - && IsDscResourceCommand(declaration); + && IsDscResourceCommand(declaration, functionNameIndex); } return false; } - private bool IsDscResourceCommand(CommandAst cmdAst) + private bool IsDscResourceCommand( + CommandAst cmdAst, + Dictionary> functionNameIndex) { string commandName = cmdAst.GetCommandName(); - return !IsScriptDefinedFunction(cmdAst) + return !IsScriptDefinedFunction(cmdAst, functionNameIndex) && String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(commandName)) && (String.Equals(commandName, "Node", StringComparison.OrdinalIgnoreCase) || Helper.Instance.GetCommandInfo(commandName) == null); } - private bool IsScriptDefinedFunction(CommandAst cmdAst) + private bool IsScriptDefinedFunction( + CommandAst cmdAst, + Dictionary> functionNameIndex) { string commandName = cmdAst.GetCommandName(); - // Command discovery runs in a separate runspace and cannot see script-local functions. + ScriptBlockAst currentScope = null; for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) { - if (parent is ScriptBlockAst scope - && scope.Find(candidate => candidate is FunctionDefinitionAst function - && String.Equals(function.Name, commandName, StringComparison.OrdinalIgnoreCase), - searchNestedScriptBlocks: false) != null) + if (parent is ScriptBlockAst scope) { - return true; + if (currentScope == null) + { + currentScope = scope; + } + + if (functionNameIndex.TryGetValue(scope, out Dictionary functionNames) + && functionNames.TryGetValue(commandName, out bool isPrivate) + && (!isPrivate || currentScope == scope)) + { + return true; + } } } return false; } + private Dictionary> GetFunctionNameIndex(Ast ast) + { + var functionNameIndex = new Dictionary>(); + IEnumerable functionDefinitions = ast.FindAll( + candidate => candidate is FunctionDefinitionAst, + true).OfType(); + + foreach (FunctionDefinitionAst function in functionDefinitions) + { + string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name); + if (String.IsNullOrEmpty(functionName)) + { + continue; + } + + ScriptBlockAst scope = GetFunctionScope(function); + if (scope == null) + { + continue; + } + + bool isPrivate = function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase); + if (function.Name.StartsWith("script:", StringComparison.OrdinalIgnoreCase) + || function.Name.StartsWith("global:", StringComparison.OrdinalIgnoreCase)) + { + scope = GetRootScriptBlock(scope); + } + + if (!functionNameIndex.TryGetValue(scope, out Dictionary functionNames)) + { + functionNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + functionNameIndex.Add(scope, functionNames); + } + + if (functionNames.TryGetValue(functionName, out bool existingIsPrivate)) + { + functionNames[functionName] = existingIsPrivate && isPrivate; + } + else + { + functionNames.Add(functionName, isPrivate); + } + } + + return functionNameIndex; + } + + private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) + { + for (Ast parent = function.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst scope) + { + return scope; + } + } + + return null; + } + + private ScriptBlockAst GetRootScriptBlock(ScriptBlockAst scope) + { + ScriptBlockAst rootScope = scope; + for (Ast parent = scope.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst ancestor) + { + rootScope = ancestor; + } + } + + return rootScope; + } + private ScriptBlockExpressionAst GetDscResourceBody(CommandAst cmdAst) { if (cmdAst.InvocationOperator != TokenKind.Unknown diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 961ea75ea..01a5b49ef 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -64,7 +64,7 @@ gci -Path C:\ } It "does not add the Get prefix to script-defined functions or skip their script block arguments" { - $target = 'function vErB {}; Verb Instance { ChildItem . {} }' + $target = 'function script:vErB {}; Verb Instance { ChildItem . {} }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) $violations.Count | Should -Be 1 @@ -74,6 +74,15 @@ gci -Path C:\ Should -Be ($target.Replace('ChildItem', 'Get-ChildItem')) } + It "respects private function visibility" { + $target = 'function private:Verb {}; Verb; & { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { $target = @' Configuration MyDscConfiguration { @@ -114,7 +123,7 @@ Configuration PackageTest { ) { param($Scope) - $scriptFunction = if ($Scope -eq 'script') { 'function vErB {}' } + $scriptFunction = if ($Scope -eq 'script') { 'function script:vErB {}' } $configurationFunction = if ($Scope -eq 'configuration') { 'function vErB {}' } $target = @" $scriptFunction From a6b0cd18d6881ce3f316da85b3c951337908db3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:41:59 +0000 Subject: [PATCH 03/27] Fix scoped function visibility analysis Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 25 ++++--------------------- Tests/Rules/AvoidUsingAlias.tests.ps1 | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index c3b00b109..f85688f87 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -329,7 +329,10 @@ private bool IsScriptDefinedFunction( { if (parent is ScriptBlockAst scope) { - if (currentScope == null) + if (currentScope == null + && !(scope.Parent is ScriptBlockExpressionAst scriptBlock + && scriptBlock.Parent is CommandAst dotSource + && dotSource.InvocationOperator == TokenKind.Dot)) { currentScope = scope; } @@ -368,12 +371,6 @@ private Dictionary> GetFunctionNameInde } bool isPrivate = function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase); - if (function.Name.StartsWith("script:", StringComparison.OrdinalIgnoreCase) - || function.Name.StartsWith("global:", StringComparison.OrdinalIgnoreCase)) - { - scope = GetRootScriptBlock(scope); - } - if (!functionNameIndex.TryGetValue(scope, out Dictionary functionNames)) { functionNames = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -406,20 +403,6 @@ private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) return null; } - private ScriptBlockAst GetRootScriptBlock(ScriptBlockAst scope) - { - ScriptBlockAst rootScope = scope; - for (Ast parent = scope.Parent; parent != null; parent = parent.Parent) - { - if (parent is ScriptBlockAst ancestor) - { - rootScope = ancestor; - } - } - - return rootScope; - } - private ScriptBlockExpressionAst GetDscResourceBody(CommandAst cmdAst) { if (cmdAst.InvocationOperator != TokenKind.Unknown diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 01a5b49ef..b31ebc214 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -75,12 +75,15 @@ gci -Path C:\ } It "respects private function visibility" { - $target = 'function private:Verb {}; Verb; & { Verb }' + $target = 'function private:Verb {}; Verb; . { Verb }; & { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) $violations.Count | Should -Be 1 $violations[0].Extent.Text | Should -Be 'Verb' $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('& { Verb }', '& { Get-Verb }')) } It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { @@ -161,15 +164,21 @@ $scriptFunction } } - It "does not treat functions declared in unrelated scopes as executable commands" { - $target = @' + It "does not treat functions declared in unrelated scopes as executable commands" -TestCases @( + @{ FunctionName = 'Verb' } + @{ FunctionName = 'script:Verb' } + @{ FunctionName = 'global:Verb' } + ) { + param($FunctionName) + + $target = @" { function Get-Example { - function Verb {} + function $FunctionName {} } Verb ResourceExample { ChildItem NestedResource {} } } -'@ +"@ $ast = [System.Management.Automation.Language.Parser]::ParseInput($target, [ref]$null, [ref]$null) $body = $ast.EndBlock.Statements[0].PipelineElements[0].Expression.Copy() $configuration = [System.Management.Automation.Language.ConfigurationDefinitionAst]::new( From d5580963a2c55cadfbdc48054d7a55bf8faf5fc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:35:38 +0000 Subject: [PATCH 04/27] Fix dot-sourced function scope indexing Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 16 +++++++++++----- Tests/Rules/AvoidUsingAlias.tests.ps1 | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index f85688f87..66cf9565e 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -329,10 +329,7 @@ private bool IsScriptDefinedFunction( { if (parent is ScriptBlockAst scope) { - if (currentScope == null - && !(scope.Parent is ScriptBlockExpressionAst scriptBlock - && scriptBlock.Parent is CommandAst dotSource - && dotSource.InvocationOperator == TokenKind.Dot)) + if (currentScope == null && !IsDotSourcedScriptBlock(scope)) { currentScope = scope; } @@ -394,7 +391,7 @@ private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) { for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { - if (parent is ScriptBlockAst scope) + if (parent is ScriptBlockAst scope && !IsDotSourcedScriptBlock(scope)) { return scope; } @@ -403,6 +400,15 @@ private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) return null; } + private bool IsDotSourcedScriptBlock(ScriptBlockAst scope) + { + return scope.Parent is ScriptBlockExpressionAst scriptBlock + && scriptBlock.Parent is CommandAst dotSource + && dotSource.InvocationOperator == TokenKind.Dot + && dotSource.CommandElements.Count == 1 + && dotSource.CommandElements[0] == scriptBlock; + } + private ScriptBlockExpressionAst GetDscResourceBody(CommandAst cmdAst) { if (cmdAst.InvocationOperator != TokenKind.Unknown diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index b31ebc214..bb1d2e135 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -86,6 +86,24 @@ gci -Path C:\ Should -Be ($target.Replace('& { Verb }', '& { Get-Verb }')) } + It "does not apply caller scope to script-block arguments of dot-invoked commands" { + $target = 'function private:Verb {}; . Invoke-Command -ScriptBlock { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "indexes declarations in directly dot-sourced blocks in the caller scope" { + $target = '. { function private:Verb {}; Verb }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { $target = @' Configuration MyDscConfiguration { From 82b986e18a0ec2f4cf49979573b8fa6ccff34e4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:10:34 +0000 Subject: [PATCH 05/27] Fix parameterized dot-source scope handling Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 1 - Tests/Rules/AvoidUsingAlias.tests.ps1 | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 66cf9565e..7a8af88be 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -405,7 +405,6 @@ private bool IsDotSourcedScriptBlock(ScriptBlockAst scope) return scope.Parent is ScriptBlockExpressionAst scriptBlock && scriptBlock.Parent is CommandAst dotSource && dotSource.InvocationOperator == TokenKind.Dot - && dotSource.CommandElements.Count == 1 && dotSource.CommandElements[0] == scriptBlock; } diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index bb1d2e135..c43e5a665 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -104,6 +104,24 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "uses caller scope for parameterized dot-sourced blocks" { + $target = 'function private:Verb {}; . { param($value) Verb } 1' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + + It "indexes declarations in parameterized dot-sourced blocks in the caller scope" { + $target = '. { param($value) function private:Verb {}; Verb } 1; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { $target = @' Configuration MyDscConfiguration { From a06a9a2a19071188611c6a424e98b29ae5a0bff5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:37:52 +0000 Subject: [PATCH 06/27] Handle callback and unreachable function scopes Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 106 ++++++++++++++++++++++++-- Tests/Rules/AvoidUsingAlias.tests.ps1 | 48 ++++++++++++ 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 7a8af88be..bb41be929 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -329,7 +329,7 @@ private bool IsScriptDefinedFunction( { if (parent is ScriptBlockAst scope) { - if (currentScope == null && !IsDotSourcedScriptBlock(scope)) + if (currentScope == null && !IsCallerScopedScriptBlock(scope)) { currentScope = scope; } @@ -355,6 +355,11 @@ private Dictionary> GetFunctionNameInde foreach (FunctionDefinitionAst function in functionDefinitions) { + if (IsInConstantFalseBranch(function)) + { + continue; + } + string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name); if (String.IsNullOrEmpty(functionName)) { @@ -391,7 +396,7 @@ private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) { for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { - if (parent is ScriptBlockAst scope && !IsDotSourcedScriptBlock(scope)) + if (parent is ScriptBlockAst scope && !IsCallerScopedScriptBlock(scope)) { return scope; } @@ -400,12 +405,99 @@ private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) return null; } - private bool IsDotSourcedScriptBlock(ScriptBlockAst scope) + private bool IsCallerScopedScriptBlock(ScriptBlockAst scope) { - return scope.Parent is ScriptBlockExpressionAst scriptBlock - && scriptBlock.Parent is CommandAst dotSource - && dotSource.InvocationOperator == TokenKind.Dot - && dotSource.CommandElements[0] == scriptBlock; + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock) + || !(scriptBlock.Parent is CommandAst commandAst)) + { + return false; + } + + if (commandAst.InvocationOperator == TokenKind.Dot + && commandAst.CommandElements[0] == scriptBlock) + { + return true; + } + + string commandName = commandAst.GetCommandName(); + if (commandName == null) + { + return false; + } + + string cmdletName = Helper.Instance.GetCmdletNameFromAlias(commandName); + if (!String.IsNullOrEmpty(cmdletName)) + { + commandName = cmdletName; + } + + if (!String.Equals(commandName, "ForEach-Object", StringComparison.OrdinalIgnoreCase) + && !String.Equals(commandName, "Where-Object", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (String.Equals(commandName, "ForEach-Object", StringComparison.OrdinalIgnoreCase) + && commandAst.CommandElements.OfType() + .Any(parameter => parameter.ParameterName.Length > 0 + && "Parallel".StartsWith(parameter.ParameterName, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return commandAst.CommandElements.Contains(scriptBlock); + } + + private bool IsInConstantFalseBranch(FunctionDefinitionAst function) + { + for (Ast parent = function.Parent; parent != null; parent = parent.Parent) + { + if (!(parent is IfStatementAst ifStatement)) + { + continue; + } + + foreach (Tuple clause in ifStatement.Clauses) + { + if (IsDescendantOf(function, clause.Item2) && IsConstantFalseCondition(clause.Item1)) + { + return true; + } + } + } + + return false; + } + + private bool IsDescendantOf(Ast ast, Ast ancestor) + { + for (Ast parent = ast.Parent; parent != null; parent = parent.Parent) + { + if (parent == ancestor) + { + return true; + } + } + + return false; + } + + private bool IsConstantFalseCondition(PipelineBaseAst condition) + { + ExpressionAst expression = condition.GetPureExpression(); + if (expression == null) + { + return false; + } + + try + { + return !LanguagePrimitives.IsTrue(expression.SafeGetValue()); + } + catch (InvalidOperationException) + { + return false; + } } private ScriptBlockExpressionAst GetDscResourceBody(CommandAst cmdAst) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index c43e5a665..c5294433f 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -113,6 +113,37 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "respects caller scope in callbacks" -TestCases @( + @{ Command = 'ForEach-Object' } + @{ Command = 'Where-Object' } + ) { + param($Command) + + $target = "function private:Verb {}; 1 | $Command { Verb }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + + It "does not apply caller scope to ForEach-Object parallel callbacks" -skip:($PSVersionTable.PSVersion.Major -lt 7) { + $target = 'function private:Verb {}; 1 | ForEach-Object -Parallel { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "does not index functions in constant-false branches" { + $target = 'if ($false) { function Verb {} }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "indexes declarations in parameterized dot-sourced blocks in the caller scope" { $target = '. { param($value) function private:Verb {}; Verb } 1; Verb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -225,6 +256,23 @@ $scriptFunction @($rule.AnalyzeScript($configuration, $null)).Count | Should -Be 0 } + It "does not index functions in constant-false branches when identifying DSC resources" { + $target = @' +{ + if ($false) { function Verb {} } + Verb ResourceExample { ChildItem NestedResource {} } +} +'@ + $ast = [System.Management.Automation.Language.Parser]::ParseInput($target, [ref]$null, [ref]$null) + $body = $ast.EndBlock.Statements[0].PipelineElements[0].Expression.Copy() + $configuration = [System.Management.Automation.Language.ConfigurationDefinitionAst]::new( + $ast.Extent, $body, 'Resource', + [System.Management.Automation.Language.StringConstantExpressionAst]::new($ast.Extent, 'Test', 'BareWord')) + $rule = [Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.AvoidAlias]::new() + + @($rule.AnalyzeScript($configuration, $null)).Count | Should -Be 0 + } + It "does not expand nested resource names in a same-line Node declaration when node is discoverable" -skip:(!(Get-Command node -CommandType Application -ErrorAction Ignore)) { $target = @' { From 502b2a344f8f6457b315ec7e0b5e51ae0f142f25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:33:47 +0000 Subject: [PATCH 07/27] Fix callback scope classification Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 83 ++++++++++++++++++++++----- Tests/Rules/AvoidUsingAlias.tests.ps1 | 26 +++++++++ 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index bb41be929..281b25b95 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -340,6 +340,11 @@ private bool IsScriptDefinedFunction( { return true; } + + if (IsParallelRunspaceScriptBlock(scope)) + { + break; + } } } @@ -419,33 +424,85 @@ private bool IsCallerScopedScriptBlock(ScriptBlockAst scope) return true; } - string commandName = commandAst.GetCommandName(); + string commandName = GetCallbackCommandName(commandAst); if (commandName == null) { return false; } - string cmdletName = Helper.Instance.GetCmdletNameFromAlias(commandName); - if (!String.IsNullOrEmpty(cmdletName)) + CommandParameterAst parameter = GetScriptBlockParameter(commandAst, scriptBlock); + if (IsForEachObjectCommand(commandName)) { - commandName = cmdletName; + if (IsParameter(parameter, "Parallel")) + { + return false; + } + + return parameter == null + || IsParameter(parameter, "Begin") + || IsParameter(parameter, "Process") + || IsParameter(parameter, "End") + || IsParameter(parameter, "RemainingScripts"); } - if (!String.Equals(commandName, "ForEach-Object", StringComparison.OrdinalIgnoreCase) - && !String.Equals(commandName, "Where-Object", StringComparison.OrdinalIgnoreCase)) + return IsWhereObjectCommand(commandName) + && (parameter == null || IsParameter(parameter, "FilterScript")); + } + + private bool IsParallelRunspaceScriptBlock(ScriptBlockAst scope) + { + return scope.Parent is ScriptBlockExpressionAst scriptBlock + && scriptBlock.Parent is CommandAst commandAst + && IsForEachObjectCommand(GetCallbackCommandName(commandAst)) + && IsParameter(GetScriptBlockParameter(commandAst, scriptBlock), "Parallel"); + } + + private string GetCallbackCommandName(CommandAst commandAst) + { + string commandName = commandAst.GetCommandName(); + if (commandName == null) { - return false; + return null; } - if (String.Equals(commandName, "ForEach-Object", StringComparison.OrdinalIgnoreCase) - && commandAst.CommandElements.OfType() - .Any(parameter => parameter.ParameterName.Length > 0 - && "Parallel".StartsWith(parameter.ParameterName, StringComparison.OrdinalIgnoreCase))) + const string coreModulePrefix = "Microsoft.PowerShell.Core\\"; + if (commandName.StartsWith(coreModulePrefix, StringComparison.OrdinalIgnoreCase)) { - return false; + commandName = commandName.Substring(coreModulePrefix.Length); + } + else if (commandName.IndexOf('\\') >= 0) + { + return null; } - return commandAst.CommandElements.Contains(scriptBlock); + string cmdletName = Helper.Instance.GetCmdletNameFromAlias(commandName); + return String.IsNullOrEmpty(cmdletName) ? commandName : cmdletName; + } + + private CommandParameterAst GetScriptBlockParameter( + CommandAst commandAst, + ScriptBlockExpressionAst scriptBlock) + { + int scriptBlockIndex = commandAst.CommandElements.IndexOf(scriptBlock); + return scriptBlockIndex > 0 + ? commandAst.CommandElements[scriptBlockIndex - 1] as CommandParameterAst + : null; + } + + private bool IsParameter(CommandParameterAst parameter, string parameterName) + { + return parameter != null + && String.Equals(parameter.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase); + } + + private bool IsForEachObjectCommand(string commandName) + { + return String.Equals(commandName, "ForEach-Object", StringComparison.OrdinalIgnoreCase); + } + + private bool IsWhereObjectCommand(string commandName) + { + return String.Equals(commandName, "Where-Object", StringComparison.OrdinalIgnoreCase); } private bool IsInConstantFalseBranch(FunctionDefinitionAst function) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index c5294433f..83058897c 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -116,6 +116,8 @@ gci -Path C:\ It "respects caller scope in callbacks" -TestCases @( @{ Command = 'ForEach-Object' } @{ Command = 'Where-Object' } + @{ Command = 'Microsoft.PowerShell.Core\ForEach-Object' } + @{ Command = 'Microsoft.PowerShell.Core\Where-Object' } ) { param($Command) @@ -135,6 +137,30 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not find functions outside ForEach-Object parallel callbacks" -skip:($PSVersionTable.PSVersion.Major -lt 7) { + $target = 'function Verb {}; 1 | ForEach-Object -Parallel { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "recognizes functions declared in ForEach-Object parallel callbacks" -skip:($PSVersionTable.PSVersion.Major -lt 7) { + $target = '1 | ForEach-Object -Parallel { function Verb {}; Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + } + + It "does not index script blocks passed as ForEach-Object input data" { + $target = 'ForEach-Object -InputObject { function Verb {} } -Process { $_ }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "does not index functions in constant-false branches" { $target = 'if ($false) { function Verb {} }; Verb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From ca34be9211104c13f21ef5313e8068a1f9c61841 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:30:57 +0000 Subject: [PATCH 08/27] Fix callback scope binding Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 188 +++++++++++++++++++++++--- Tests/Rules/AvoidUsingAlias.tests.ps1 | 38 ++++++ 2 files changed, 206 insertions(+), 20 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 281b25b95..352a48dae 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -412,8 +412,13 @@ private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) private bool IsCallerScopedScriptBlock(ScriptBlockAst scope) { - if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock) - || !(scriptBlock.Parent is CommandAst commandAst)) + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + return false; + } + + CommandAst commandAst = GetScriptBlockCommand(scriptBlock); + if (commandAst == null) { return false; } @@ -430,31 +435,50 @@ private bool IsCallerScopedScriptBlock(ScriptBlockAst scope) return false; } - CommandParameterAst parameter = GetScriptBlockParameter(commandAst, scriptBlock); if (IsForEachObjectCommand(commandName)) { - if (IsParameter(parameter, "Parallel")) + string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); + if (IsParameter(parameterName, "Parallel")) { return false; } - return parameter == null - || IsParameter(parameter, "Begin") - || IsParameter(parameter, "Process") - || IsParameter(parameter, "End") - || IsParameter(parameter, "RemainingScripts"); + return IsParameter(parameterName, "Begin") + || IsParameter(parameterName, "Process") + || IsParameter(parameterName, "End") + || IsParameter(parameterName, "RemainingScripts"); } return IsWhereObjectCommand(commandName) - && (parameter == null || IsParameter(parameter, "FilterScript")); + && IsParameter(GetScriptBlockParameterName(commandAst, scriptBlock), "FilterScript"); } private bool IsParallelRunspaceScriptBlock(ScriptBlockAst scope) { - return scope.Parent is ScriptBlockExpressionAst scriptBlock - && scriptBlock.Parent is CommandAst commandAst + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + return false; + } + + CommandAst commandAst = GetScriptBlockCommand(scriptBlock); + return commandAst != null && IsForEachObjectCommand(GetCallbackCommandName(commandAst)) - && IsParameter(GetScriptBlockParameter(commandAst, scriptBlock), "Parallel"); + && IsParameter( + GetScriptBlockParameterName(commandAst, scriptBlock), + "Parallel"); + } + + private CommandAst GetScriptBlockCommand(ScriptBlockExpressionAst scriptBlock) + { + if (scriptBlock.Parent is CommandAst commandAst) + { + return commandAst; + } + + return scriptBlock.Parent is CommandParameterAst parameter + && parameter.Argument == scriptBlock + ? parameter.Parent as CommandAst + : null; } private string GetCallbackCommandName(CommandAst commandAst) @@ -479,20 +503,144 @@ private string GetCallbackCommandName(CommandAst commandAst) return String.IsNullOrEmpty(cmdletName) ? commandName : cmdletName; } - private CommandParameterAst GetScriptBlockParameter( + private string GetScriptBlockParameterName( CommandAst commandAst, ScriptBlockExpressionAst scriptBlock) { + CommandInfo commandInfo = Helper.Instance.GetCommandInfo(GetCallbackCommandName(commandAst)); + if (commandInfo == null) + { + return null; + } + + Dictionary parameters; + try + { + parameters = commandInfo.Parameters; + } + catch (InvalidOperationException) + { + parameters = Helper.Instance.GetCommandInfo(commandInfo.Name, bypassCache: true)?.Parameters; + if (parameters == null) + { + return null; + } + } + + CommandParameterAst attachedParameter = commandAst.CommandElements + .OfType() + .FirstOrDefault(parameter => parameter.Argument == scriptBlock); + if (attachedParameter != null) + { + return ResolveParameter(parameters, attachedParameter.ParameterName)?.Name; + } + int scriptBlockIndex = commandAst.CommandElements.IndexOf(scriptBlock); - return scriptBlockIndex > 0 - ? commandAst.CommandElements[scriptBlockIndex - 1] as CommandParameterAst - : null; + if (scriptBlockIndex < 0) + { + return null; + } + + int positionalIndex = 0; + for (int i = 1; i <= scriptBlockIndex; i++) + { + CommandElementAst commandElement = commandAst.CommandElements[i]; + if (i == scriptBlockIndex) + { + return GetPositionalScriptBlockParameter(parameters, positionalIndex)?.Name; + } + + if (!(commandElement is CommandParameterAst parameter)) + { + positionalIndex++; + continue; + } + + ParameterMetadata parameterMetadata = ResolveParameter(parameters, parameter.ParameterName); + if (parameter.Argument != null + || parameterMetadata?.ParameterType == typeof(SwitchParameter)) + { + continue; + } + + if (i + 1 == scriptBlockIndex) + { + return parameterMetadata?.Name; + } + + if (i + 1 < scriptBlockIndex + && !(commandAst.CommandElements[i + 1] is CommandParameterAst)) + { + i++; + } + } + + return null; + } + + private ParameterMetadata ResolveParameter( + Dictionary parameters, + string parameterName) + { + if (String.IsNullOrEmpty(parameterName)) + { + return null; + } + + ParameterMetadata exactMatch = null; + var prefixMatches = new List(); + foreach (ParameterMetadata parameter in parameters.Values) + { + if (String.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase) + || parameter.Aliases.Contains(parameterName, StringComparer.OrdinalIgnoreCase)) + { + exactMatch = parameter; + break; + } + + if (parameter.Name.StartsWith(parameterName, StringComparison.OrdinalIgnoreCase) + || parameter.Aliases.Any(alias => alias.StartsWith(parameterName, StringComparison.OrdinalIgnoreCase))) + { + prefixMatches.Add(parameter); + } + } + + return exactMatch ?? (prefixMatches.Count == 1 ? prefixMatches[0] : null); + } + + private ParameterMetadata GetPositionalScriptBlockParameter( + Dictionary parameters, + int position) + { + ParameterMetadata match = null; + foreach (ParameterMetadata parameter in parameters.Values) + { + Type parameterType = parameter.ParameterType; + if (parameterType.IsArray) + { + parameterType = parameterType.GetElementType(); + } + + if (!typeof(ScriptBlock).IsAssignableFrom(parameterType) + || !parameter.Attributes.OfType().Any(attribute => attribute.Position == position)) + { + continue; + } + + if (match != null) + { + return null; + } + + match = parameter; + } + + return match; } - private bool IsParameter(CommandParameterAst parameter, string parameterName) + private bool IsParameter(string parameter, string parameterName) { - return parameter != null - && String.Equals(parameter.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase); + return String.Equals(parameter, parameterName, StringComparison.OrdinalIgnoreCase); } private bool IsForEachObjectCommand(string commandName) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 83058897c..020c751f1 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -129,6 +129,34 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "respects caller scope for colon-attached callback arguments" { + foreach ($command in @( + 'ForEach-Object -Process:{ Verb }' + 'Where-Object -FilterScript:{ Verb }' + )) { + $target = "function private:Verb {}; 1 | $command" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + } + + It "uses the bound callback parameter after switches and abbreviated parameters" { + $target = 'function private:Verb {}; 1 | ForEach-Object -Verbose { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + + $target = 'function private:Verb {}; 1 | ForEach-Object -Proc { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "does not apply caller scope to ForEach-Object parallel callbacks" -skip:($PSVersionTable.PSVersion.Major -lt 7) { $target = 'function private:Verb {}; 1 | ForEach-Object -Parallel { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -145,6 +173,16 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "resolves abbreviated and colon-attached parallel callback parameters" -skip:($PSVersionTable.PSVersion.Major -lt 7) { + foreach ($parameter in @('-Pa { Verb }', '-Pa:{ Verb }')) { + $target = "function Verb {}; 1 | ForEach-Object $parameter" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + } + It "recognizes functions declared in ForEach-Object parallel callbacks" -skip:($PSVersionTable.PSVersion.Major -lt 7) { $target = '1 | ForEach-Object -Parallel { function Verb {}; Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From f9af9d5dbe2221972fbca8b44b1fa07b83fb4a72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:19:02 +0000 Subject: [PATCH 09/27] Fix alias scope callback analysis Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 226 +++++++++++++++++++++----- Tests/Rules/AvoidUsingAlias.tests.ps1 | 61 +++++++ 2 files changed, 247 insertions(+), 40 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 352a48dae..2ee97e707 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -324,12 +324,17 @@ private bool IsScriptDefinedFunction( Dictionary> functionNameIndex) { string commandName = cmdAst.GetCommandName(); + if (commandName == null) + { + return false; + } + ScriptBlockAst currentScope = null; for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) { if (parent is ScriptBlockAst scope) { - if (currentScope == null && !IsCallerScopedScriptBlock(scope)) + if (currentScope == null && !IsCallerScopedScriptBlock(scope, functionNameIndex)) { currentScope = scope; } @@ -341,7 +346,7 @@ private bool IsScriptDefinedFunction( return true; } - if (IsParallelRunspaceScriptBlock(scope)) + if (IsIsolatedRunspaceScriptBlock(scope, functionNameIndex)) { break; } @@ -354,10 +359,11 @@ private bool IsScriptDefinedFunction( private Dictionary> GetFunctionNameIndex(Ast ast) { var functionNameIndex = new Dictionary>(); - IEnumerable functionDefinitions = ast.FindAll( + FunctionDefinitionAst[] functionDefinitions = ast.FindAll( candidate => candidate is FunctionDefinitionAst, - true).OfType(); + true).OfType().ToArray(); + var lexicalFunctionNameIndex = new Dictionary>(); foreach (FunctionDefinitionAst function in functionDefinitions) { if (IsInConstantFalseBranch(function)) @@ -366,42 +372,89 @@ private Dictionary> GetFunctionNameInde } string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name); - if (String.IsNullOrEmpty(functionName)) + ScriptBlockAst scope = GetNearestFunctionScope(function); + if (!String.IsNullOrEmpty(functionName) && scope != null) { - continue; + AddFunctionToIndex( + lexicalFunctionNameIndex, + scope, + functionName, + function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase)); } + } - ScriptBlockAst scope = GetFunctionScope(function); - if (scope == null) + foreach (FunctionDefinitionAst function in functionDefinitions) + { + if (IsInConstantFalseBranch(function)) { continue; } - bool isPrivate = function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase); - if (!functionNameIndex.TryGetValue(scope, out Dictionary functionNames)) + string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name); + if (String.IsNullOrEmpty(functionName)) { - functionNames = new Dictionary(StringComparer.OrdinalIgnoreCase); - functionNameIndex.Add(scope, functionNames); + continue; } - if (functionNames.TryGetValue(functionName, out bool existingIsPrivate)) + ScriptBlockAst scope = GetFunctionScope(function, lexicalFunctionNameIndex); + if (scope == null) { - functionNames[functionName] = existingIsPrivate && isPrivate; + continue; } - else + + AddFunctionToIndex( + functionNameIndex, + scope, + functionName, + function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase)); + } + + return functionNameIndex; + } + + private void AddFunctionToIndex( + Dictionary> functionNameIndex, + ScriptBlockAst scope, + string functionName, + bool isPrivate) + { + if (!functionNameIndex.TryGetValue(scope, out Dictionary functionNames)) + { + functionNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + functionNameIndex.Add(scope, functionNames); + } + + if (functionNames.TryGetValue(functionName, out bool existingIsPrivate)) + { + functionNames[functionName] = existingIsPrivate && isPrivate; + } + else + { + functionNames.Add(functionName, isPrivate); + } + } + + private ScriptBlockAst GetNearestFunctionScope(FunctionDefinitionAst function) + { + for (Ast parent = function.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst scope) { - functionNames.Add(functionName, isPrivate); + return scope; } } - return functionNameIndex; + return null; } - private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) + private ScriptBlockAst GetFunctionScope( + FunctionDefinitionAst function, + Dictionary> functionNameIndex) { for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { - if (parent is ScriptBlockAst scope && !IsCallerScopedScriptBlock(scope)) + if (parent is ScriptBlockAst scope + && !IsCallerScopedScriptBlock(scope, functionNameIndex)) { return scope; } @@ -410,7 +463,9 @@ private ScriptBlockAst GetFunctionScope(FunctionDefinitionAst function) return null; } - private bool IsCallerScopedScriptBlock(ScriptBlockAst scope) + private bool IsCallerScopedScriptBlock( + ScriptBlockAst scope, + Dictionary> functionNameIndex) { if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) { @@ -423,6 +478,11 @@ private bool IsCallerScopedScriptBlock(ScriptBlockAst scope) return false; } + if (IsScriptDefinedFunction(commandAst, functionNameIndex)) + { + return false; + } + if (commandAst.InvocationOperator == TokenKind.Dot && commandAst.CommandElements[0] == scriptBlock) { @@ -453,7 +513,9 @@ private bool IsCallerScopedScriptBlock(ScriptBlockAst scope) && IsParameter(GetScriptBlockParameterName(commandAst, scriptBlock), "FilterScript"); } - private bool IsParallelRunspaceScriptBlock(ScriptBlockAst scope) + private bool IsIsolatedRunspaceScriptBlock( + ScriptBlockAst scope, + Dictionary> functionNameIndex) { if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) { @@ -461,24 +523,73 @@ private bool IsParallelRunspaceScriptBlock(ScriptBlockAst scope) } CommandAst commandAst = GetScriptBlockCommand(scriptBlock); - return commandAst != null - && IsForEachObjectCommand(GetCallbackCommandName(commandAst)) - && IsParameter( - GetScriptBlockParameterName(commandAst, scriptBlock), - "Parallel"); + if (commandAst == null || IsScriptDefinedFunction(commandAst, functionNameIndex)) + { + return false; + } + + string commandName = GetCallbackCommandName(commandAst); + string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); + if (IsForEachObjectCommand(commandName) && IsParameter(parameterName, "Parallel")) + { + return true; + } + + if ((String.Equals(commandName, "Start-Job", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "Start-ThreadJob", StringComparison.OrdinalIgnoreCase)) + && (IsParameter(parameterName, "ScriptBlock") + || IsParameter(parameterName, "InitializationScript"))) + { + return true; + } + + if (String.Equals(commandName, "Invoke-Command", StringComparison.OrdinalIgnoreCase) + && IsParameter(parameterName, "ScriptBlock") + && (HasCommandParameter(commandAst, "ComputerName") + || HasCommandParameter(commandAst, "Session"))) + { + return true; + } + + if (String.Equals(commandName, "InlineScript", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return (String.Equals(commandName, "GetScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "TestScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "SetScript", StringComparison.OrdinalIgnoreCase)) + && commandAst.CommandElements.Count > 1 + && String.Equals(commandAst.CommandElements[1].ToString(), "=", StringComparison.Ordinal); } private CommandAst GetScriptBlockCommand(ScriptBlockExpressionAst scriptBlock) { - if (scriptBlock.Parent is CommandAst commandAst) + Ast current = scriptBlock; + while (current.Parent != null) { - return commandAst; + if (current.Parent is CommandAst commandAst) + { + return commandAst; + } + + if (current.Parent is ArrayLiteralAst arrayLiteral) + { + current = arrayLiteral; + continue; + } + + if (current.Parent is CommandParameterAst parameter + && parameter.Argument == current) + { + current = parameter; + continue; + } + + return null; } - return scriptBlock.Parent is CommandParameterAst parameter - && parameter.Argument == scriptBlock - ? parameter.Parent as CommandAst - : null; + return null; } private string GetCallbackCommandName(CommandAst commandAst) @@ -529,13 +640,20 @@ private string GetScriptBlockParameterName( CommandParameterAst attachedParameter = commandAst.CommandElements .OfType() - .FirstOrDefault(parameter => parameter.Argument == scriptBlock); + .FirstOrDefault(parameter => parameter.Argument != null + && (parameter.Argument == scriptBlock + || IsDescendantOf(scriptBlock, parameter.Argument))); if (attachedParameter != null) { return ResolveParameter(parameters, attachedParameter.ParameterName)?.Name; } - int scriptBlockIndex = commandAst.CommandElements.IndexOf(scriptBlock); + CommandElementAst argumentElement = commandAst.CommandElements + .FirstOrDefault(element => element == scriptBlock + || IsDescendantOf(scriptBlock, element)); + int scriptBlockIndex = argumentElement == null + ? -1 + : commandAst.CommandElements.IndexOf(argumentElement); if (scriptBlockIndex < 0) { return null; @@ -613,6 +731,7 @@ private ParameterMetadata GetPositionalScriptBlockParameter( int position) { ParameterMetadata match = null; + ParameterMetadata remainingScriptsParameter = null; foreach (ParameterMetadata parameter in parameters.Values) { Type parameterType = parameter.ParameterType; @@ -621,21 +740,48 @@ private ParameterMetadata GetPositionalScriptBlockParameter( parameterType = parameterType.GetElementType(); } - if (!typeof(ScriptBlock).IsAssignableFrom(parameterType) - || !parameter.Attributes.OfType().Any(attribute => attribute.Position == position)) + if (!typeof(ScriptBlock).IsAssignableFrom(parameterType)) { continue; } - if (match != null) + IEnumerable parameterAttributes = parameter.Attributes.OfType(); + if (parameterAttributes.Any(attribute => attribute.Position == position)) { - return null; + if (match != null) + { + return null; + } + + match = parameter; } - match = parameter; + if (parameterAttributes.Any(attribute => attribute.ValueFromRemainingArguments)) + { + if (remainingScriptsParameter != null) + { + return null; + } + + remainingScriptsParameter = parameter; + } + } + + return match ?? remainingScriptsParameter; + } + + private bool HasCommandParameter(CommandAst commandAst, string parameterName) + { + CommandInfo commandInfo = Helper.Instance.GetCommandInfo(GetCallbackCommandName(commandAst)); + if (commandInfo == null) + { + return false; } - return match; + return commandAst.CommandElements + .OfType() + .Any(parameter => ResolveParameter(commandInfo.Parameters, parameter.ParameterName)?.Name + .Equals(parameterName, StringComparison.OrdinalIgnoreCase) == true); } private bool IsParameter(string parameter, string parameterName) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 020c751f1..3733c6456 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -173,6 +173,67 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not find functions outside isolated execution boundaries" -TestCases @( + @{ Boundary = 'Start-Job { Verb }' } + @{ Boundary = 'Invoke-Command -ComputerName localhost -ScriptBlock { Verb }' } + @{ Boundary = 'Invoke-Command -Session $session -ScriptBlock { Verb }' } + @{ Boundary = 'InlineScript { Verb }' } + @{ Boundary = 'Script Test { GetScript = { Verb } }' } + ) { + param($Boundary) + + $target = "function Verb {}; $Boundary" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "respects visible script-defined commands before callback scope rules" -skip:($PSVersionTable.PSVersion.Major -lt 7) { + $target = 'function ForEach-Object { param([scriptblock]$Parallel) & $Parallel }; function Verb {}; ForEach-Object -Parallel { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + + $target = 'function ForEach-Object { param([scriptblock]$Process) & $Process }; function private:Verb {}; 1 | ForEach-Object -Process { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('{ Verb }', '{ Get-Verb }')) + } + + It "respects caller scope for script blocks inside array callback arguments" { + $target = 'function private:Verb {}; 1 | ForEach-Object -Process {}, { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + + It "does not index script blocks inside array input data" { + $target = 'ForEach-Object -InputObject { function Verb {} }, { function Other {} } -Process { $_ }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "respects caller scope for trailing RemainingScripts callbacks" { + $target = 'function private:Verb {}; 1 | ForEach-Object {}, {}, { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "resolves abbreviated and colon-attached parallel callback parameters" -skip:($PSVersionTable.PSVersion.Major -lt 7) { foreach ($parameter in @('-Pa { Verb }', '-Pa:{ Verb }')) { $target = "function Verb {}; 1 | ForEach-Object $parameter" From 642cf6c7177fe37a6edac60d9450d2fc8b890392 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:40:59 +0000 Subject: [PATCH 10/27] Handle unsupported command parameter metadata Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 106 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 2ee97e707..46998f42d 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -624,25 +624,22 @@ private string GetScriptBlockParameterName( return null; } - Dictionary parameters; - try - { - parameters = commandInfo.Parameters; - } - catch (InvalidOperationException) - { - parameters = Helper.Instance.GetCommandInfo(commandInfo.Name, bypassCache: true)?.Parameters; - if (parameters == null) - { - return null; - } - } + Dictionary parameters = GetCommandParameters(commandInfo); CommandParameterAst attachedParameter = commandAst.CommandElements .OfType() .FirstOrDefault(parameter => parameter.Argument != null && (parameter.Argument == scriptBlock || IsDescendantOf(scriptBlock, parameter.Argument))); + if (parameters == null) + { + return IsInvokeCommand(GetCallbackCommandName(commandAst)) + && attachedParameter != null + && IsInvokeCommandScriptBlockParameter(attachedParameter.ParameterName) + ? "ScriptBlock" + : null; + } + if (attachedParameter != null) { return ResolveParameter(parameters, attachedParameter.ParameterName)?.Name; @@ -778,10 +775,89 @@ private bool HasCommandParameter(CommandAst commandAst, string parameterName) return false; } + Dictionary parameters = GetCommandParameters(commandInfo); return commandAst.CommandElements .OfType() - .Any(parameter => ResolveParameter(commandInfo.Parameters, parameter.ParameterName)?.Name - .Equals(parameterName, StringComparison.OrdinalIgnoreCase) == true); + .Any(parameter => + { + string resolvedName = parameters == null + ? null + : ResolveParameter(parameters, parameter.ParameterName)?.Name; + return resolvedName?.Equals(parameterName, StringComparison.OrdinalIgnoreCase) == true + || (resolvedName == null + && IsInvokeCommand(GetCallbackCommandName(commandAst)) + && IsInvokeCommandParameter(parameter.ParameterName, parameterName)); + }); + } + + private Dictionary GetCommandParameters(CommandInfo commandInfo) + { + if ((commandInfo.CommandType & CommandTypes.Application) != 0) + { + return null; + } + + try + { + return commandInfo.Parameters; + } + catch (InvalidOperationException) + { + return GetCommandParametersWithoutCache(commandInfo); + } + catch (NotSupportedException) + { + return GetCommandParametersWithoutCache(commandInfo); + } + } + + private Dictionary GetCommandParametersWithoutCache(CommandInfo commandInfo) + { + try + { + return Helper.Instance.GetCommandInfo(commandInfo.Name, bypassCache: true)?.Parameters; + } + catch (InvalidOperationException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + } + + private bool IsInvokeCommand(string commandName) + { + return String.Equals(commandName, "Invoke-Command", StringComparison.OrdinalIgnoreCase); + } + + private bool IsInvokeCommandScriptBlockParameter(string parameterName) + { + return String.Equals(parameterName, "ScriptBlock", StringComparison.OrdinalIgnoreCase) + || String.Equals(parameterName, "Command", StringComparison.OrdinalIgnoreCase); + } + + private bool IsInvokeCommandParameter(string parameterName, string expectedName) + { + if (String.Equals(expectedName, "ScriptBlock", StringComparison.OrdinalIgnoreCase)) + { + return IsInvokeCommandScriptBlockParameter(parameterName); + } + + if (String.Equals(expectedName, "ComputerName", StringComparison.OrdinalIgnoreCase)) + { + return String.Equals(parameterName, "ComputerName", StringComparison.OrdinalIgnoreCase) + || String.Equals(parameterName, "Cn", StringComparison.OrdinalIgnoreCase); + } + + if (String.Equals(expectedName, "Session", StringComparison.OrdinalIgnoreCase)) + { + return String.Equals(parameterName, "Session", StringComparison.OrdinalIgnoreCase) + || String.Equals(parameterName, "PSSession", StringComparison.OrdinalIgnoreCase); + } + + return false; } private bool IsParameter(string parameter, string parameterName) From 83182911e9dad092c5f4b8533b5429a84e0dd122 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:49:18 +0000 Subject: [PATCH 11/27] Address AvoidAlias review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 388 ++++++++++++++++++-------- Tests/Rules/AvoidUsingAlias.tests.ps1 | 80 ++++++ 2 files changed, 344 insertions(+), 124 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 46998f42d..51fa66eb9 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -22,6 +22,24 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules #endif public class AvoidAlias : IScriptRule { + private sealed class FunctionAnalysisContext + { + public Dictionary>> FunctionNameIndex { get; set; } + + public Dictionary ScriptDefinedFunctionCache { get; } = new Dictionary(); + + public Dictionary CallerScopedScriptBlockCache { get; } = new Dictionary(); + + public Dictionary IsolatedRunspaceScriptBlockCache { get; } = new Dictionary(); + + public void ClearCaches() + { + ScriptDefinedFunctionCache.Clear(); + CallerScopedScriptBlockCache.Clear(); + IsolatedRunspaceScriptBlockCache.Clear(); + } + } + private readonly string allowListArgName = "allowlist"; // keep legacy argument name for next version to allow customers to transition but remove later private readonly string allowListLegacyArgName = "whitelist"; @@ -97,7 +115,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) } // Finds all CommandAsts. IEnumerable foundAsts = ast.FindAll(testAst => testAst is CommandAst, true); - Dictionary> functionNameIndex = GetFunctionNameIndex(ast); + FunctionAnalysisContext functionAnalysis = GetFunctionNameIndex(ast); // Iterates all CommandAsts and check the command name. foreach (CommandAst cmdAst in foundAsts) @@ -120,7 +138,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) } string cmdletNameIfCommandNameWasAlias = Helper.Instance.GetCmdletNameFromAlias(commandName); - bool isDscResourceDeclaration = IsDscResourceDeclaration(cmdAst, functionNameIndex); + bool isDscResourceDeclaration = IsDscResourceDeclaration(cmdAst, functionAnalysis); if (isDscResourceDeclaration && String.IsNullOrEmpty(cmdletNameIfCommandNameWasAlias)) { continue; @@ -141,7 +159,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName) } // If we find match of any kind, do not continue with the Get-{commandname} check - if (Helper.Instance.GetCommandInfo(commandName) != null || IsScriptDefinedFunction(cmdAst, functionNameIndex)) { + if (Helper.Instance.GetCommandInfo(commandName) != null || IsScriptDefinedFunction(cmdAst, functionAnalysis)) { continue; } @@ -195,7 +213,7 @@ private bool IgnoreCommandast(CommandAst cmdAst) private bool IsDscResourceDeclaration( CommandAst cmdAst, - Dictionary> functionNameIndex) + FunctionAnalysisContext functionAnalysis) { bool hasConfigurationAncestor = false; for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) @@ -218,7 +236,7 @@ private bool IsDscResourceDeclaration( } if (!hasConfigurationAncestor || GetDscResourceBody(cmdAst) == null - || !IsDscResourceCommand(cmdAst, functionNameIndex)) + || !IsDscResourceCommand(cmdAst, functionAnalysis)) { return false; } @@ -242,7 +260,7 @@ private bool IsDscResourceDeclaration( if (parent is CommandExpressionAst commandExpression && (!(commandExpression.Expression is ScriptBlockExpressionAst body) - || !IsFollowingLineDscResourceBody(body, functionNameIndex))) + || !IsFollowingLineDscResourceBody(body, functionAnalysis))) { return false; } @@ -253,7 +271,7 @@ private bool IsDscResourceDeclaration( if (scriptBlock.Parent is CommandAst enclosingCommand) { if (GetDscResourceBody(enclosingCommand) != scriptBlock - || !IsDscResourceCommand(enclosingCommand, functionNameIndex)) + || !IsDscResourceCommand(enclosingCommand, functionAnalysis)) { return false; } @@ -272,7 +290,7 @@ private bool IsDscResourceDeclaration( private bool IsFollowingLineDscResourceBody( ScriptBlockExpressionAst body, - Dictionary> functionNameIndex) + FunctionAnalysisContext functionAnalysis) { if (!(body.Parent is CommandExpressionAst bodyExpression) || !(bodyExpression.Parent is PipelineAst bodyPipeline) @@ -302,7 +320,7 @@ private bool IsFollowingLineDscResourceBody( } return GetDscResourceBody(declaration) == body - && IsDscResourceCommand(declaration, functionNameIndex); + && IsDscResourceCommand(declaration, functionAnalysis); } return false; @@ -310,10 +328,10 @@ private bool IsFollowingLineDscResourceBody( private bool IsDscResourceCommand( CommandAst cmdAst, - Dictionary> functionNameIndex) + FunctionAnalysisContext functionAnalysis) { string commandName = cmdAst.GetCommandName(); - return !IsScriptDefinedFunction(cmdAst, functionNameIndex) + return !IsScriptDefinedFunction(cmdAst, functionAnalysis) && String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(commandName)) && (String.Equals(commandName, "Node", StringComparison.OrdinalIgnoreCase) || Helper.Instance.GetCommandInfo(commandName) == null); @@ -321,7 +339,7 @@ private bool IsDscResourceCommand( private bool IsScriptDefinedFunction( CommandAst cmdAst, - Dictionary> functionNameIndex) + FunctionAnalysisContext functionAnalysis) { string commandName = cmdAst.GetCommandName(); if (commandName == null) @@ -329,41 +347,78 @@ private bool IsScriptDefinedFunction( return false; } + if (!String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(commandName))) + { + return false; + } + + if (functionAnalysis.ScriptDefinedFunctionCache.TryGetValue(cmdAst, out bool? cachedResult)) + { + return cachedResult.GetValueOrDefault(); + } + + functionAnalysis.ScriptDefinedFunctionCache[cmdAst] = null; ScriptBlockAst currentScope = null; for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) { if (parent is ScriptBlockAst scope) { - if (currentScope == null && !IsCallerScopedScriptBlock(scope, functionNameIndex)) + if (currentScope == null && !IsCallerScopedScriptBlock(scope, functionAnalysis)) { currentScope = scope; } - if (functionNameIndex.TryGetValue(scope, out Dictionary functionNames) - && functionNames.TryGetValue(commandName, out bool isPrivate) - && (!isPrivate || currentScope == scope)) + if (functionAnalysis.FunctionNameIndex.TryGetValue( + scope, + out Dictionary> functionNames) + && functionNames.TryGetValue(commandName, out List functionDefinitions) + && functionDefinitions.Any(function => + (!function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase) + || currentScope == scope) + && !IsFunctionDeclaredAfterCommand(function, cmdAst))) { + functionAnalysis.ScriptDefinedFunctionCache[cmdAst] = true; return true; } - if (IsIsolatedRunspaceScriptBlock(scope, functionNameIndex)) + if (IsIsolatedRunspaceScriptBlock(scope, functionAnalysis)) { break; } } } + functionAnalysis.ScriptDefinedFunctionCache[cmdAst] = false; return false; } - private Dictionary> GetFunctionNameIndex(Ast ast) + private bool IsFunctionDeclaredAfterCommand(FunctionDefinitionAst function, CommandAst cmdAst) + { + ScriptBlockAst commandScope = null; + for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst scope) + { + commandScope = scope; + break; + } + } + + return commandScope == GetNearestFunctionScope(function) + && function.Extent.StartOffset > cmdAst.Extent.StartOffset; + } + + private FunctionAnalysisContext GetFunctionNameIndex(Ast ast) { - var functionNameIndex = new Dictionary>(); FunctionDefinitionAst[] functionDefinitions = ast.FindAll( candidate => candidate is FunctionDefinitionAst, true).OfType().ToArray(); - var lexicalFunctionNameIndex = new Dictionary>(); + var functionAnalysis = new FunctionAnalysisContext + { + FunctionNameIndex = new Dictionary>>() + }; + foreach (FunctionDefinitionAst function in functionDefinitions) { if (IsInConstantFalseBranch(function)) @@ -371,66 +426,76 @@ private Dictionary> GetFunctionNameInde continue; } - string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name); ScriptBlockAst scope = GetNearestFunctionScope(function); - if (!String.IsNullOrEmpty(functionName) && scope != null) + if (scope != null) { - AddFunctionToIndex( - lexicalFunctionNameIndex, - scope, - functionName, - function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase)); + AddFunctionToIndex(functionAnalysis.FunctionNameIndex, scope, function); } } + var previousScopes = new Dictionary(); foreach (FunctionDefinitionAst function in functionDefinitions) { - if (IsInConstantFalseBranch(function)) + if (!IsInConstantFalseBranch(function)) { - continue; + previousScopes[function] = GetNearestFunctionScope(function); } + } - string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name); - if (String.IsNullOrEmpty(functionName)) + for (int iteration = 0; iteration <= previousScopes.Count; iteration++) + { + functionAnalysis.ClearCaches(); + var functionNameIndex = new Dictionary>>(); + var currentScopes = new Dictionary(); + foreach (FunctionDefinitionAst function in previousScopes.Keys) { - continue; + ScriptBlockAst scope = GetFunctionScope(function, functionAnalysis); + currentScopes[function] = scope; + if (scope != null) + { + AddFunctionToIndex(functionNameIndex, scope, function); + } } - ScriptBlockAst scope = GetFunctionScope(function, lexicalFunctionNameIndex); - if (scope == null) + bool scopesChanged = previousScopes.Any(pair => currentScopes[pair.Key] != pair.Value); + functionAnalysis.FunctionNameIndex = functionNameIndex; + previousScopes = currentScopes; + if (!scopesChanged) { - continue; + break; } - - AddFunctionToIndex( - functionNameIndex, - scope, - functionName, - function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase)); } - return functionNameIndex; + functionAnalysis.ClearCaches(); + return functionAnalysis; } private void AddFunctionToIndex( - Dictionary> functionNameIndex, + Dictionary>> functionNameIndex, ScriptBlockAst scope, - string functionName, - bool isPrivate) + FunctionDefinitionAst function) { - if (!functionNameIndex.TryGetValue(scope, out Dictionary functionNames)) + string functionName = Helper.Instance.FunctionNameWithoutScope(function.Name); + if (String.IsNullOrEmpty(functionName)) { - functionNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + return; + } + + if (!functionNameIndex.TryGetValue( + scope, + out Dictionary> functionNames)) + { + functionNames = new Dictionary>(StringComparer.OrdinalIgnoreCase); functionNameIndex.Add(scope, functionNames); } - if (functionNames.TryGetValue(functionName, out bool existingIsPrivate)) + if (functionNames.TryGetValue(functionName, out List functionDefinitions)) { - functionNames[functionName] = existingIsPrivate && isPrivate; + functionDefinitions.Add(function); } else { - functionNames.Add(functionName, isPrivate); + functionNames.Add(functionName, new List { function }); } } @@ -449,12 +514,12 @@ private ScriptBlockAst GetNearestFunctionScope(FunctionDefinitionAst function) private ScriptBlockAst GetFunctionScope( FunctionDefinitionAst function, - Dictionary> functionNameIndex) + FunctionAnalysisContext functionAnalysis) { for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { if (parent is ScriptBlockAst scope - && !IsCallerScopedScriptBlock(scope, functionNameIndex)) + && !IsCallerScopedScriptBlock(scope, functionAnalysis)) { return scope; } @@ -464,103 +529,168 @@ private ScriptBlockAst GetFunctionScope( } private bool IsCallerScopedScriptBlock( - ScriptBlockAst scope, - Dictionary> functionNameIndex) + ScriptBlockAst scope, + FunctionAnalysisContext functionAnalysis) { - if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) - { - return false; - } + if (functionAnalysis.CallerScopedScriptBlockCache.TryGetValue(scope, out bool? cachedResult)) + { + return cachedResult.GetValueOrDefault(); + } - CommandAst commandAst = GetScriptBlockCommand(scriptBlock); - if (commandAst == null) - { - return false; - } + functionAnalysis.CallerScopedScriptBlockCache[scope] = null; + bool result = false; + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + functionAnalysis.CallerScopedScriptBlockCache[scope] = result; + return result; + } - if (IsScriptDefinedFunction(commandAst, functionNameIndex)) - { - return false; - } + CommandAst commandAst = GetScriptBlockCommand(scriptBlock); + if (commandAst == null || IsScriptDefinedFunction(commandAst, functionAnalysis)) + { + functionAnalysis.CallerScopedScriptBlockCache[scope] = result; + return result; + } - if (commandAst.InvocationOperator == TokenKind.Dot - && commandAst.CommandElements[0] == scriptBlock) + if (commandAst.InvocationOperator == TokenKind.Dot + && commandAst.CommandElements[0] == scriptBlock) + { + result = true; + } + else + { + string commandName = GetCallbackCommandName(commandAst); + if (IsForEachObjectCommand(commandName)) { - return true; + string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); + result = !IsParameter(parameterName, "Parallel") + && (IsParameter(parameterName, "Begin") + || IsParameter(parameterName, "Process") + || IsParameter(parameterName, "End") + || IsParameter(parameterName, "RemainingScripts")); } - - string commandName = GetCallbackCommandName(commandAst); - if (commandName == null) + else if (IsWhereObjectCommand(commandName)) { - return false; + result = IsParameter(GetScriptBlockParameterName(commandAst, scriptBlock), "FilterScript"); } - - if (IsForEachObjectCommand(commandName)) + else if (IsInvokeCommand(commandName) + && IsParameter(GetScriptBlockParameterName(commandAst, scriptBlock), "ScriptBlock")) { - string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); - if (IsParameter(parameterName, "Parallel")) - { - return false; - } - - return IsParameter(parameterName, "Begin") - || IsParameter(parameterName, "Process") - || IsParameter(parameterName, "End") - || IsParameter(parameterName, "RemainingScripts"); + result = HasCommandParameter(commandAst, "NoNewScope") + && !HasCommandParameter(commandAst, "ComputerName") + && !HasCommandParameter(commandAst, "Session"); + } } - return IsWhereObjectCommand(commandName) - && IsParameter(GetScriptBlockParameterName(commandAst, scriptBlock), "FilterScript"); + functionAnalysis.CallerScopedScriptBlockCache[scope] = result; + return result; } private bool IsIsolatedRunspaceScriptBlock( - ScriptBlockAst scope, - Dictionary> functionNameIndex) + ScriptBlockAst scope, + FunctionAnalysisContext functionAnalysis) { - if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) - { - return false; - } + if (functionAnalysis.IsolatedRunspaceScriptBlockCache.TryGetValue(scope, out bool? cachedResult)) + { + return cachedResult.GetValueOrDefault(); + } - CommandAst commandAst = GetScriptBlockCommand(scriptBlock); - if (commandAst == null || IsScriptDefinedFunction(commandAst, functionNameIndex)) - { - return false; - } + functionAnalysis.IsolatedRunspaceScriptBlockCache[scope] = null; + bool result = false; + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + functionAnalysis.IsolatedRunspaceScriptBlockCache[scope] = result; + return result; + } - string commandName = GetCallbackCommandName(commandAst); - string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); - if (IsForEachObjectCommand(commandName) && IsParameter(parameterName, "Parallel")) - { - return true; - } + CommandAst commandAst = GetScriptBlockCommand(scriptBlock); + if (commandAst == null) + { + commandAst = GetPrecedingDscScriptPropertyCommand(scriptBlock); + } - if ((String.Equals(commandName, "Start-Job", StringComparison.OrdinalIgnoreCase) - || String.Equals(commandName, "Start-ThreadJob", StringComparison.OrdinalIgnoreCase)) - && (IsParameter(parameterName, "ScriptBlock") - || IsParameter(parameterName, "InitializationScript"))) - { - return true; - } + if (commandAst == null || IsScriptDefinedFunction(commandAst, functionAnalysis)) + { + functionAnalysis.IsolatedRunspaceScriptBlockCache[scope] = result; + return result; + } + + string commandName = GetCallbackCommandName(commandAst); + string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); + if (IsForEachObjectCommand(commandName) && IsParameter(parameterName, "Parallel")) + { + result = true; + } + else if ((String.Equals(commandName, "Start-Job", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "Start-ThreadJob", StringComparison.OrdinalIgnoreCase)) + && (IsParameter(parameterName, "ScriptBlock") + || IsParameter(parameterName, "InitializationScript"))) + { + result = true; + } + else if (String.Equals(commandName, "Invoke-Command", StringComparison.OrdinalIgnoreCase) + && IsParameter(parameterName, "ScriptBlock") + && (HasCommandParameter(commandAst, "ComputerName") + || HasCommandParameter(commandAst, "Session"))) + { + result = true; + } + else if (String.Equals(commandName, "InlineScript", StringComparison.OrdinalIgnoreCase)) + { + result = true; + } + else + { + result = (String.Equals(commandName, "GetScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "TestScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "SetScript", StringComparison.OrdinalIgnoreCase)) + && commandAst.CommandElements.Count > 1 + && String.Equals(commandAst.CommandElements[1].ToString(), "=", StringComparison.Ordinal); + } + + functionAnalysis.IsolatedRunspaceScriptBlockCache[scope] = result; + return result; + } - if (String.Equals(commandName, "Invoke-Command", StringComparison.OrdinalIgnoreCase) - && IsParameter(parameterName, "ScriptBlock") - && (HasCommandParameter(commandAst, "ComputerName") - || HasCommandParameter(commandAst, "Session"))) + private CommandAst GetPrecedingDscScriptPropertyCommand(ScriptBlockExpressionAst scriptBlock) + { + if (!(scriptBlock.Parent is CommandExpressionAst commandExpression) + || !(commandExpression.Parent is PipelineAst pipeline)) + { + return null; + } + + Ast statement = pipeline; + for (Ast parent = pipeline.Parent; parent != null; parent = parent.Parent) + { + var statements = (parent as NamedBlockAst)?.Statements + ?? (parent as StatementBlockAst)?.Statements; + if (statements == null) { - return true; + statement = parent; + continue; } - if (String.Equals(commandName, "InlineScript", StringComparison.OrdinalIgnoreCase)) + int index = statements.IndexOf(statement as StatementAst); + if (index <= 0 + || !(statements[index - 1] is PipelineAst propertyPipeline) + || propertyPipeline.PipelineElements.Count != 1 + || !(propertyPipeline.PipelineElements[0] is CommandAst propertyCommand) + || propertyCommand.CommandElements.Count != 2 + || !String.Equals(propertyCommand.CommandElements[1].ToString(), "=", StringComparison.Ordinal)) { - return true; + return null; } - return (String.Equals(commandName, "GetScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(commandName, "TestScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(commandName, "SetScript", StringComparison.OrdinalIgnoreCase)) - && commandAst.CommandElements.Count > 1 - && String.Equals(commandAst.CommandElements[1].ToString(), "=", StringComparison.Ordinal); + string propertyName = propertyCommand.GetCommandName(); + return String.Equals(propertyName, "GetScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(propertyName, "TestScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(propertyName, "SetScript", StringComparison.OrdinalIgnoreCase) + ? propertyCommand + : null; + } + + return null; } private CommandAst GetScriptBlockCommand(ScriptBlockExpressionAst scriptBlock) @@ -645,6 +775,16 @@ private string GetScriptBlockParameterName( return ResolveParameter(parameters, attachedParameter.ParameterName)?.Name; } + if (IsWhereObjectCommand(GetCallbackCommandName(commandAst)) + && commandAst.CommandElements + .OfType() + .Any(parameter => IsParameter( + ResolveParameter(parameters, parameter.ParameterName)?.Name, + "Property"))) + { + return null; + } + CommandElementAst argumentElement = commandAst.CommandElements .FirstOrDefault(element => element == scriptBlock || IsDescendantOf(scriptBlock, element)); diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 3733c6456..32da436b5 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -86,6 +86,15 @@ gci -Path C:\ Should -Be ($target.Replace('& { Verb }', '& { Get-Verb }')) } + It "does not resolve function declarations before their execution" { + $target = 'Verb; function Verb {}' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "does not apply caller scope to script-block arguments of dot-invoked commands" { $target = 'function private:Verb {}; . Invoke-Command -ScriptBlock { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -113,6 +122,36 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "recognizes caller scope for local Invoke-Command NoNewScope callbacks" { + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + $target = 'function private:Verb {}; Invoke-Command -NoNewScope -ScriptBlock { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + + $target = 'Invoke-Command -NoNewScope -ScriptBlock { function private:Verb {}; Verb }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + + It "does not treat a newline-separated DSC script property as caller-scoped" { + $target = @' +function Verb {}; +Script Test { + SetScript = + { Verb } +} +'@ + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "respects caller scope in callbacks" -TestCases @( @{ Command = 'ForEach-Object' } @{ Command = 'Where-Object' } @@ -129,6 +168,17 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "preserves caller scope when an alias takes precedence over a same-named function" { + $target = 'function where {}; function private:Verb {}; 1 | where { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'where' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('1 | where', '1 | Where-Object')) + } + It "respects caller scope for colon-attached callback arguments" { foreach ($command in @( 'ForEach-Object -Process:{ Verb }' @@ -207,6 +257,18 @@ gci -Path C:\ Should -Be ($target.Replace('{ Verb }', '{ Get-Verb }')) } + It "resolves dot-sourced callback commands before classifying nested callbacks" { + $target = '. { function ForEach-Object { param([scriptblock]$Process) & $Process } }; ForEach-Object { function private:Verb {}; Verb }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Substring(0, $target.LastIndexOf(' Verb')) + ' Get-Verb') + } + It "respects caller scope for script blocks inside array callback arguments" { $target = 'function private:Verb {}; 1 | ForEach-Object -Process {}, { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -269,6 +331,24 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not classify Where-Object comparison values as executable callbacks" { + $target = 'Where-Object -Property Name -EQ { function Verb {} }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "handles deeply nested caller-scoped callbacks without repeated visibility scans" { + $depth = 12 + $target = ('1 | ForEach-Object { ' * $depth) + 'Verb' + (' }' * $depth) + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + } + It "indexes declarations in parameterized dot-sourced blocks in the caller scope" { $target = '. { param($value) function private:Verb {}; Verb } 1; Verb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From 00f63225825afa67258a824c8fe28d84521ed408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:17:04 +0000 Subject: [PATCH 12/27] Fix AvoidAlias scope classification review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 150 ++++++++++++++++++++++---- Tests/Rules/AvoidUsingAlias.tests.ps1 | 42 ++++++++ 2 files changed, 174 insertions(+), 18 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 51fa66eb9..ced28d6fe 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -375,7 +375,7 @@ private bool IsScriptDefinedFunction( && functionDefinitions.Any(function => (!function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase) || currentScope == scope) - && !IsFunctionDeclaredAfterCommand(function, cmdAst))) + && !IsFunctionDeclaredAfterCommand(function, cmdAst, currentScope, functionAnalysis))) { functionAnalysis.ScriptDefinedFunctionCache[cmdAst] = true; return true; @@ -392,19 +392,14 @@ private bool IsScriptDefinedFunction( return false; } - private bool IsFunctionDeclaredAfterCommand(FunctionDefinitionAst function, CommandAst cmdAst) + private bool IsFunctionDeclaredAfterCommand( + FunctionDefinitionAst function, + CommandAst cmdAst, + ScriptBlockAst commandScope, + FunctionAnalysisContext functionAnalysis) { - ScriptBlockAst commandScope = null; - for (Ast parent = cmdAst.Parent; parent != null; parent = parent.Parent) - { - if (parent is ScriptBlockAst scope) - { - commandScope = scope; - break; - } - } - - return commandScope == GetNearestFunctionScope(function) + return commandScope != null + && commandScope == GetFunctionScope(function, functionAnalysis) && function.Extent.StartOffset > cmdAst.Extent.StartOffset; } @@ -562,6 +557,12 @@ private bool IsCallerScopedScriptBlock( string commandName = GetCallbackCommandName(commandAst); if (IsForEachObjectCommand(commandName)) { + if (HasCommandParameter(commandAst, "MemberName")) + { + functionAnalysis.CallerScopedScriptBlockCache[scope] = result; + return result; + } + string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); result = !IsParameter(parameterName, "Parallel") && (IsParameter(parameterName, "Begin") @@ -576,9 +577,8 @@ private bool IsCallerScopedScriptBlock( else if (IsInvokeCommand(commandName) && IsParameter(GetScriptBlockParameterName(commandAst, scriptBlock), "ScriptBlock")) { - result = HasCommandParameter(commandAst, "NoNewScope") - && !HasCommandParameter(commandAst, "ComputerName") - && !HasCommandParameter(commandAst, "Session"); + result = HasEnabledCommandParameter(commandAst, "NoNewScope") + && !IsRemoteInvokeCommand(commandAst); } } @@ -630,8 +630,7 @@ private bool IsIsolatedRunspaceScriptBlock( } else if (String.Equals(commandName, "Invoke-Command", StringComparison.OrdinalIgnoreCase) && IsParameter(parameterName, "ScriptBlock") - && (HasCommandParameter(commandAst, "ComputerName") - || HasCommandParameter(commandAst, "Session"))) + && IsRemoteInvokeCommand(commandAst)) { result = true; } @@ -709,6 +708,20 @@ private CommandAst GetScriptBlockCommand(ScriptBlockExpressionAst scriptBlock) continue; } + if (current.Parent is ArrayExpressionAst arrayExpression) + { + current = arrayExpression; + continue; + } + + if (current.Parent is CommandExpressionAst + || current.Parent is PipelineAst + || current.Parent is StatementBlockAst) + { + current = current.Parent; + continue; + } + if (current.Parent is CommandParameterAst parameter && parameter.Argument == current) { @@ -930,6 +943,107 @@ private bool HasCommandParameter(CommandAst commandAst, string parameterName) }); } + private bool HasEnabledCommandParameter(CommandAst commandAst, string parameterName) + { + CommandInfo commandInfo = Helper.Instance.GetCommandInfo(GetCallbackCommandName(commandAst)); + Dictionary parameters = commandInfo == null + ? null + : GetCommandParameters(commandInfo); + foreach (CommandParameterAst parameter in commandAst.CommandElements.OfType()) + { + string resolvedName = parameters == null + ? null + : ResolveParameter(parameters, parameter.ParameterName)?.Name; + if (!String.Equals(resolvedName, parameterName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (parameter.Argument == null) + { + return true; + } + + try + { + return LanguagePrimitives.IsTrue(parameter.Argument.SafeGetValue()); + } + catch (InvalidOperationException) + { + return true; + } + } + + return false; + } + + private bool IsRemoteInvokeCommand(CommandAst commandAst) + { + CommandInfo commandInfo = Helper.Instance.GetCommandInfo(GetCallbackCommandName(commandAst)); + if (commandInfo == null) + { + return false; + } + + Dictionary parameters = GetCommandParameters(commandInfo); + if (parameters == null) + { + return false; + } + + string[] remoteTargetParameters = + { + "ComputerName", + "Session", + "ConnectionUri", + "HostName", + "VMId", + "VMName", + "ContainerId" + }; + + foreach (CommandParameterAst parameter in commandAst.CommandElements.OfType()) + { + string parameterName = ResolveParameter(parameters, parameter.ParameterName)?.Name; + if (remoteTargetParameters.Contains(parameterName, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + } + + int positionalIndex = 0; + for (int i = 1; i < commandAst.CommandElements.Count; i++) + { + CommandElementAst element = commandAst.CommandElements[i]; + if (element is CommandParameterAst parameter) + { + if (parameter.Argument != null + || (i + 1 < commandAst.CommandElements.Count + && !(commandAst.CommandElements[i + 1] is CommandParameterAst))) + { + i++; + } + + continue; + } + + bool isScriptBlock = element is ScriptBlockExpressionAst + || element.FindAll(candidate => candidate is ScriptBlockExpressionAst, true).Any(); + if (!isScriptBlock + && remoteTargetParameters.Any(name => + parameters.TryGetValue(name, out ParameterMetadata metadata) + && metadata.Attributes.OfType() + .Any(attribute => attribute.Position == positionalIndex))) + { + return true; + } + + positionalIndex++; + } + + return false; + } + private Dictionary GetCommandParameters(CommandInfo commandInfo) { if ((commandInfo.CommandType & CommandTypes.Application) != 0) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 32da436b5..bd4f19504 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -95,6 +95,19 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "respects execution order across caller-scoped blocks" -TestCases @( + @{ Target = 'Verb; . { function Verb {} }' } + @{ Target = '. { Verb }; function Verb {}' } + ) { + param($Target) + + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $Target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "does not apply caller scope to script-block arguments of dot-invoked commands" { $target = 'function private:Verb {}; . Invoke-Command -ScriptBlock { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -227,6 +240,8 @@ Script Test { @{ Boundary = 'Start-Job { Verb }' } @{ Boundary = 'Invoke-Command -ComputerName localhost -ScriptBlock { Verb }' } @{ Boundary = 'Invoke-Command -Session $session -ScriptBlock { Verb }' } + @{ Boundary = 'Invoke-Command localhost { Verb }' } + @{ Boundary = 'Invoke-Command -ConnectionUri http://server:5985/wsman -ScriptBlock { Verb }' } @{ Boundary = 'InlineScript { Verb }' } @{ Boundary = 'Script Test { GetScript = { Verb } }' } ) { @@ -278,6 +293,15 @@ Script Test { Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "respects caller scope for callbacks in array subexpressions" { + $target = 'function private:Verb {}; 1 | ForEach-Object -Process @({ Verb })' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "does not index script blocks inside array input data" { $target = 'ForEach-Object -InputObject { function Verb {} }, { function Other {} } -Process { $_ }; Verb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -340,6 +364,24 @@ Script Test { $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not classify ForEach-Object MemberName arguments as executable callbacks" { + $target = '1 | ForEach-Object -MemberName Equals { function Verb {} }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "does not treat an explicitly disabled NoNewScope switch as caller-scoped" { + $target = 'function private:Verb {}; Invoke-Command -NoNewScope:$false -ScriptBlock { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "handles deeply nested caller-scoped callbacks without repeated visibility scans" { $depth = 12 $target = ('1 | ForEach-Object { ' * $depth) + 'Verb' + (' }' * $depth) From c550c39a38e55bad919c31722757f56dfe961ae8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:11:16 +0000 Subject: [PATCH 13/27] Fix AvoidAlias execution-order and positional handling Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 66 +++++++++++++++++++++++++-- Tests/Rules/AvoidUsingAlias.tests.ps1 | 23 ++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index ced28d6fe..6aa2dcb63 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -398,9 +398,63 @@ private bool IsFunctionDeclaredAfterCommand( ScriptBlockAst commandScope, FunctionAnalysisContext functionAnalysis) { - return commandScope != null - && commandScope == GetFunctionScope(function, functionAnalysis) - && function.Extent.StartOffset > cmdAst.Extent.StartOffset; + if (commandScope == null || commandScope != GetFunctionScope(function, functionAnalysis)) + { + return false; + } + + int functionPhase = GetForEachObjectExecutionPhase(function, out CommandAst functionCallback); + int commandPhase = GetForEachObjectExecutionPhase(cmdAst, out CommandAst commandCallback); + if (functionCallback == commandCallback + && functionPhase >= 0 + && commandPhase >= 0 + && functionPhase != commandPhase) + { + return functionPhase > commandPhase; + } + + return function.Extent.StartOffset > cmdAst.Extent.StartOffset; + } + + private int GetForEachObjectExecutionPhase(Ast ast, out CommandAst callbackCommand) + { + callbackCommand = null; + for (Ast current = ast; current != null; current = current.Parent) + { + if (!(current is ScriptBlockAst scope) + || !(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + continue; + } + + CommandAst commandAst = GetScriptBlockCommand(scriptBlock); + if (commandAst == null + || !IsForEachObjectCommand(GetCallbackCommandName(commandAst))) + { + continue; + } + + string parameterName = GetScriptBlockParameterName(commandAst, scriptBlock); + if (IsParameter(parameterName, "Begin")) + { + callbackCommand = commandAst; + return 0; + } + + if (IsParameter(parameterName, "Process")) + { + callbackCommand = commandAst; + return 1; + } + + if (IsParameter(parameterName, "End")) + { + callbackCommand = commandAst; + return 2; + } + } + + return -1; } private FunctionAnalysisContext GetFunctionNameIndex(Ast ast) @@ -1017,8 +1071,10 @@ private bool IsRemoteInvokeCommand(CommandAst commandAst) CommandElementAst element = commandAst.CommandElements[i]; if (element is CommandParameterAst parameter) { - if (parameter.Argument != null - || (i + 1 < commandAst.CommandElements.Count + ParameterMetadata metadata = ResolveParameter(parameters, parameter.ParameterName); + if (parameter.Argument == null + && metadata?.ParameterType != typeof(SwitchParameter) + && (i + 1 < commandAst.CommandElements.Count && !(commandAst.CommandElements[i + 1] is CommandParameterAst))) { i++; diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index bd4f19504..94a520aa3 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -108,6 +108,27 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "respects execution order across ForEach-Object callback phases" -TestCases @( + @{ + Target = '1 | ForEach-Object -Process { Verb } -Begin { function Verb {} }' + ExpectedViolations = 0 + ExpectedText = '1 | ForEach-Object -Process { Verb } -Begin { function Verb {} }' + } + @{ + Target = '1 | ForEach-Object -Begin { Verb } -Process { function Verb {} }' + ExpectedViolations = 1 + ExpectedText = '1 | ForEach-Object -Begin { Get-Verb } -Process { function Verb {} }' + } + ) { + param($Target, $ExpectedViolations, $ExpectedText) + + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $Target -IncludeRule $violationName) + + $violations.Count | Should -Be $ExpectedViolations + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $Target -Settings $settings | Should -Be $ExpectedText + } + It "does not apply caller scope to script-block arguments of dot-invoked commands" { $target = 'function private:Verb {}; . Invoke-Command -ScriptBlock { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -241,6 +262,8 @@ Script Test { @{ Boundary = 'Invoke-Command -ComputerName localhost -ScriptBlock { Verb }' } @{ Boundary = 'Invoke-Command -Session $session -ScriptBlock { Verb }' } @{ Boundary = 'Invoke-Command localhost { Verb }' } + @{ Boundary = 'Invoke-Command -Verbose localhost -ScriptBlock { Verb }' } + @{ Boundary = 'Invoke-Command -Verbose:$true localhost -ScriptBlock { Verb }' } @{ Boundary = 'Invoke-Command -ConnectionUri http://server:5985/wsman -ScriptBlock { Verb }' } @{ Boundary = 'InlineScript { Verb }' } @{ Boundary = 'Script Test { GetScript = { Verb } }' } From d2f2467e4e85f528d1a1903513e1258545f8c950 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:52:03 +0000 Subject: [PATCH 14/27] Fix AvoidAlias review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 183 +++++++++++++++++++++++++- Tests/Rules/AvoidUsingAlias.tests.ps1 | 58 ++++++++ 2 files changed, 236 insertions(+), 5 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 6aa2dcb63..2cc8979d9 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -373,7 +373,8 @@ private bool IsScriptDefinedFunction( out Dictionary> functionNames) && functionNames.TryGetValue(commandName, out List functionDefinitions) && functionDefinitions.Any(function => - (!function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase) + !IsDescendantOf(function, cmdAst) + && (!function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase) || currentScope == scope) && !IsFunctionDeclaredAfterCommand(function, cmdAst, currentScope, functionAnalysis))) { @@ -398,11 +399,35 @@ private bool IsFunctionDeclaredAfterCommand( ScriptBlockAst commandScope, FunctionAnalysisContext functionAnalysis) { - if (commandScope == null || commandScope != GetFunctionScope(function, functionAnalysis)) + if (commandScope == null) { return false; } + ScriptBlockAst functionScope = GetFunctionScope(function, functionAnalysis); + if (functionScope == null) + { + return false; + } + + CommandAst immediateInvocation = null; + if (commandScope != functionScope + && !IsImmediatelyInvokedChildScope(commandScope, functionScope, out immediateInvocation)) + { + return false; + } + + int functionNamedBlockPhase = GetNamedBlockExecutionPhase(function, out FunctionDefinitionAst functionOwner); + int commandNamedBlockPhase = GetNamedBlockExecutionPhase(cmdAst, out FunctionDefinitionAst commandOwner); + if (functionOwner != null + && functionOwner == commandOwner + && functionNamedBlockPhase >= 0 + && commandNamedBlockPhase >= 0 + && functionNamedBlockPhase != commandNamedBlockPhase) + { + return functionNamedBlockPhase > commandNamedBlockPhase; + } + int functionPhase = GetForEachObjectExecutionPhase(function, out CommandAst functionCallback); int commandPhase = GetForEachObjectExecutionPhase(cmdAst, out CommandAst commandCallback); if (functionCallback == commandCallback @@ -413,7 +438,82 @@ private bool IsFunctionDeclaredAfterCommand( return functionPhase > commandPhase; } - return function.Extent.StartOffset > cmdAst.Extent.StartOffset; + int commandExecutionOffset = immediateInvocation == null + ? cmdAst.Extent.StartOffset + : immediateInvocation.Extent.StartOffset; + return function.Extent.StartOffset > commandExecutionOffset; + } + + private bool IsImmediatelyInvokedChildScope( + ScriptBlockAst commandScope, + ScriptBlockAst functionScope, + out CommandAst invocation) + { + invocation = null; + if (!(commandScope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + return false; + } + + CommandAst command = GetScriptBlockCommand(scriptBlock); + if (command == null || command.InvocationOperator != TokenKind.Ampersand) + { + return false; + } + + ScriptBlockAst parentScope = null; + for (Ast parent = commandScope.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst scope) + { + parentScope = scope; + break; + } + } + + if (parentScope != functionScope) + { + return false; + } + + invocation = command; + return true; + } + + private int GetNamedBlockExecutionPhase(Ast ast, out FunctionDefinitionAst containingFunction) + { + NamedBlockAst namedBlock = null; + containingFunction = null; + for (Ast current = ast; current != null; current = current.Parent) + { + if (namedBlock == null && current is NamedBlockAst block) + { + namedBlock = block; + } + + if (current != ast && current is FunctionDefinitionAst function) + { + containingFunction = function; + break; + } + } + + if (namedBlock == null || containingFunction == null) + { + return -1; + } + + switch (namedBlock.BlockKind) + { + case TokenKind.Begin: + return 0; + case TokenKind.Process: + return 1; + case TokenKind.End: + return 2; + default: + return -1; + } } private int GetForEachObjectExecutionPhase(Ast ast, out CommandAst callbackCommand) @@ -611,7 +711,8 @@ private bool IsCallerScopedScriptBlock( string commandName = GetCallbackCommandName(commandAst); if (IsForEachObjectCommand(commandName)) { - if (HasCommandParameter(commandAst, "MemberName")) + if (HasCommandParameter(commandAst, "MemberName") + || HasPositionalCommandParameter(commandAst, scriptBlock, "MemberName")) { functionAnalysis.CallerScopedScriptBlockCache[scope] = result; return result; @@ -768,7 +869,8 @@ private CommandAst GetScriptBlockCommand(ScriptBlockExpressionAst scriptBlock) continue; } - if (current.Parent is CommandExpressionAst + if (current.Parent is ParenExpressionAst + || current.Parent is CommandExpressionAst || current.Parent is PipelineAst || current.Parent is StatementBlockAst) { @@ -802,6 +904,10 @@ private string GetCallbackCommandName(CommandAst commandAst) { commandName = commandName.Substring(coreModulePrefix.Length); } + else if (commandName.EndsWith("\\Start-ThreadJob", StringComparison.OrdinalIgnoreCase)) + { + commandName = "Start-ThreadJob"; + } else if (commandName.IndexOf('\\') >= 0) { return null; @@ -900,6 +1006,73 @@ private string GetScriptBlockParameterName( return null; } + private bool HasPositionalCommandParameter( + CommandAst commandAst, + ScriptBlockExpressionAst scriptBlock, + string parameterName) + { + CommandInfo commandInfo = Helper.Instance.GetCommandInfo(GetCallbackCommandName(commandAst)); + Dictionary parameters = commandInfo == null + ? null + : GetCommandParameters(commandInfo); + if (parameters == null) + { + return false; + } + + CommandElementAst argumentElement = commandAst.CommandElements + .FirstOrDefault(element => element == scriptBlock + || IsDescendantOf(scriptBlock, element)); + int scriptBlockIndex = argumentElement == null + ? -1 + : commandAst.CommandElements.IndexOf(argumentElement); + if (scriptBlockIndex < 0) + { + return false; + } + + int positionalIndex = 0; + for (int i = 1; i < scriptBlockIndex; i++) + { + CommandElementAst commandElement = commandAst.CommandElements[i]; + if (!(commandElement is CommandParameterAst parameter)) + { + if (IsPositionalParameter(parameters, positionalIndex, parameterName)) + { + return true; + } + + positionalIndex++; + continue; + } + + ParameterMetadata metadata = ResolveParameter(parameters, parameter.ParameterName); + if (parameter.Argument != null || metadata?.ParameterType == typeof(SwitchParameter)) + { + continue; + } + + if (i + 1 < scriptBlockIndex + && !(commandAst.CommandElements[i + 1] is CommandParameterAst)) + { + i++; + } + } + + return false; + } + + private bool IsPositionalParameter( + Dictionary parameters, + int position, + string parameterName) + { + return parameters.Values.Any(parameter => + String.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase) + && parameter.Attributes.OfType() + .Any(attribute => attribute.Position == position)); + } + private ParameterMetadata ResolveParameter( Dictionary parameters, string parameterName) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 94a520aa3..f0822c605 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -108,6 +108,22 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not resolve declarations after an immediately invoked child block" { + $target = '& { Verb }; function Verb {}' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "respects function begin and process execution order" { + $target = 'function Invoke-Example { process { Verb } begin { function Verb {} } }; Invoke-Example' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + } + It "respects execution order across ForEach-Object callback phases" -TestCases @( @{ Target = '1 | ForEach-Object -Process { Verb } -Begin { function Verb {} }' @@ -307,6 +323,23 @@ Script Test { Should -Be ($target.Substring(0, $target.LastIndexOf(' Verb')) + ' Get-Verb') } + It "does not let callback declarations change command resolution at invocation time" -TestCases @( + @{ + Target = 'function private:Verb {}; 1 | ForEach-Object { function ForEach-Object { param([scriptblock]$Process) & $Process } }; function Unrelated {}; 1 | ForEach-Object -Process { Verb }' + } + @{ + Target = 'function private:Verb {}; 1 | ForEach-Object { function ForEach-Object { param([scriptblock]$Process) & $Process } }; 1 | ForEach-Object -Process { Verb }' + } + ) { + param($Target) + + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $Target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "respects caller scope for script blocks inside array callback arguments" { $target = 'function private:Verb {}; 1 | ForEach-Object -Process {}, { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -394,6 +427,31 @@ Script Test { $violations.Count | Should -Be 1 $violations[0].Extent.Text | Should -Be 'Verb' $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + + $target = '1 | ForEach-Object Equals { function Verb {} }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "respects caller scope for parenthesized literal callbacks" { + $target = 'function private:Verb {}; 1 | ForEach-Object -Process ({ Verb })' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + + It "does not find functions outside module-qualified thread jobs" -skip:($null -eq (Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)) { + $target = 'function Verb {}; ThreadJob\Start-ThreadJob -ScriptBlock { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } It "does not treat an explicitly disabled NoNewScope switch as caller-scoped" { From 53709647c83c9a9986c5d3a65f3b200c56b44210 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:40:26 +0000 Subject: [PATCH 15/27] Fix AvoidAlias scope resolution review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 70 ++++++++++++++++++++++++--- Tests/Rules/AvoidUsingAlias.tests.ps1 | 28 +++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 2cc8979d9..20e203055 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -486,7 +486,7 @@ private int GetNamedBlockExecutionPhase(Ast ast, out FunctionDefinitionAst conta containingFunction = null; for (Ast current = ast; current != null; current = current.Parent) { - if (namedBlock == null && current is NamedBlockAst block) + if (current is NamedBlockAst block) { namedBlock = block; } @@ -702,7 +702,8 @@ private bool IsCallerScopedScriptBlock( } if (commandAst.InvocationOperator == TokenKind.Dot - && commandAst.CommandElements[0] == scriptBlock) + && commandAst.CommandElements.Count > 0 + && UnwrapParenthesizedExpression(commandAst.CommandElements[0]) == scriptBlock) { result = true; } @@ -741,6 +742,19 @@ private bool IsCallerScopedScriptBlock( return result; } + private Ast UnwrapParenthesizedExpression(Ast ast) + { + while (ast is ParenExpressionAst parenthesizedExpression + && parenthesizedExpression.Pipeline is PipelineAst pipeline + && pipeline.PipelineElements.Count == 1 + && pipeline.PipelineElements[0] is CommandExpressionAst commandExpression) + { + ast = commandExpression.Expression; + } + + return ast; + } + private bool IsIsolatedRunspaceScriptBlock( ScriptBlockAst scope, FunctionAnalysisContext functionAnalysis) @@ -1037,7 +1051,7 @@ private bool HasPositionalCommandParameter( CommandElementAst commandElement = commandAst.CommandElements[i]; if (!(commandElement is CommandParameterAst parameter)) { - if (IsPositionalParameter(parameters, positionalIndex, parameterName)) + if (IsPositionalParameter(parameters, positionalIndex, parameterName, commandElement)) { return true; } @@ -1065,12 +1079,52 @@ private bool HasPositionalCommandParameter( private bool IsPositionalParameter( Dictionary parameters, int position, - string parameterName) + string parameterName, + Ast argument) { - return parameters.Values.Any(parameter => - String.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase) - && parameter.Attributes.OfType() - .Any(attribute => attribute.Position == position)); + ParameterMetadata targetParameter = parameters.Values.FirstOrDefault(parameter => + String.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase)); + if (targetParameter == null) + { + return false; + } + + Type argumentType = (argument as ExpressionAst)?.StaticType; + if (argumentType == null) + { + return false; + } + + IEnumerable targetAttributes = targetParameter.Attributes + .OfType() + .Where(attribute => attribute.Position == position); + foreach (ParameterMetadata parameter in parameters.Values) + { + Type parameterType = parameter.ParameterType.IsArray + ? parameter.ParameterType.GetElementType() + : parameter.ParameterType; + if (!parameterType.IsAssignableFrom(argumentType)) + { + continue; + } + + IEnumerable parameterSets = parameter.Attributes + .OfType() + .Where(attribute => attribute.Position == position) + .Select(attribute => attribute.ParameterSetName); + if (targetAttributes.Select(attribute => attribute.ParameterSetName) + .Any(targetSet => parameterSets.Any(parameterSet => + String.IsNullOrEmpty(targetSet) + || String.IsNullOrEmpty(parameterSet) + || targetSet == "__AllParameterSets" + || parameterSet == "__AllParameterSets" + || String.Equals(targetSet, parameterSet, StringComparison.OrdinalIgnoreCase)))) + { + return true; + } + } + + return false; } private ParameterMetadata ResolveParameter( diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index f0822c605..a80d0b9e1 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -124,6 +124,15 @@ gci -Path C:\ $violations.Count | Should -Be 0 } + It "respects function execution order through dot-sourced blocks in named blocks" { + $target = 'function Invoke-Example { process { Verb } begin { . { function Verb {} } } }; Invoke-Example' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "respects execution order across ForEach-Object callback phases" -TestCases @( @{ Target = '1 | ForEach-Object -Process { Verb } -Begin { function Verb {} }' @@ -172,6 +181,19 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "uses caller scope for parenthesized dot-sourced blocks and declarations" { + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + foreach ($target in @( + 'function private:Verb {}; . ({ Verb })', + '. ({ function private:Verb {}; Verb }); Verb' + )) { + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + } + It "recognizes caller scope for local Invoke-Command NoNewScope callbacks" { $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } $target = 'function private:Verb {}; Invoke-Command -NoNewScope -ScriptBlock { Verb }' @@ -374,6 +396,12 @@ Script Test { $violations.Count | Should -Be 0 $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + + $target = 'function private:Verb {}; 1 | ForEach-Object {} {} { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } It "resolves abbreviated and colon-attached parallel callback parameters" -skip:($PSVersionTable.PSVersion.Major -lt 7) { From e472dde1c565f3bd03c2b2f7cdeb4eb84f670aac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:48:06 +0000 Subject: [PATCH 16/27] Address AvoidAlias review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 104 +++++++++++++++++++------- Tests/Rules/AvoidUsingAlias.tests.ps1 | 62 +++++++++++++++ 2 files changed, 141 insertions(+), 25 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 20e203055..901091a16 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -417,10 +417,10 @@ private bool IsFunctionDeclaredAfterCommand( return false; } - int functionNamedBlockPhase = GetNamedBlockExecutionPhase(function, out FunctionDefinitionAst functionOwner); - int commandNamedBlockPhase = GetNamedBlockExecutionPhase(cmdAst, out FunctionDefinitionAst commandOwner); - if (functionOwner != null - && functionOwner == commandOwner + int functionNamedBlockPhase = GetNamedBlockExecutionPhase(function, out ScriptBlockAst functionExecutionBlock); + int commandNamedBlockPhase = GetNamedBlockExecutionPhase(cmdAst, out ScriptBlockAst commandExecutionBlock); + if (functionExecutionBlock != null + && functionExecutionBlock == commandExecutionBlock && functionNamedBlockPhase >= 0 && commandNamedBlockPhase >= 0 && functionNamedBlockPhase != commandNamedBlockPhase) @@ -456,7 +456,10 @@ private bool IsImmediatelyInvokedChildScope( } CommandAst command = GetScriptBlockCommand(scriptBlock); - if (command == null || command.InvocationOperator != TokenKind.Ampersand) + if (command == null + || command.InvocationOperator != TokenKind.Ampersand + || command.CommandElements.Count == 0 + || UnwrapParenthesizedExpression(command.CommandElements[0]) != scriptBlock) { return false; } @@ -480,10 +483,10 @@ private bool IsImmediatelyInvokedChildScope( return true; } - private int GetNamedBlockExecutionPhase(Ast ast, out FunctionDefinitionAst containingFunction) + private int GetNamedBlockExecutionPhase(Ast ast, out ScriptBlockAst containingExecutionBlock) { NamedBlockAst namedBlock = null; - containingFunction = null; + containingExecutionBlock = null; for (Ast current = ast; current != null; current = current.Parent) { if (current is NamedBlockAst block) @@ -493,12 +496,18 @@ private int GetNamedBlockExecutionPhase(Ast ast, out FunctionDefinitionAst conta if (current != ast && current is FunctionDefinitionAst function) { - containingFunction = function; + containingExecutionBlock = function.Body; + break; + } + + if (current is ScriptBlockAst scriptBlock && scriptBlock.Parent == null) + { + containingExecutionBlock = scriptBlock; break; } } - if (namedBlock == null || containingFunction == null) + if (namedBlock == null || containingExecutionBlock == null) { return -1; } @@ -668,15 +677,43 @@ private ScriptBlockAst GetFunctionScope( for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { if (parent is ScriptBlockAst scope - && !IsCallerScopedScriptBlock(scope, functionAnalysis)) + && GetJobScriptBlockScope(scope) is ScriptBlockAst jobScriptBlock) { - return scope; + return jobScriptBlock; + } + + if (parent is ScriptBlockAst functionScope + && !IsCallerScopedScriptBlock(functionScope, functionAnalysis)) + { + return functionScope; } } return null; } + private ScriptBlockAst GetJobScriptBlockScope(ScriptBlockAst scope) + { + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock) + || !(GetScriptBlockCommand(scriptBlock) is CommandAst commandAst) + || !IsJobCommand(GetCallbackCommandName(commandAst)) + || !IsParameter( + GetScriptBlockParameterName(commandAst, scriptBlock), + "InitializationScript")) + { + return null; + } + + return commandAst.FindAll(ast => ast is ScriptBlockExpressionAst, true) + .OfType() + .Where(candidate => candidate != scriptBlock + && IsParameter( + GetScriptBlockParameterName(commandAst, candidate), + "ScriptBlock")) + .Select(candidate => candidate.ScriptBlock) + .FirstOrDefault(); + } + private bool IsCallerScopedScriptBlock( ScriptBlockAst scope, FunctionAnalysisContext functionAnalysis) @@ -773,12 +810,19 @@ private bool IsIsolatedRunspaceScriptBlock( } CommandAst commandAst = GetScriptBlockCommand(scriptBlock); + bool isDscScriptProperty = false; if (commandAst == null) { commandAst = GetPrecedingDscScriptPropertyCommand(scriptBlock); + isDscScriptProperty = commandAst != null; + } + else + { + isDscScriptProperty = IsDscScriptPropertyCommand(commandAst); } - if (commandAst == null || IsScriptDefinedFunction(commandAst, functionAnalysis)) + if (commandAst == null + || (!isDscScriptProperty && IsScriptDefinedFunction(commandAst, functionAnalysis))) { functionAnalysis.IsolatedRunspaceScriptBlockCache[scope] = result; return result; @@ -790,8 +834,7 @@ private bool IsIsolatedRunspaceScriptBlock( { result = true; } - else if ((String.Equals(commandName, "Start-Job", StringComparison.OrdinalIgnoreCase) - || String.Equals(commandName, "Start-ThreadJob", StringComparison.OrdinalIgnoreCase)) + else if (IsJobCommand(commandName) && (IsParameter(parameterName, "ScriptBlock") || IsParameter(parameterName, "InitializationScript"))) { @@ -809,17 +852,33 @@ private bool IsIsolatedRunspaceScriptBlock( } else { - result = (String.Equals(commandName, "GetScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(commandName, "TestScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(commandName, "SetScript", StringComparison.OrdinalIgnoreCase)) - && commandAst.CommandElements.Count > 1 - && String.Equals(commandAst.CommandElements[1].ToString(), "=", StringComparison.Ordinal); + result = isDscScriptProperty; } functionAnalysis.IsolatedRunspaceScriptBlockCache[scope] = result; return result; } + private bool IsDscScriptPropertyCommand(CommandAst commandAst) + { + if (commandAst.CommandElements.Count < 2 + || !String.Equals(commandAst.CommandElements[1].ToString(), "=", StringComparison.Ordinal)) + { + return false; + } + + string propertyName = commandAst.GetCommandName(); + return String.Equals(propertyName, "GetScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(propertyName, "TestScript", StringComparison.OrdinalIgnoreCase) + || String.Equals(propertyName, "SetScript", StringComparison.OrdinalIgnoreCase); + } + + private bool IsJobCommand(string commandName) + { + return String.Equals(commandName, "Start-Job", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "Start-ThreadJob", StringComparison.OrdinalIgnoreCase); + } + private CommandAst GetPrecedingDscScriptPropertyCommand(ScriptBlockExpressionAst scriptBlock) { if (!(scriptBlock.Parent is CommandExpressionAst commandExpression) @@ -850,12 +909,7 @@ private CommandAst GetPrecedingDscScriptPropertyCommand(ScriptBlockExpressionAst return null; } - string propertyName = propertyCommand.GetCommandName(); - return String.Equals(propertyName, "GetScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(propertyName, "TestScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(propertyName, "SetScript", StringComparison.OrdinalIgnoreCase) - ? propertyCommand - : null; + return IsDscScriptPropertyCommand(propertyCommand) ? propertyCommand : null; } return null; diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index a80d0b9e1..a03563346 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -117,6 +117,15 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not treat script-block arguments to call-operator commands as immediately invoked" { + $target = 'function Save-Callback { param($Action) $script:cb = $Action }; & Save-Callback { Verb }; function Verb {}; & $script:cb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "respects function begin and process execution order" { $target = 'function Invoke-Example { process { Verb } begin { function Verb {} } }; Invoke-Example' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -124,6 +133,15 @@ gci -Path C:\ $violations.Count | Should -Be 0 } + It "respects script-level begin and process execution order" { + $target = 'param() process { Verb } begin { function Verb {} }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "respects function execution order through dot-sourced blocks in named blocks" { $target = 'function Invoke-Example { process { Verb } begin { . { function Verb {} } } }; Invoke-Example' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -224,6 +242,26 @@ Script Test { $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "keeps DSC script properties isolated when property names are functions" { + foreach ($property in @('GetScript', 'TestScript', 'SetScript')) { + $targets = @( + "function $property {}; Script Test { $property = { Verb } }" + "function $property {}; Script Test {`n $property =`n { Verb }`n}" + ) + + foreach ($target in $targets) { + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('Verb }', 'Get-Verb }')) + } + } + } + It "respects caller scope in callbacks" -TestCases @( @{ Command = 'ForEach-Object' } @{ Command = 'Where-Object' } @@ -316,6 +354,30 @@ Script Test { $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "makes job initialization functions visible only to the same job script block" { + $target = 'Start-Job -InitializationScript { function Verb {} } -ScriptBlock { Verb }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('; Verb', '; Get-Verb')) + } + + It "makes thread-job initialization functions visible only to the same job script block" -skip:($null -eq (Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)) { + $target = 'Start-ThreadJob -InitializationScript { function Verb {} } -ScriptBlock { Verb }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('; Verb', '; Get-Verb')) + } + It "respects visible script-defined commands before callback scope rules" -skip:($PSVersionTable.PSVersion.Major -lt 7) { $target = 'function ForEach-Object { param([scriptblock]$Parallel) & $Parallel }; function Verb {}; ForEach-Object -Parallel { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From a44400142d590f2fa9796ad4c1332bf1314c4480 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:41:47 +0000 Subject: [PATCH 17/27] Fix job initializer function scope analysis Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 48 +++++++++++++++++++++++++-- Tests/Rules/AvoidUsingAlias.tests.ps1 | 36 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 901091a16..ef9178d2f 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -410,6 +410,20 @@ private bool IsFunctionDeclaredAfterCommand( return false; } + ScriptBlockAst initializationScope = GetJobInitializationScope(function, functionAnalysis, functionScope); + if (initializationScope != null) + { + if (commandScope == functionScope) + { + return false; + } + + if (commandScope == initializationScope) + { + functionScope = initializationScope; + } + } + CommandAst immediateInvocation = null; if (commandScope != functionScope && !IsImmediatelyInvokedChildScope(commandScope, functionScope, out immediateInvocation)) @@ -612,6 +626,11 @@ private FunctionAnalysisContext GetFunctionNameIndex(Ast ast) if (scope != null) { AddFunctionToIndex(functionNameIndex, scope, function); + ScriptBlockAst initializationScope = GetJobInitializationScope(function, functionAnalysis, scope); + if (initializationScope != null) + { + AddFunctionToIndex(functionNameIndex, initializationScope, function); + } } } @@ -677,7 +696,7 @@ private ScriptBlockAst GetFunctionScope( for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { if (parent is ScriptBlockAst scope - && GetJobScriptBlockScope(scope) is ScriptBlockAst jobScriptBlock) + && GetJobScriptBlockScope(scope, functionAnalysis) is ScriptBlockAst jobScriptBlock) { return jobScriptBlock; } @@ -692,11 +711,14 @@ private ScriptBlockAst GetFunctionScope( return null; } - private ScriptBlockAst GetJobScriptBlockScope(ScriptBlockAst scope) + private ScriptBlockAst GetJobScriptBlockScope( + ScriptBlockAst scope, + FunctionAnalysisContext functionAnalysis) { if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock) || !(GetScriptBlockCommand(scriptBlock) is CommandAst commandAst) || !IsJobCommand(GetCallbackCommandName(commandAst)) + || IsScriptDefinedFunction(commandAst, functionAnalysis) || !IsParameter( GetScriptBlockParameterName(commandAst, scriptBlock), "InitializationScript")) @@ -714,6 +736,28 @@ private ScriptBlockAst GetJobScriptBlockScope(ScriptBlockAst scope) .FirstOrDefault(); } + private ScriptBlockAst GetJobInitializationScope( + FunctionDefinitionAst function, + FunctionAnalysisContext functionAnalysis, + ScriptBlockAst jobScriptBlock) + { + if (jobScriptBlock == null) + { + return null; + } + + for (Ast parent = function.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst scope + && GetJobScriptBlockScope(scope, functionAnalysis) == jobScriptBlock) + { + return scope; + } + } + + return null; + } + private bool IsCallerScopedScriptBlock( ScriptBlockAst scope, FunctionAnalysisContext functionAnalysis) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index a03563346..1d78f2bea 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -378,6 +378,42 @@ Script Test { Should -Be ($target.Replace('; Verb', '; Get-Verb')) } + It "preserves initializer-local function lookup and declaration order for job callbacks" { + $commands = @('Start-Job') + if ($null -ne (Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)) { + $commands += 'Start-ThreadJob' + } + + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + foreach ($command in $commands) { + $target = "$command -InitializationScript { function Verb {}; Verb } -ScriptBlock { Verb }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + + $target = "$command -InitializationScript { Verb; function Verb {} } -ScriptBlock { Verb }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.StartOffset | Should -Be $target.IndexOf('Verb;') + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('{ Verb;', '{ Get-Verb;')) + } + } + + It "does not transfer initializer functions across a script-defined job command" { + $target = 'function Start-Job { param($InitializationScript, $ScriptBlock) & $InitializationScript; & $ScriptBlock }; Start-Job -InitializationScript { function Verb {} } -ScriptBlock { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('-ScriptBlock { Verb }', '-ScriptBlock { Get-Verb }')) + } + It "respects visible script-defined commands before callback scope rules" -skip:($PSVersionTable.PSVersion.Major -lt 7) { $target = 'function ForEach-Object { param([scriptblock]$Parallel) & $Parallel }; function Verb {}; ForEach-Object -Parallel { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From 29e17563e16a87b1cbbff64368d17ecfceb1215a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:28:34 +0000 Subject: [PATCH 18/27] Handle nested initialized job calls Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 2 +- Tests/Rules/AvoidUsingAlias.tests.ps1 | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index ef9178d2f..dced657f9 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -413,7 +413,7 @@ private bool IsFunctionDeclaredAfterCommand( ScriptBlockAst initializationScope = GetJobInitializationScope(function, functionAnalysis, functionScope); if (initializationScope != null) { - if (commandScope == functionScope) + if (commandScope == functionScope || IsDescendantOf(commandScope, functionScope)) { return false; } diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 1d78f2bea..5cee0dd52 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -392,6 +392,12 @@ Script Test { $violations.Count | Should -Be 0 Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + $target = "$command -ScriptBlock { & { Verb } } -InitializationScript { function Verb {} }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + $target = "$command -InitializationScript { Verb; function Verb {} } -ScriptBlock { Verb }" $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From 69b69878625abc63d78807a15bcdde01a9c733e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:11:19 +0000 Subject: [PATCH 19/27] Fix AvoidAlias review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 162 ++++++++++++++++++++------ Tests/Rules/AvoidUsingAlias.tests.ps1 | 43 +++++++ 2 files changed, 167 insertions(+), 38 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index dced657f9..f0bec7356 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -464,37 +464,48 @@ private bool IsImmediatelyInvokedChildScope( out CommandAst invocation) { invocation = null; - if (!(commandScope.Parent is ScriptBlockExpressionAst scriptBlock)) + ScriptBlockAst currentScope = commandScope; + while (currentScope != functionScope) { - return false; - } + if (!(currentScope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + return false; + } - CommandAst command = GetScriptBlockCommand(scriptBlock); - if (command == null - || command.InvocationOperator != TokenKind.Ampersand - || command.CommandElements.Count == 0 - || UnwrapParenthesizedExpression(command.CommandElements[0]) != scriptBlock) - { - return false; - } + CommandAst command = GetScriptBlockCommand(scriptBlock); + if (command == null + || command.InvocationOperator != TokenKind.Ampersand + || command.CommandElements.Count == 0 + || UnwrapParenthesizedExpression(command.CommandElements[0]) != scriptBlock) + { + return false; + } - ScriptBlockAst parentScope = null; - for (Ast parent = commandScope.Parent; parent != null; parent = parent.Parent) - { - if (parent is ScriptBlockAst scope) + ScriptBlockAst parentScope = null; + for (Ast parent = currentScope.Parent; parent != null; parent = parent.Parent) { - parentScope = scope; - break; + if (parent is ScriptBlockAst scope) + { + parentScope = scope; + break; + } } - } - if (parentScope != functionScope) - { - return false; + if (parentScope == null) + { + return false; + } + + if (parentScope == functionScope) + { + invocation = command; + return true; + } + + currentScope = parentScope; } - invocation = command; - return true; + return false; } private int GetNamedBlockExecutionPhase(Ast ast, out ScriptBlockAst containingExecutionBlock) @@ -514,7 +525,8 @@ private int GetNamedBlockExecutionPhase(Ast ast, out ScriptBlockAst containingEx break; } - if (current is ScriptBlockAst scriptBlock && scriptBlock.Parent == null) + if (current is ScriptBlockAst scriptBlock + && (scriptBlock.Parent == null || IsDirectlyInvokedScriptBlock(scriptBlock))) { containingExecutionBlock = scriptBlock; break; @@ -539,6 +551,20 @@ private int GetNamedBlockExecutionPhase(Ast ast, out ScriptBlockAst containingEx } } + private bool IsDirectlyInvokedScriptBlock(ScriptBlockAst scope) + { + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + return false; + } + + CommandAst command = GetScriptBlockCommand(scriptBlock); + return command != null + && command.InvocationOperator == TokenKind.Ampersand + && command.CommandElements.Count > 0 + && UnwrapParenthesizedExpression(command.CommandElements[0]) == scriptBlock; + } + private int GetForEachObjectExecutionPhase(Ast ast, out CommandAst callbackCommand) { callbackCommand = null; @@ -593,7 +619,7 @@ private FunctionAnalysisContext GetFunctionNameIndex(Ast ast) foreach (FunctionDefinitionAst function in functionDefinitions) { - if (IsInConstantFalseBranch(function)) + if (IsInConstantUnreachableBranch(function)) { continue; } @@ -608,7 +634,7 @@ private FunctionAnalysisContext GetFunctionNameIndex(Ast ast) var previousScopes = new Dictionary(); foreach (FunctionDefinitionAst function in functionDefinitions) { - if (!IsInConstantFalseBranch(function)) + if (!IsInConstantUnreachableBranch(function)) { previousScopes[function] = GetNearestFunctionScope(function); } @@ -857,12 +883,12 @@ private bool IsIsolatedRunspaceScriptBlock( bool isDscScriptProperty = false; if (commandAst == null) { - commandAst = GetPrecedingDscScriptPropertyCommand(scriptBlock); + commandAst = GetPrecedingDscScriptPropertyCommand(scriptBlock, functionAnalysis); isDscScriptProperty = commandAst != null; } else { - isDscScriptProperty = IsDscScriptPropertyCommand(commandAst); + isDscScriptProperty = IsDscScriptPropertyCommand(commandAst, functionAnalysis); } if (commandAst == null @@ -903,7 +929,9 @@ private bool IsIsolatedRunspaceScriptBlock( return result; } - private bool IsDscScriptPropertyCommand(CommandAst commandAst) + private bool IsDscScriptPropertyCommand( + CommandAst commandAst, + FunctionAnalysisContext functionAnalysis) { if (commandAst.CommandElements.Count < 2 || !String.Equals(commandAst.CommandElements[1].ToString(), "=", StringComparison.Ordinal)) @@ -912,9 +940,25 @@ private bool IsDscScriptPropertyCommand(CommandAst commandAst) } string propertyName = commandAst.GetCommandName(); - return String.Equals(propertyName, "GetScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(propertyName, "TestScript", StringComparison.OrdinalIgnoreCase) - || String.Equals(propertyName, "SetScript", StringComparison.OrdinalIgnoreCase); + if (!String.Equals(propertyName, "GetScript", StringComparison.OrdinalIgnoreCase) + && !String.Equals(propertyName, "TestScript", StringComparison.OrdinalIgnoreCase) + && !String.Equals(propertyName, "SetScript", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + for (Ast parent = commandAst.Parent; parent != null; parent = parent.Parent) + { + if (parent is CommandAst resource + && String.Equals(resource.GetCommandName(), "Script", StringComparison.OrdinalIgnoreCase) + && GetDscResourceBody(resource) != null + && IsDscResourceCommand(resource, functionAnalysis)) + { + return true; + } + } + + return false; } private bool IsJobCommand(string commandName) @@ -923,7 +967,9 @@ private bool IsJobCommand(string commandName) || String.Equals(commandName, "Start-ThreadJob", StringComparison.OrdinalIgnoreCase); } - private CommandAst GetPrecedingDscScriptPropertyCommand(ScriptBlockExpressionAst scriptBlock) + private CommandAst GetPrecedingDscScriptPropertyCommand( + ScriptBlockExpressionAst scriptBlock, + FunctionAnalysisContext functionAnalysis) { if (!(scriptBlock.Parent is CommandExpressionAst commandExpression) || !(commandExpression.Parent is PipelineAst pipeline)) @@ -953,7 +999,7 @@ private CommandAst GetPrecedingDscScriptPropertyCommand(ScriptBlockExpressionAst return null; } - return IsDscScriptPropertyCommand(propertyCommand) ? propertyCommand : null; + return IsDscScriptPropertyCommand(propertyCommand, functionAnalysis) ? propertyCommand : null; } return null; @@ -1510,7 +1556,7 @@ private bool IsWhereObjectCommand(string commandName) return String.Equals(commandName, "Where-Object", StringComparison.OrdinalIgnoreCase); } - private bool IsInConstantFalseBranch(FunctionDefinitionAst function) + private bool IsInConstantUnreachableBranch(FunctionDefinitionAst function) { for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { @@ -1519,13 +1565,35 @@ private bool IsInConstantFalseBranch(FunctionDefinitionAst function) continue; } - foreach (Tuple clause in ifStatement.Clauses) + int containingClauseIndex = -1; + for (int i = 0; i < ifStatement.Clauses.Count; i++) { - if (IsDescendantOf(function, clause.Item2) && IsConstantFalseCondition(clause.Item1)) + Tuple clause = ifStatement.Clauses[i]; + if (IsDescendantOf(function, clause.Item2)) { - return true; + if (IsConstantFalseCondition(clause.Item1)) + { + return true; + } + + containingClauseIndex = i; + break; } } + + if (containingClauseIndex >= 0 + && ifStatement.Clauses.Take(containingClauseIndex) + .Any(clause => IsConstantTrueCondition(clause.Item1))) + { + return true; + } + + if (ifStatement.ElseClause != null + && IsDescendantOf(function, ifStatement.ElseClause) + && ifStatement.Clauses.Any(clause => IsConstantTrueCondition(clause.Item1))) + { + return true; + } } return false; @@ -1562,6 +1630,24 @@ private bool IsConstantFalseCondition(PipelineBaseAst condition) } } + private bool IsConstantTrueCondition(PipelineBaseAst condition) + { + ExpressionAst expression = condition.GetPureExpression(); + if (expression == null) + { + return false; + } + + try + { + return LanguagePrimitives.IsTrue(expression.SafeGetValue()); + } + catch (InvalidOperationException) + { + return false; + } + } + private ScriptBlockExpressionAst GetDscResourceBody(CommandAst cmdAst) { if (cmdAst.InvocationOperator != TokenKind.Unknown diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 5cee0dd52..732f268b8 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -466,6 +466,36 @@ Script Test { $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "checks declaration order through nested directly invoked blocks" { + $target = '& { & { Verb } }; function Verb {}' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('& { & { Verb } }', '& { & { Get-Verb } }')) + } + + It "preserves named-block phases in independently invoked script blocks" { + $target = '& { process { Verb } begin { function Verb {} } }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + + It "does not treat ordinary function assignments as DSC script properties" { + $target = 'function Verb {}; function SetScript { param($Op, [scriptblock]$Action) & $Action }; SetScript = { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "respects caller scope for script blocks inside array callback arguments" { $target = 'function private:Verb {}; 1 | ForEach-Object -Process {}, { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -543,6 +573,19 @@ Script Test { $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not index functions in statically unreachable else and elseif branches" { + foreach ($target in @( + 'if ($true) {} else { function Verb {} }; Verb' + 'if ($true) {} elseif ($true) { function Verb {} }; Verb' + )) { + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + } + It "does not classify Where-Object comparison values as executable callbacks" { $target = 'Where-Object -Property Name -EQ { function Verb {} }; Verb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From 4a96313ebf7acdc103c5b5f9bc84f3f0bebe37ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:55:43 +0000 Subject: [PATCH 20/27] Recognize DSC Script resource on macOS Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index f0bec7356..6205ebafa 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -334,6 +334,7 @@ private bool IsDscResourceCommand( return !IsScriptDefinedFunction(cmdAst, functionAnalysis) && String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(commandName)) && (String.Equals(commandName, "Node", StringComparison.OrdinalIgnoreCase) + || String.Equals(commandName, "Script", StringComparison.OrdinalIgnoreCase) || Helper.Instance.GetCommandInfo(commandName) == null); } From 18fedd1c33fad291b0645cf25f839d24f6cb1e69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:56:13 +0000 Subject: [PATCH 21/27] Fix AvoidAlias scope review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 31 +++++++++++++++++++++---- Tests/Rules/AvoidUsingAlias.tests.ps1 | 33 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 6205ebafa..1cda8975c 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -291,13 +291,20 @@ private bool IsDscResourceDeclaration( private bool IsFollowingLineDscResourceBody( ScriptBlockExpressionAst body, FunctionAnalysisContext functionAnalysis) + { + return GetFollowingLineDscResourceDeclaration(body, functionAnalysis) != null; + } + + private CommandAst GetFollowingLineDscResourceDeclaration( + ScriptBlockExpressionAst body, + FunctionAnalysisContext functionAnalysis) { if (!(body.Parent is CommandExpressionAst bodyExpression) || !(bodyExpression.Parent is PipelineAst bodyPipeline) || bodyPipeline.PipelineElements.Count != 1 || bodyPipeline.PipelineElements[0] != bodyExpression) { - return false; + return null; } Ast statement = bodyPipeline; @@ -316,14 +323,16 @@ private bool IsFollowingLineDscResourceBody( || declarationPipeline.PipelineElements.Count != 1 || !(declarationPipeline.PipelineElements[0] is CommandAst declaration)) { - return false; + return null; } return GetDscResourceBody(declaration) == body - && IsDscResourceCommand(declaration, functionAnalysis); + && IsDscResourceCommand(declaration, functionAnalysis) + ? declaration + : null; } - return false; + return null; } private bool IsDscResourceCommand( @@ -433,7 +442,8 @@ private bool IsFunctionDeclaredAfterCommand( } int functionNamedBlockPhase = GetNamedBlockExecutionPhase(function, out ScriptBlockAst functionExecutionBlock); - int commandNamedBlockPhase = GetNamedBlockExecutionPhase(cmdAst, out ScriptBlockAst commandExecutionBlock); + int commandNamedBlockPhase = GetNamedBlockExecutionPhase( + immediateInvocation ?? cmdAst, out ScriptBlockAst commandExecutionBlock); if (functionExecutionBlock != null && functionExecutionBlock == commandExecutionBlock && functionNamedBlockPhase >= 0 @@ -957,6 +967,17 @@ private bool IsDscScriptPropertyCommand( { return true; } + + if (parent is CommandExpressionAst bodyExpression + && bodyExpression.Expression is ScriptBlockExpressionAst body) + { + CommandAst followingLineResource = GetFollowingLineDscResourceDeclaration(body, functionAnalysis); + if (followingLineResource != null + && String.Equals(followingLineResource.GetCommandName(), "Script", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } } return false; diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 732f268b8..7780f5276 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -620,6 +620,15 @@ Script Test { Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "uses the immediate invocation block to resolve named-block scope" { + $target = 'param() process { & { Verb } } begin { function Verb {} }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "does not find functions outside module-qualified thread jobs" -skip:($null -eq (Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)) { $target = 'function Verb {}; ThreadJob\Start-ThreadJob -ScriptBlock { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -759,6 +768,30 @@ $scriptFunction @($rule.AnalyzeScript($configuration, $null)).Count | Should -Be 0 } + It "recognizes Script resource properties when the opening brace is on the following line" { + $target = @' +{ + function Verb {} + Script Test + { + SetScript = { Verb } + } +} +'@ + $ast = [System.Management.Automation.Language.Parser]::ParseInput($target, [ref]$null, [ref]$null) + $body = $ast.EndBlock.Statements[0].PipelineElements[0].Expression.Copy() + $configuration = [System.Management.Automation.Language.ConfigurationDefinitionAst]::new( + $ast.Extent, $body, 'Resource', + [System.Management.Automation.Language.StringConstantExpressionAst]::new($ast.Extent, 'Test', 'BareWord')) + $rule = [Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules.AvoidAlias]::new() + + $violations = @($rule.AnalyzeScript($configuration, $null)) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "does not index functions in constant-false branches when identifying DSC resources" { $target = @' { From aec74d7067e04f94450ec069ee1ecec4ed007d4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:25:53 +0000 Subject: [PATCH 22/27] Fix AvoidAlias review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 154 +++++++++++++++++++++++++- Tests/Rules/AvoidUsingAlias.tests.ps1 | 59 ++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 1cda8975c..deb9e0a3a 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -384,6 +384,7 @@ private bool IsScriptDefinedFunction( && functionNames.TryGetValue(commandName, out List functionDefinitions) && functionDefinitions.Any(function => !IsDescendantOf(function, cmdAst) + && !IsFunctionDeclaredInSamePipelineCallback(function, cmdAst) && (!function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase) || currentScope == scope) && !IsFunctionDeclaredAfterCommand(function, cmdAst, currentScope, functionAnalysis))) @@ -453,6 +454,20 @@ private bool IsFunctionDeclaredAfterCommand( return functionNamedBlockPhase > commandNamedBlockPhase; } + CommandAst dotInvocation = GetDotSourcedInvocation(function, functionScope); + if (dotInvocation != null && functionExecutionBlock != commandExecutionBlock) + { + int callerNamedBlockPhase = GetNamedBlockExecutionPhase( + dotInvocation, out ScriptBlockAst callerExecutionBlock); + if (callerExecutionBlock == commandExecutionBlock + && callerNamedBlockPhase >= 0 + && commandNamedBlockPhase >= 0 + && callerNamedBlockPhase != commandNamedBlockPhase) + { + return callerNamedBlockPhase > commandNamedBlockPhase; + } + } + int functionPhase = GetForEachObjectExecutionPhase(function, out CommandAst functionCallback); int commandPhase = GetForEachObjectExecutionPhase(cmdAst, out CommandAst commandCallback); if (functionCallback == commandCallback @@ -466,7 +481,60 @@ private bool IsFunctionDeclaredAfterCommand( int commandExecutionOffset = immediateInvocation == null ? cmdAst.Extent.StartOffset : immediateInvocation.Extent.StartOffset; - return function.Extent.StartOffset > commandExecutionOffset; + int functionExecutionOffset = dotInvocation != null && functionExecutionBlock != commandExecutionBlock + ? dotInvocation.Extent.StartOffset + : function.Extent.StartOffset; + return functionExecutionOffset > commandExecutionOffset; + } + + private bool IsFunctionDeclaredInSamePipelineCallback(FunctionDefinitionAst function, CommandAst cmdAst) + { + GetForEachObjectExecutionPhase(function, out CommandAst callbackCommand); + if (callbackCommand == null + || cmdAst == callbackCommand + || IsDescendantOf(cmdAst, callbackCommand)) + { + return false; + } + + PipelineAst callbackPipeline = GetContainingPipeline(callbackCommand); + return callbackPipeline != null && callbackPipeline == GetContainingPipeline(cmdAst); + } + + private PipelineAst GetContainingPipeline(Ast ast) + { + for (Ast parent = ast; parent != null; parent = parent.Parent) + { + if (parent is PipelineAst pipeline) + { + return pipeline; + } + } + + return null; + } + + private CommandAst GetDotSourcedInvocation(FunctionDefinitionAst function, ScriptBlockAst functionScope) + { + for (Ast parent = function.Parent; parent != null && parent != functionScope; parent = parent.Parent) + { + if (!(parent is ScriptBlockAst scope) + || !(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + continue; + } + + CommandAst command = GetScriptBlockCommand(scriptBlock); + if (command != null + && command.InvocationOperator == TokenKind.Dot + && command.CommandElements.Count > 0 + && UnwrapParenthesizedExpression(command.CommandElements[0]) == scriptBlock) + { + return command; + } + } + + return null; } private bool IsImmediatelyInvokedChildScope( @@ -485,7 +553,8 @@ private bool IsImmediatelyInvokedChildScope( CommandAst command = GetScriptBlockCommand(scriptBlock); if (command == null - || command.InvocationOperator != TokenKind.Ampersand + || (command.InvocationOperator != TokenKind.Ampersand + && command.InvocationOperator != TokenKind.Dot) || command.CommandElements.Count == 0 || UnwrapParenthesizedExpression(command.CommandElements[0]) != scriptBlock) { @@ -571,7 +640,8 @@ private bool IsDirectlyInvokedScriptBlock(ScriptBlockAst scope) CommandAst command = GetScriptBlockCommand(scriptBlock); return command != null - && command.InvocationOperator == TokenKind.Ampersand + && (command.InvocationOperator == TokenKind.Ampersand + || command.InvocationOperator == TokenKind.Dot) && command.CommandElements.Count > 0 && UnwrapParenthesizedExpression(command.CommandElements[0]) == scriptBlock; } @@ -730,6 +800,22 @@ private ScriptBlockAst GetFunctionScope( FunctionDefinitionAst function, FunctionAnalysisContext functionAnalysis) { + if (function.Name.StartsWith("script:", StringComparison.OrdinalIgnoreCase) + || function.Name.StartsWith("global:", StringComparison.OrdinalIgnoreCase)) + { + ScriptBlockAst scope = GetNearestFunctionScope(function); + ScriptBlockAst parentScope; + while (scope != null && TryGetInvokedParentScope(scope, out parentScope)) + { + scope = parentScope; + } + + if (scope != null && scope != GetNearestFunctionScope(function)) + { + return scope; + } + } + for (Ast parent = function.Parent; parent != null; parent = parent.Parent) { if (parent is ScriptBlockAst scope @@ -748,6 +834,36 @@ private ScriptBlockAst GetFunctionScope( return null; } + private bool TryGetInvokedParentScope(ScriptBlockAst scope, out ScriptBlockAst parentScope) + { + parentScope = null; + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) + { + return false; + } + + CommandAst command = GetScriptBlockCommand(scriptBlock); + if (command == null + || (command.InvocationOperator != TokenKind.Ampersand + && command.InvocationOperator != TokenKind.Dot) + || command.CommandElements.Count == 0 + || UnwrapParenthesizedExpression(command.CommandElements[0]) != scriptBlock) + { + return false; + } + + for (Ast parent = scope.Parent; parent != null; parent = parent.Parent) + { + if (parent is ScriptBlockAst enclosingScope) + { + parentScope = enclosingScope; + return true; + } + } + + return false; + } + private ScriptBlockAst GetJobScriptBlockScope( ScriptBlockAst scope, FunctionAnalysisContext functionAnalysis) @@ -962,8 +1078,7 @@ private bool IsDscScriptPropertyCommand( { if (parent is CommandAst resource && String.Equals(resource.GetCommandName(), "Script", StringComparison.OrdinalIgnoreCase) - && GetDscResourceBody(resource) != null - && IsDscResourceCommand(resource, functionAnalysis)) + && IsDscScriptPropertyInResourceBody(commandAst, resource, functionAnalysis)) { return true; } @@ -975,7 +1090,7 @@ private bool IsDscScriptPropertyCommand( if (followingLineResource != null && String.Equals(followingLineResource.GetCommandName(), "Script", StringComparison.OrdinalIgnoreCase)) { - return true; + return IsDscScriptPropertyInResourceBody(commandAst, followingLineResource, functionAnalysis); } } } @@ -983,6 +1098,33 @@ private bool IsDscScriptPropertyCommand( return false; } + private bool IsDscScriptPropertyInResourceBody( + CommandAst propertyCommand, + CommandAst resource, + FunctionAnalysisContext functionAnalysis) + { + ScriptBlockExpressionAst body = GetDscResourceBody(resource); + if (body == null || !IsDscResourceCommand(resource, functionAnalysis)) + { + return false; + } + + for (Ast parent = propertyCommand.Parent; parent != null; parent = parent.Parent) + { + if (parent == body.ScriptBlock) + { + return true; + } + + if (parent is ScriptBlockAst) + { + return false; + } + } + + return false; + } + private bool IsJobCommand(string commandName) { return String.Equals(commandName, "Start-Job", StringComparison.OrdinalIgnoreCase) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 7780f5276..9c9ace497 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -117,6 +117,18 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not resolve callback declarations for other commands in the same pipeline" { + $target = '1 | ForEach-Object -Begin { function Verb {} } -Process { $_ } | Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('} | Verb', '} | Get-Verb')) + } + It "does not treat script-block arguments to call-operator commands as immediately invoked" { $target = 'function Save-Callback { param($Action) $script:cb = $Action }; & Save-Callback { Verb }; function Verb {}; & $script:cb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -190,6 +202,23 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "indexes explicit script and global functions declared in immediately invoked blocks" { + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + foreach ($scope in @('script', 'global')) { + $target = "& { function ${scope}:Verb {} }; Verb" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + + $target = 'function Register-Commands { & { function script:Verb {} } }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "uses caller scope for parameterized dot-sourced blocks" { $target = 'function private:Verb {}; . { param($value) Verb } 1' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -476,6 +505,14 @@ Script Test { $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be ($target.Replace('& { & { Verb } }', '& { & { Get-Verb } }')) + + $target = '. { & { Verb } }; function Verb {}' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('{ Verb }', '{ Get-Verb }')) } It "preserves named-block phases in independently invoked script blocks" { @@ -487,6 +524,15 @@ Script Test { Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "preserves named-block phases in dot-invoked script blocks" { + $target = '. { process { Verb } begin { function Verb {} } }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "does not treat ordinary function assignments as DSC script properties" { $target = 'function Verb {}; function SetScript { param($Op, [scriptblock]$Action) & $Action }; SetScript = { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -496,6 +542,19 @@ Script Test { Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "does not isolate nested script properties inside executable DSC callbacks" { + foreach ($target in @( + 'Script Test { SetScript = { function Verb {}; function SetScript { param($op, $action) & $action }; SetScript = { Verb } } }' + "Script Test`n{`n SetScript = { function Verb {}; function SetScript { param(`$op, `$action) & `$action }; SetScript = { Verb } }`n}" + )) { + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + } + It "respects caller scope for script blocks inside array callback arguments" { $target = 'function private:Verb {}; 1 | ForEach-Object -Process {}, { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From ba09c25cca323fc228290704de5f43292c9ec1a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:22:17 +0000 Subject: [PATCH 23/27] Fix AvoidAlias invocation phase ordering Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 29 +++++++++++++++++---------- Tests/Rules/AvoidUsingAlias.tests.ps1 | 25 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index deb9e0a3a..59bce8463 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -443,8 +443,12 @@ private bool IsFunctionDeclaredAfterCommand( } int functionNamedBlockPhase = GetNamedBlockExecutionPhase(function, out ScriptBlockAst functionExecutionBlock); + GetNamedBlockExecutionPhase(cmdAst, out ScriptBlockAst commandOwnExecutionBlock); + bool sharesExecutionBlock = functionExecutionBlock != null + && functionExecutionBlock == commandOwnExecutionBlock; int commandNamedBlockPhase = GetNamedBlockExecutionPhase( - immediateInvocation ?? cmdAst, out ScriptBlockAst commandExecutionBlock); + sharesExecutionBlock ? cmdAst : immediateInvocation ?? cmdAst, + out ScriptBlockAst commandExecutionBlock); if (functionExecutionBlock != null && functionExecutionBlock == commandExecutionBlock && functionNamedBlockPhase >= 0 @@ -454,11 +458,11 @@ private bool IsFunctionDeclaredAfterCommand( return functionNamedBlockPhase > commandNamedBlockPhase; } - CommandAst dotInvocation = GetDotSourcedInvocation(function, functionScope); - if (dotInvocation != null && functionExecutionBlock != commandExecutionBlock) + CommandAst functionScopeInvocation = GetFunctionScopeInvocation(function, functionScope); + if (functionScopeInvocation != null && functionExecutionBlock != commandExecutionBlock) { int callerNamedBlockPhase = GetNamedBlockExecutionPhase( - dotInvocation, out ScriptBlockAst callerExecutionBlock); + functionScopeInvocation, out ScriptBlockAst callerExecutionBlock); if (callerExecutionBlock == commandExecutionBlock && callerNamedBlockPhase >= 0 && commandNamedBlockPhase >= 0 @@ -478,11 +482,12 @@ private bool IsFunctionDeclaredAfterCommand( return functionPhase > commandPhase; } - int commandExecutionOffset = immediateInvocation == null + int commandExecutionOffset = immediateInvocation == null || sharesExecutionBlock ? cmdAst.Extent.StartOffset : immediateInvocation.Extent.StartOffset; - int functionExecutionOffset = dotInvocation != null && functionExecutionBlock != commandExecutionBlock - ? dotInvocation.Extent.StartOffset + int functionExecutionOffset = functionScopeInvocation != null + && functionExecutionBlock != commandExecutionBlock + ? functionScopeInvocation.Extent.StartOffset : function.Extent.StartOffset; return functionExecutionOffset > commandExecutionOffset; } @@ -514,8 +519,9 @@ private PipelineAst GetContainingPipeline(Ast ast) return null; } - private CommandAst GetDotSourcedInvocation(FunctionDefinitionAst function, ScriptBlockAst functionScope) + private CommandAst GetFunctionScopeInvocation(FunctionDefinitionAst function, ScriptBlockAst functionScope) { + CommandAst invocation = null; for (Ast parent = function.Parent; parent != null && parent != functionScope; parent = parent.Parent) { if (!(parent is ScriptBlockAst scope) @@ -526,15 +532,16 @@ private CommandAst GetDotSourcedInvocation(FunctionDefinitionAst function, Scrip CommandAst command = GetScriptBlockCommand(scriptBlock); if (command != null - && command.InvocationOperator == TokenKind.Dot + && (command.InvocationOperator == TokenKind.Ampersand + || command.InvocationOperator == TokenKind.Dot) && command.CommandElements.Count > 0 && UnwrapParenthesizedExpression(command.CommandElements[0]) == scriptBlock) { - return command; + invocation = command; } } - return null; + return invocation; } private bool IsImmediatelyInvokedChildScope( diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 9c9ace497..fe3b59d6b 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -219,6 +219,31 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "preserves declarations and calls in the same immediately invoked scope" { + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + foreach ($scope in @('script', 'global')) { + $target = "& { function ${scope}:Verb {}; Verb }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + } + + It "respects named-block order across nested invoked scopes" -TestCases @( + @{ Target = 'param() process { Verb } begin { . { . { function Verb {} } } }' } + @{ Target = 'param() process { Verb } begin { & { function script:Verb {} } }' } + @{ Target = 'param() process { Verb } begin { & { function global:Verb {} } }' } + ) { + param($Target) + + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $Target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $Target -Settings $settings | Should -Be $Target + } + It "uses caller scope for parameterized dot-sourced blocks" { $target = 'function private:Verb {}; . { param($value) Verb } 1' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From a51fb19891c6ec52281d18bcac59ad4ae468ae40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:18:47 +0000 Subject: [PATCH 24/27] Fix AvoidAlias nested invocation ordering Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 8 ++++++++ Tests/Rules/AvoidUsingAlias.tests.ps1 | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 59bce8463..987004810 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -485,8 +485,16 @@ private bool IsFunctionDeclaredAfterCommand( int commandExecutionOffset = immediateInvocation == null || sharesExecutionBlock ? cmdAst.Extent.StartOffset : immediateInvocation.Extent.StartOffset; + bool sharesInvocationScope = immediateInvocation != null + && immediateInvocation == functionScopeInvocation; + if (sharesInvocationScope) + { + commandExecutionOffset = cmdAst.Extent.StartOffset; + } + int functionExecutionOffset = functionScopeInvocation != null && functionExecutionBlock != commandExecutionBlock + && !sharesInvocationScope ? functionScopeInvocation.Extent.StartOffset : function.Extent.StartOffset; return functionExecutionOffset > commandExecutionOffset; diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index fe3b59d6b..1eb7481be 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -230,6 +230,25 @@ gci -Path C:\ } } + It "respects declaration order across nested invoked scopes" { + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + foreach ($scope in @('script', 'global')) { + $target = "& { & { Verb }; function ${scope}:Verb {} }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be "& { & { Get-Verb }; function ${scope}:Verb {} }" + + $target = "& { function ${scope}:Verb {}; & { Verb } }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + } + It "respects named-block order across nested invoked scopes" -TestCases @( @{ Target = 'param() process { Verb } begin { . { . { function Verb {} } } }' } @{ Target = 'param() process { Verb } begin { & { function script:Verb {} } }' } From ab40eebc455960cd57aa58e6e23ff7386bd0ea8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:24:14 +0000 Subject: [PATCH 25/27] Fix AvoidAlias scope ordering review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 182 ++++++++++++++++++++++---- Tests/Rules/AvoidUsingAlias.tests.ps1 | 88 +++++++++++++ 2 files changed, 248 insertions(+), 22 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 987004810..8d3ce9237 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -351,17 +351,12 @@ private bool IsScriptDefinedFunction( CommandAst cmdAst, FunctionAnalysisContext functionAnalysis) { - string commandName = cmdAst.GetCommandName(); + string commandName = GetCallbackCommandName(cmdAst); if (commandName == null) { return false; } - if (!String.IsNullOrEmpty(Helper.Instance.GetCmdletNameFromAlias(commandName))) - { - return false; - } - if (functionAnalysis.ScriptDefinedFunctionCache.TryGetValue(cmdAst, out bool? cachedResult)) { return cachedResult.GetValueOrDefault(); @@ -384,7 +379,7 @@ private bool IsScriptDefinedFunction( && functionNames.TryGetValue(commandName, out List functionDefinitions) && functionDefinitions.Any(function => !IsDescendantOf(function, cmdAst) - && !IsFunctionDeclaredInSamePipelineCallback(function, cmdAst) + && !IsFunctionDeclaredInSamePipelineCallback(function, cmdAst, functionAnalysis) && (!function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase) || currentScope == scope) && !IsFunctionDeclaredAfterCommand(function, cmdAst, currentScope, functionAnalysis))) @@ -433,15 +428,31 @@ private bool IsFunctionDeclaredAfterCommand( { functionScope = initializationScope; } + else if (IsDescendantOf(commandScope, initializationScope)) + { + functionScope = initializationScope; + } } CommandAst immediateInvocation = null; if (commandScope != functionScope - && !IsImmediatelyInvokedChildScope(commandScope, functionScope, out immediateInvocation)) + && !IsImmediatelyInvokedChildScope(commandScope, functionScope, functionAnalysis, out immediateInvocation)) { return false; } + if (TryGetNearestSharedExecutionBlock(function, cmdAst, functionAnalysis, out ScriptBlockAst sharedExecutionBlock)) + { + int sharedFunctionPhase = GetNamedBlockExecutionPhase(function, sharedExecutionBlock); + int sharedCommandPhase = GetNamedBlockExecutionPhase(cmdAst, sharedExecutionBlock); + if (sharedFunctionPhase >= 0 + && sharedCommandPhase >= 0 + && sharedFunctionPhase != sharedCommandPhase) + { + return sharedFunctionPhase > sharedCommandPhase; + } + } + int functionNamedBlockPhase = GetNamedBlockExecutionPhase(function, out ScriptBlockAst functionExecutionBlock); GetNamedBlockExecutionPhase(cmdAst, out ScriptBlockAst commandOwnExecutionBlock); bool sharesExecutionBlock = functionExecutionBlock != null @@ -500,9 +511,12 @@ private bool IsFunctionDeclaredAfterCommand( return functionExecutionOffset > commandExecutionOffset; } - private bool IsFunctionDeclaredInSamePipelineCallback(FunctionDefinitionAst function, CommandAst cmdAst) + private bool IsFunctionDeclaredInSamePipelineCallback( + FunctionDefinitionAst function, + CommandAst cmdAst, + FunctionAnalysisContext functionAnalysis) { - GetForEachObjectExecutionPhase(function, out CommandAst callbackCommand); + CommandAst callbackCommand = GetCallerScopedCallbackCommand(function, functionAnalysis); if (callbackCommand == null || cmdAst == callbackCommand || IsDescendantOf(cmdAst, callbackCommand)) @@ -555,6 +569,7 @@ private CommandAst GetFunctionScopeInvocation(FunctionDefinitionAst function, Sc private bool IsImmediatelyInvokedChildScope( ScriptBlockAst commandScope, ScriptBlockAst functionScope, + FunctionAnalysisContext functionAnalysis, out CommandAst invocation) { invocation = null; @@ -567,11 +582,14 @@ private bool IsImmediatelyInvokedChildScope( } CommandAst command = GetScriptBlockCommand(scriptBlock); - if (command == null - || (command.InvocationOperator != TokenKind.Ampersand - && command.InvocationOperator != TokenKind.Dot) - || command.CommandElements.Count == 0 - || UnwrapParenthesizedExpression(command.CommandElements[0]) != scriptBlock) + bool directlyInvoked = command != null + && (command.InvocationOperator == TokenKind.Ampersand + || command.InvocationOperator == TokenKind.Dot) + && command.CommandElements.Count > 0 + && UnwrapParenthesizedExpression(command.CommandElements[0]) == scriptBlock; + if (!directlyInvoked + && !IsCallerScopedScriptBlock(currentScope, functionAnalysis) + && !IsSynchronousInvokeCommandScriptBlock(currentScope, functionAnalysis)) { return false; } @@ -697,11 +715,121 @@ private int GetForEachObjectExecutionPhase(Ast ast, out CommandAst callbackComma callbackCommand = commandAst; return 2; } + + if (IsParameter(parameterName, "RemainingScripts")) + { + callbackCommand = commandAst; + return 3; + } } return -1; } + private bool TryGetNearestSharedExecutionBlock( + Ast first, + Ast second, + FunctionAnalysisContext functionAnalysis, + out ScriptBlockAst executionBlock) + { + var firstBlocks = new Dictionary(); + int depth = 0; + for (Ast current = first; current != null; current = current.Parent, depth++) + { + if (current is ScriptBlockAst scope && IsExecutionBlock(scope, functionAnalysis)) + { + firstBlocks[scope] = depth; + } + } + + executionBlock = null; + int nearestDistance = Int32.MaxValue; + depth = 0; + for (Ast current = second; current != null; current = current.Parent, depth++) + { + if (current is ScriptBlockAst scope + && firstBlocks.TryGetValue(scope, out int firstDepth) + && firstDepth + depth < nearestDistance) + { + executionBlock = scope; + nearestDistance = firstDepth + depth; + } + } + + return executionBlock != null; + } + + private bool IsExecutionBlock(ScriptBlockAst scope, FunctionAnalysisContext functionAnalysis) + { + return scope.Parent == null + || scope.Parent is FunctionDefinitionAst + || IsDirectlyInvokedScriptBlock(scope) + || IsCallerScopedScriptBlock(scope, functionAnalysis) + || IsSynchronousInvokeCommandScriptBlock(scope, functionAnalysis); + } + + private int GetNamedBlockExecutionPhase(Ast ast, ScriptBlockAst executionBlock) + { + for (Ast current = ast; current != null && current != executionBlock; current = current.Parent) + { + if (current is NamedBlockAst namedBlock && namedBlock.Parent == executionBlock) + { + return GetNamedBlockExecutionPhase(namedBlock); + } + } + + return -1; + } + + private int GetNamedBlockExecutionPhase(NamedBlockAst namedBlock) + { + switch (namedBlock.BlockKind) + { + case TokenKind.Begin: + return 0; + case TokenKind.Process: + return 1; + case TokenKind.End: + return 2; + default: + return -1; + } + } + + private CommandAst GetCallerScopedCallbackCommand(Ast ast, FunctionAnalysisContext functionAnalysis) + { + for (Ast current = ast; current != null; current = current.Parent) + { + if (current is ScriptBlockAst scope + && scope.Parent is ScriptBlockExpressionAst scriptBlock) + { + CommandAst command = GetScriptBlockCommand(scriptBlock); + if (command != null && IsCallerScopedScriptBlock(scope, functionAnalysis)) + { + return command; + } + } + } + + return null; + } + + private bool IsSynchronousInvokeCommandScriptBlock( + ScriptBlockAst scope, + FunctionAnalysisContext functionAnalysis) + { + if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock) + || !(GetScriptBlockCommand(scriptBlock) is CommandAst command)) + { + return false; + } + + return IsInvokeCommand(GetCallbackCommandName(command)) + && IsParameter(GetScriptBlockParameterName(command, scriptBlock), "ScriptBlock") + && !IsScriptDefinedFunction(command, functionAnalysis) + && !IsRemoteInvokeCommand(command); + } + private FunctionAnalysisContext GetFunctionNameIndex(Ast ast) { FunctionDefinitionAst[] functionDefinitions = ast.FindAll( @@ -820,13 +948,18 @@ private ScriptBlockAst GetFunctionScope( { ScriptBlockAst scope = GetNearestFunctionScope(function); ScriptBlockAst parentScope; - while (scope != null && TryGetInvokedParentScope(scope, out parentScope)) + while (scope != null && TryGetInvokedParentScope(scope, functionAnalysis, out parentScope)) { scope = parentScope; } if (scope != null && scope != GetNearestFunctionScope(function)) { + if (GetJobScriptBlockScope(scope, functionAnalysis) is ScriptBlockAst jobScriptBlock) + { + return jobScriptBlock; + } + return scope; } } @@ -849,7 +982,10 @@ private ScriptBlockAst GetFunctionScope( return null; } - private bool TryGetInvokedParentScope(ScriptBlockAst scope, out ScriptBlockAst parentScope) + private bool TryGetInvokedParentScope( + ScriptBlockAst scope, + FunctionAnalysisContext functionAnalysis, + out ScriptBlockAst parentScope) { parentScope = null; if (!(scope.Parent is ScriptBlockExpressionAst scriptBlock)) @@ -858,11 +994,13 @@ private bool TryGetInvokedParentScope(ScriptBlockAst scope, out ScriptBlockAst p } CommandAst command = GetScriptBlockCommand(scriptBlock); - if (command == null - || (command.InvocationOperator != TokenKind.Ampersand - && command.InvocationOperator != TokenKind.Dot) - || command.CommandElements.Count == 0 - || UnwrapParenthesizedExpression(command.CommandElements[0]) != scriptBlock) + bool directlyInvoked = command != null + && (command.InvocationOperator == TokenKind.Ampersand + || command.InvocationOperator == TokenKind.Dot) + && command.CommandElements.Count > 0 + && UnwrapParenthesizedExpression(command.CommandElements[0]) == scriptBlock; + if (!directlyInvoked + && !IsCallerScopedScriptBlock(scope, functionAnalysis)) { return false; } diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 1eb7481be..be064529e 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -41,6 +41,18 @@ Describe "AvoidUsingAlias" { $violations[0].SuggestedCorrections[0].Text | Should -Be 'Invoke-Command' } + It "resolves aliases to script-defined callback commands" { + $target = 'function Invoke-Command { param($ComputerName, $ScriptBlock) & $ScriptBlock }; function Verb {}; icm x { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'icm' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Invoke-Command' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | + Should -Be ($target.Replace('icm', 'Invoke-Command')) + } + It "still adds the Get prefix to ordinary commands with resource-shaped arguments" { $violations = Invoke-ScriptAnalyzer -ScriptDefinition 'Verb Argument {}' -IncludeRule $violationName $violations.Count | Should -Be 1 @@ -129,6 +141,19 @@ gci -Path C:\ Should -Be ($target.Replace('} | Verb', '} | Get-Verb')) } + It "does not resolve declarations from other caller-scoped callbacks in the same pipeline" -TestCases @( + @{ Target = '1 | Where-Object { function Verb {}; $true } | Verb' } + @{ Target = '1 | . { function Verb {} } | Verb' } + ) { + param($Target) + + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $Target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "does not treat script-block arguments to call-operator commands as immediately invoked" { $target = 'function Save-Callback { param($Action) $script:cb = $Action }; & Save-Callback { Verb }; function Verb {}; & $script:cb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -136,6 +161,11 @@ gci -Path C:\ $violations.Count | Should -Be 0 $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + + $target = 'function Invoke-Command { param($ScriptBlock) $script:cb = $ScriptBlock }; Invoke-Command -ScriptBlock { Verb }; function Verb {}; & $script:cb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 } It "respects function begin and process execution order" { @@ -163,6 +193,35 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "respects invocation order for synchronous script-block callbacks" -TestCases @( + @{ + Target = 'Invoke-Command -ScriptBlock { Verb }; function Verb {}' + ExpectedViolations = 1 + } + @{ + Target = '1 | ForEach-Object -Begin { & { Verb } } -Process { function Verb {} }' + ExpectedViolations = 1 + } + ) { + param($Target, $ExpectedViolations) + + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $Target -IncludeRule $violationName) + + $violations.Count | Should -Be $ExpectedViolations + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "compares named-block phases at the nearest shared execution block" -TestCases @( + @{ Target = '. { process { & { Verb } } begin { function script:Verb {} } }' } + @{ Target = '& { process { Verb } begin { & { function script:Verb {} } } }' } + ) { + param($Target) + + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $Target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + } + It "respects execution order across ForEach-Object callback phases" -TestCases @( @{ Target = '1 | ForEach-Object -Process { Verb } -Begin { function Verb {} }' @@ -219,6 +278,15 @@ gci -Path C:\ $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "resolves explicit script functions declared in caller-scoped callbacks" { + $target = '& { 1 | ForEach-Object { function script:Verb {} } }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "preserves declarations and calls in the same immediately invoked scope" { $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } foreach ($scope in @('script', 'global')) { @@ -481,6 +549,19 @@ Script Test { } } + It "maps global functions from nested job initializers to the job body" { + $target = 'Start-Job -InitializationScript { & { function global:Verb {} } } -ScriptBlock { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + + $target = 'Start-Job -InitializationScript { & { Verb; function global:Verb {} } } -ScriptBlock { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.StartOffset | Should -Be $target.IndexOf('Verb;') + } + It "does not transfer initializer functions across a script-defined job command" { $target = 'function Start-Job { param($InitializationScript, $ScriptBlock) & $InitializationScript; & $ScriptBlock }; Start-Job -InitializationScript { function Verb {} } -ScriptBlock { Verb }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -641,6 +722,13 @@ Script Test { Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "resolves declarations before RemainingScripts callbacks" { + $target = '1 | ForEach-Object -RemainingScripts { Verb } -Begin { function Verb {} } -Process { $_ }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + } + It "resolves abbreviated and colon-attached parallel callback parameters" -skip:($PSVersionTable.PSVersion.Major -lt 7) { foreach ($parameter in @('-Pa { Verb }', '-Pa:{ Verb }')) { $target = "function Verb {}; 1 | ForEach-Object $parameter" From d830cbf7b9366a501942c0bedae6eeb40d5cc17a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:23:28 +0000 Subject: [PATCH 26/27] Fix AvoidAlias module and callback ordering Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 10 ++++++++-- Tests/Rules/AvoidUsingAlias.tests.ps1 | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 8d3ce9237..7b70dab0a 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -351,6 +351,12 @@ private bool IsScriptDefinedFunction( CommandAst cmdAst, FunctionAnalysisContext functionAnalysis) { + string originalCommandName = cmdAst.GetCommandName(); + if (originalCommandName != null && originalCommandName.IndexOf('\\') >= 0) + { + return false; + } + string commandName = GetCallbackCommandName(cmdAst); if (commandName == null) { @@ -713,13 +719,13 @@ private int GetForEachObjectExecutionPhase(Ast ast, out CommandAst callbackComma if (IsParameter(parameterName, "End")) { callbackCommand = commandAst; - return 2; + return 3; } if (IsParameter(parameterName, "RemainingScripts")) { callbackCommand = commandAst; - return 3; + return 2; } } diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index be064529e..df3757654 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -729,6 +729,15 @@ Script Test { $violations.Count | Should -Be 0 } + It "resolves declarations from RemainingScripts callbacks before End callbacks" { + $target = '1 | ForEach-Object -Begin {} -Process { $_ } -RemainingScripts { function Verb {} } -End { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "resolves abbreviated and colon-attached parallel callback parameters" -skip:($PSVersionTable.PSVersion.Major -lt 7) { foreach ($parameter in @('-Pa { Verb }', '-Pa:{ Verb }')) { $target = "function Verb {}; 1 | ForEach-Object $parameter" @@ -746,6 +755,15 @@ Script Test { $violations.Count | Should -Be 0 } + It "does not resolve module-qualified callbacks to local functions" -skip:($PSVersionTable.PSVersion.Major -lt 7) { + $target = 'function ForEach-Object {}; function Verb {}; 1 | Microsoft.PowerShell.Core\ForEach-Object -Parallel { Verb }' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "does not index script blocks passed as ForEach-Object input data" { $target = 'ForEach-Object -InputObject { function Verb {} } -Process { $_ }; Verb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) From 679dd5a20dfa3c8a44bf14c17f8a289d1e44dcbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:56:25 +0000 Subject: [PATCH 27/27] Fix AvoidAlias scope handling review findings Co-authored-by: bergmeister <9250262+bergmeister@users.noreply.github.com> --- Rules/AvoidAlias.cs | 20 ++++++++---- Tests/Rules/AvoidUsingAlias.tests.ps1 | 46 +++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/Rules/AvoidAlias.cs b/Rules/AvoidAlias.cs index 7b70dab0a..ad19ae295 100644 --- a/Rules/AvoidAlias.cs +++ b/Rules/AvoidAlias.cs @@ -666,7 +666,10 @@ private int GetNamedBlockExecutionPhase(Ast ast, out ScriptBlockAst containingEx case TokenKind.End: return 2; default: - return -1; + return String.Equals( + namedBlock.BlockKind.ToString(), + "Clean", + StringComparison.OrdinalIgnoreCase) ? 3 : -1; } } @@ -798,7 +801,10 @@ private int GetNamedBlockExecutionPhase(NamedBlockAst namedBlock) case TokenKind.End: return 2; default: - return -1; + return String.Equals( + namedBlock.BlockKind.ToString(), + "Clean", + StringComparison.OrdinalIgnoreCase) ? 3 : -1; } } @@ -1006,7 +1012,8 @@ private bool TryGetInvokedParentScope( && command.CommandElements.Count > 0 && UnwrapParenthesizedExpression(command.CommandElements[0]) == scriptBlock; if (!directlyInvoked - && !IsCallerScopedScriptBlock(scope, functionAnalysis)) + && !IsCallerScopedScriptBlock(scope, functionAnalysis) + && !IsSynchronousInvokeCommandScriptBlock(scope, functionAnalysis)) { return false; } @@ -1351,6 +1358,7 @@ private CommandAst GetScriptBlockCommand(ScriptBlockExpressionAst scriptBlock) } if (current.Parent is ParenExpressionAst + || current.Parent is SubExpressionAst || current.Parent is CommandExpressionAst || current.Parent is PipelineAst || current.Parent is StatementBlockAst) @@ -1747,7 +1755,8 @@ private bool IsRemoteInvokeCommand(CommandAst commandAst) "HostName", "VMId", "VMName", - "ContainerId" + "ContainerId", + "SSHConnection" }; foreach (CommandParameterAst parameter in commandAst.CommandElements.OfType()) @@ -1777,8 +1786,7 @@ private bool IsRemoteInvokeCommand(CommandAst commandAst) continue; } - bool isScriptBlock = element is ScriptBlockExpressionAst - || element.FindAll(candidate => candidate is ScriptBlockExpressionAst, true).Any(); + bool isScriptBlock = UnwrapParenthesizedExpression(element) is ScriptBlockExpressionAst; if (!isScriptBlock && remoteTargetParameters.Any(name => parameters.TryGetValue(name, out ParameterMetadata metadata) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index df3757654..d9641fb70 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -368,6 +368,15 @@ gci -Path C:\ Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } + It "resolves globally scoped functions defined in synchronous local Invoke-Command blocks" { + $target = 'Invoke-Command -ScriptBlock { function global:Verb {} }; Verb' + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + It "does not treat a newline-separated DSC script property as caller-scoped" { $target = @' function Verb {}; @@ -495,6 +504,24 @@ Script Test { $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' } + It "does not find functions outside SSHConnection invocations" -skip:($null -eq (Get-Command Invoke-Command).Parameters['SSHConnection']) { + $target = "function Verb {}; Invoke-Command -SSHConnection @{ HostName = 'server' } -ScriptBlock { Verb }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + + It "does not resolve local functions inside remote callbacks with computed targets" { + $target = "function Verb {}; Invoke-Command (& { 'localhost' }) -ScriptBlock { Verb }" + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 1 + $violations[0].Extent.Text | Should -Be 'Verb' + $violations[0].SuggestedCorrections[0].Text | Should -Be 'Get-Verb' + } + It "makes job initialization functions visible only to the same job script block" { $target = 'Start-Job -InitializationScript { function Verb {} } -ScriptBlock { Verb }; Verb' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) @@ -821,7 +848,20 @@ Script Test { } It "respects caller scope for parenthesized literal callbacks" { - $target = 'function private:Verb {}; 1 | ForEach-Object -Process ({ Verb })' + $settings = @{ Rules = @{ PSAvoidUsingCmdletAliases = @{ Enable = $true } } } + foreach ($target in @( + 'function private:Verb {}; 1 | ForEach-Object -Process ({ Verb })', + 'function private:Verb {}; 1 | ForEach-Object -Process $({ Verb })' + )) { + $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) + + $violations.Count | Should -Be 0 + Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target + } + } + + It "uses the immediate invocation block to resolve named-block scope" { + $target = 'param() process { & { Verb } } begin { function Verb {} }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) $violations.Count | Should -Be 0 @@ -829,8 +869,8 @@ Script Test { Invoke-Formatter -ScriptDefinition $target -Settings $settings | Should -Be $target } - It "uses the immediate invocation block to resolve named-block scope" { - $target = 'param() process { & { Verb } } begin { function Verb {} }' + It "recognizes clean blocks as executing after end blocks" -skip:($PSVersionTable.PSVersion -lt [version]'7.3') { + $target = '& { clean { Verb } begin { function Verb {} } }' $violations = @(Invoke-ScriptAnalyzer -ScriptDefinition $target -IncludeRule $violationName) $violations.Count | Should -Be 0