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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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" {