From 1f068d3e914aaf860497959e06500b9dec6bfe4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 8 Oct 2026 15:24:41 +0200 Subject: [PATCH 1/2] Respect scope-qualified local function declarations --- Engine/DotSourceScope.cs | 2 +- Engine/LocalFunctionScope.cs | 62 +++++++++-- .../ScopedDotSourceResolution.tests.ps1 | 50 +++++++++ .../ScopedLocalFunctionResolution.tests.ps1 | 101 ++++++++++++++++++ 4 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 Tests/Engine/ScopedDotSourceResolution.tests.ps1 create mode 100644 Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 diff --git a/Engine/DotSourceScope.cs b/Engine/DotSourceScope.cs index 9f79c86f5..d14df75c5 100644 --- a/Engine/DotSourceScope.cs +++ b/Engine/DotSourceScope.cs @@ -121,7 +121,7 @@ internal static DotSourceScope Build(IReadOnlyList filePaths, bool retai foreach (FunctionDefinitionAst function in ast.FindAll(node => node is FunctionDefinitionAst, true)) { - if (!string.IsNullOrWhiteSpace(function.Name)) + if (!string.IsNullOrWhiteSpace(function.Name) && LocalFunctionScope.IsVisibleAcrossFiles(function)) { functions.Add(function.Name); } diff --git a/Engine/LocalFunctionScope.cs b/Engine/LocalFunctionScope.cs index 2be074fd3..be6bcc37e 100644 --- a/Engine/LocalFunctionScope.cs +++ b/Engine/LocalFunctionScope.cs @@ -18,13 +18,16 @@ internal sealed class LocalFunctionScope private readonly Dictionary> definitionsByName; private readonly HashSet alsoInScopeNames; + private readonly HashSet privateInScopeNames; private LocalFunctionScope( Dictionary> definitionsByName, - HashSet alsoInScopeNames) + HashSet alsoInScopeNames, + HashSet privateInScopeNames) { this.definitionsByName = definitionsByName; this.alsoInScopeNames = alsoInScopeNames; + this.privateInScopeNames = privateInScopeNames; } internal static LocalFunctionScope FromAst(Ast ast) => FromAst(ast, null); @@ -41,43 +44,74 @@ internal static LocalFunctionScope FromAst(Ast ast, IEnumerable alsoInSc continue; } - if (!definitionsByName.TryGetValue(function.Name, out var definitions)) + string name = Helper.Instance.FunctionNameWithoutScope(function.Name); + if (!definitionsByName.TryGetValue(name, out var definitions)) { definitions = new List(); - definitionsByName[function.Name] = definitions; + definitionsByName[name] = definitions; } definitions.Add(function); } } var alsoInScopeNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var privateInScopeNames = new HashSet(StringComparer.OrdinalIgnoreCase); if (alsoInScope != null) { - alsoInScopeNames.UnionWith(alsoInScope); + foreach (string name in alsoInScope) + { + if (!string.IsNullOrWhiteSpace(name)) + { + var names = name.StartsWith("private:", StringComparison.OrdinalIgnoreCase) + ? privateInScopeNames : alsoInScopeNames; + names.Add(Helper.Instance.FunctionNameWithoutScope(name)); + } + } } - return new LocalFunctionScope(definitionsByName, alsoInScopeNames); + return new LocalFunctionScope(definitionsByName, alsoInScopeNames, privateInScopeNames); } /// - /// Returns the containing function of , or null if it is defined at the - /// top level of the file. A function nested inside another is only in scope for calls made from - /// within that containing function's body, not for the whole file. + /// Returns the enclosing function or directly invoked child script block, or null for file scope. + /// Dot-sourced blocks execute in the caller's scope rather than introducing a child scope. /// - private static Ast GetContainingFunction(FunctionDefinitionAst function) + private static Ast GetContainingScope(Ast node) { - for (Ast parent = function.Parent; parent != null; parent = parent.Parent) + for (Ast parent = node.Parent; parent != null; parent = parent.Parent) { if (parent is FunctionDefinitionAst enclosingFunction) { return enclosingFunction; } + if (parent is ScriptBlockAst block && block.Parent is ScriptBlockExpressionAst expression + && expression.Parent is CommandAst invocation && invocation.InvocationOperator == TokenKind.Ampersand) + { + return block; + } } return null; } + private static bool HasScriptWideScope(FunctionDefinitionAst function) => + function.Name.StartsWith("script:", StringComparison.OrdinalIgnoreCase) + || function.Name.StartsWith("global:", StringComparison.OrdinalIgnoreCase); + + // Dot-source groups must not flatten local/private definitions inside child scopes. + internal static bool IsVisibleAcrossFiles(FunctionDefinitionAst function) => + HasScriptWideScope(function) || GetContainingScope(function) == null; + private static bool IsVisibleTo(FunctionDefinitionAst function, Ast callSite) { - Ast container = GetContainingFunction(function); + if (HasScriptWideScope(function)) + { + return true; + } + + Ast container = GetContainingScope(function); + if (function.Name.StartsWith("private:", StringComparison.OrdinalIgnoreCase)) + { + return ReferenceEquals(container, GetContainingScope(callSite)); + } if (container == null) { // Defined at the top level of the file: visible everywhere in it. @@ -106,6 +140,12 @@ internal bool IsDefinedInScript(string commandName, Ast callSite = null) return true; } + if (privateInScopeNames.Contains(commandName) + && (callSite == null || GetContainingScope(callSite) == null)) + { + return true; + } + if (!definitionsByName.TryGetValue(commandName, out var definitions)) { return false; diff --git a/Tests/Engine/ScopedDotSourceResolution.tests.ps1 b/Tests/Engine/ScopedDotSourceResolution.tests.ps1 new file mode 100644 index 000000000..1d9374197 --- /dev/null +++ b/Tests/Engine/ScopedDotSourceResolution.tests.ps1 @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe 'Scoped declarations in dot-sourced groups' { + BeforeAll { + $casingSettings = @{ + IncludeRules = @('PSUseCorrectCasing') + Rules = @{ PSUseCorrectCasing = @{ Enable = $true; CheckKeyword = $false; CheckOperator = $false } } + } + function NewScopedWorkload { + param([string]$Definition, [string]$Caller) + $root = Join-Path $TestDrive ([IO.Path]::GetRandomFileName()) + $null = New-Item -Path $root -ItemType Directory + Set-Content -LiteralPath (Join-Path $root 'Definition.ps1') -Value $Definition -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'Caller.ps1') -Value $Caller -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'Root.psm1') -Value ". `$PSScriptRoot/Definition.ps1`n. `$PSScriptRoot/Caller.ps1" -Encoding utf8 + return $root + } + } + + It 'recognizes a dot-sourced declaration at file scope' -TestCases @( + @{ Scope = 'script' }, @{ Scope = 'global' }, @{ Scope = 'local' }, @{ Scope = 'private' } + ) { + param($Scope) + $root = NewScopedWorkload ('function ' + $Scope + ':Get-ChildItem { param($PATH) }') "Get-ChildItem -PATH 'x'" + Invoke-ScriptAnalyzer -Path $root -Recurse -Settings $casingSettings | Should -BeNullOrEmpty + } + + It 'does not inherit a dot-sourced private declaration into a child function' { + $root = NewScopedWorkload 'function private:Get-ChildItem { param($PATH) }' "function Child { Get-ChildItem -PATH 'x' }" + $diagnostics = @(Invoke-ScriptAnalyzer -Path $root -Recurse -Settings $casingSettings) + $diagnostics.Count | Should -Be 1 + $diagnostics[0].ScriptPath | Should -BeExactly (Join-Path $root 'Caller.ps1') + } + + It 'does not flatten an ordinary nested function into the dot-source group' { + $root = NewScopedWorkload 'function Outer { function Get-ChildItem { param($PATH) } }' "Get-ChildItem -PATH 'x'" + $diagnostics = @(Invoke-ScriptAnalyzer -Path $root -Recurse -Settings $casingSettings) + $diagnostics.Count | Should -Be 1 + $diagnostics[0].ScriptPath | Should -BeExactly (Join-Path $root 'Caller.ps1') + } + + It 'shares a nested declaration with the dot-source group' -TestCases @( + @{ Scope = 'script' }, @{ Scope = 'global' } + ) { + param($Scope) + $root = NewScopedWorkload ('function Outer { function ' + $Scope + ':Get-ChildItem { param($PATH) } }') "Outer`nGet-ChildItem -PATH 'x'" + Invoke-ScriptAnalyzer -Path $root -Recurse -Settings $casingSettings | Should -BeNullOrEmpty + } +} diff --git a/Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 b/Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 new file mode 100644 index 000000000..c71d965f3 --- /dev/null +++ b/Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe 'Scope-qualified function declarations' { + BeforeAll { + $casingSettings = @{ + IncludeRules = @('PSUseCorrectCasing') + Rules = @{ PSUseCorrectCasing = @{ Enable = $true; CheckKeyword = $false; CheckOperator = $false } } + } + } + + It 'recognizes a declaration in its defining scope' -TestCases @( + @{ Scope = 'script' }, @{ Scope = 'global' }, @{ Scope = 'local' }, @{ Scope = 'private' } + ) { + param($Scope) + $definition = 'function ' + $Scope + ':Get-ChildItem { param($PATH) }' + "`nGet-ChildItem -PATH 'x'" + Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings | Should -BeNullOrEmpty + } + + It 'recognizes a nested declaration outside the defining function' -TestCases @( + @{ Scope = 'script' }, @{ Scope = 'global' } + ) { + param($Scope) + $definition = 'function Outer { function ' + $Scope + ':Get-ChildItem { param($PATH) } }' + "`nOuter`nGet-ChildItem -PATH 'x'" + Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings | Should -BeNullOrEmpty + } + + It 'keeps a nested local declaration inside its containing function' { + $definition = @' +function Outer { + function local:Get-ChildItem { param($PATH) } + Get-ChildItem -PATH 'inside' +} +Get-ChildItem -PATH 'outside' +'@ + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings) + $diagnostics.Count | Should -Be 1 + $diagnostics[0].Extent.Text | Should -BeExactly '-PATH' + $diagnostics[0].Extent.StartLineNumber | Should -Be 5 + $diagnostics[0].SuggestedCorrections[0].Text | Should -BeExactly 'Path' + } + + It 'does not inherit a private declaration into a child function' { + $definition = @' +function private:Get-ChildItem { param($PATH) } +Get-ChildItem -PATH 'same' +function Child { Get-ChildItem -PATH 'child' } +'@ + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings) + $diagnostics.Count | Should -Be 1 + $diagnostics[0].Extent.StartLineNumber | Should -Be 3 + $diagnostics[0].SuggestedCorrections[0].Text | Should -BeExactly 'Path' + } + + It 'keeps a nested private declaration in its exact defining scope' { + $definition = @' +function Outer { + function private:Get-ChildItem { param($PATH) } + Get-ChildItem -PATH 'same' + function Child { Get-ChildItem -PATH 'child' } +} +Get-ChildItem -PATH 'outside' +'@ + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings) + $diagnostics.Count | Should -Be 2 + @($diagnostics.Extent.StartLineNumber | Sort-Object) | Should -Be @(4, 6) + } + + It 'preserves casing when formatting a scoped local definition' { + $definition = "function ScRiPt:get-childitem { param(`$PATH) }`nget-childitem -PATH 'x'" + Invoke-Formatter -ScriptDefinition $definition -Settings $casingSettings | Should -BeExactly $definition + } + + It 'does not borrow mandatory parameters from a cmdlet shadowed by a scoped function' { + Invoke-ScriptAnalyzer -ScriptDefinition "function script:Write-Warning { }`nWrite-Warning" -IncludeRule PSUseCmdletCorrectly | Should -BeNullOrEmpty + } + + It 'does not inherit a private declaration into a directly invoked child block' { + $definition = "function private:Get-ChildItem { param(`$PATH) }`nGet-ChildItem -PATH 'same'`n& { Get-ChildItem -PATH 'child' }" + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings) + $diagnostics.Count | Should -Be 1 + $diagnostics[0].Extent.StartLineNumber | Should -Be 3 + } + + It 'sees a private declaration from a dot-sourced block in the same scope' { + $definition = "function private:Get-ChildItem { param(`$PATH) }`n. { Get-ChildItem -PATH 'same' }" + Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings | Should -BeNullOrEmpty + } + + It 'sees a private declaration from a ForEach-Object block in the same scope' { + $definition = "function private:Get-ChildItem { param(`$PATH) }`n1 | ForEach-Object { Get-ChildItem -PATH 'same' }" + Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings | Should -BeNullOrEmpty + } + + It 'still validates a module-qualified cmdlet despite a scoped local declaration' { + $definition = "function script:Get-ChildItem { param(`$PATH) }`nMicrosoft.PowerShell.Management\Get-ChildItem -PATH 'x'" + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings) + $diagnostics.Count | Should -Be 1 + $diagnostics[0].SuggestedCorrections[0].Text | Should -BeExactly 'Path' + } +} From 4400463623b0d708fc41f3d0e4714a88756b65ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 8 Oct 2026 15:31:26 +0200 Subject: [PATCH 2/2] Keep command argument blocks in their caller scope --- Engine/LocalFunctionScope.cs | 3 ++- Tests/Engine/ScopedDotSourceResolution.tests.ps1 | 5 +++++ Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Engine/LocalFunctionScope.cs b/Engine/LocalFunctionScope.cs index be6bcc37e..1bd6cda6b 100644 --- a/Engine/LocalFunctionScope.cs +++ b/Engine/LocalFunctionScope.cs @@ -84,7 +84,8 @@ private static Ast GetContainingScope(Ast node) return enclosingFunction; } if (parent is ScriptBlockAst block && block.Parent is ScriptBlockExpressionAst expression - && expression.Parent is CommandAst invocation && invocation.InvocationOperator == TokenKind.Ampersand) + && expression.Parent is CommandAst invocation && invocation.InvocationOperator == TokenKind.Ampersand + && ReferenceEquals(invocation.CommandElements[0], expression)) { return block; } diff --git a/Tests/Engine/ScopedDotSourceResolution.tests.ps1 b/Tests/Engine/ScopedDotSourceResolution.tests.ps1 index 1d9374197..d5c9e2581 100644 --- a/Tests/Engine/ScopedDotSourceResolution.tests.ps1 +++ b/Tests/Engine/ScopedDotSourceResolution.tests.ps1 @@ -40,6 +40,11 @@ Describe 'Scoped declarations in dot-sourced groups' { $diagnostics[0].ScriptPath | Should -BeExactly (Join-Path $root 'Caller.ps1') } + It 'shares declarations made by an ampersand command argument block in the caller scope' { + $root = NewScopedWorkload '1 | & ForEach-Object { function Get-ChildItem { param($PATH) } }' "Get-ChildItem -PATH 'x'" + Invoke-ScriptAnalyzer -Path $root -Recurse -Settings $casingSettings | Should -BeNullOrEmpty + } + It 'shares a nested declaration with the dot-source group' -TestCases @( @{ Scope = 'script' }, @{ Scope = 'global' } ) { diff --git a/Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 b/Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 index c71d965f3..44f118445 100644 --- a/Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 +++ b/Tests/Engine/ScopedLocalFunctionResolution.tests.ps1 @@ -92,6 +92,11 @@ Get-ChildItem -PATH 'outside' Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings | Should -BeNullOrEmpty } + It 'keeps an ampersand command argument block in the caller scope' { + $definition = "function private:Get-ChildItem { param(`$PATH) }`n1 | & ForEach-Object { Get-ChildItem -PATH 'same' }" + Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings | Should -BeNullOrEmpty + } + It 'still validates a module-qualified cmdlet despite a scoped local declaration' { $definition = "function script:Get-ChildItem { param(`$PATH) }`nMicrosoft.PowerShell.Management\Get-ChildItem -PATH 'x'" $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $definition -Settings $casingSettings)