Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Engine/DotSourceScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ internal static DotSourceScope Build(IReadOnlyList<string> 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);
}
Expand Down
63 changes: 52 additions & 11 deletions Engine/LocalFunctionScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ internal sealed class LocalFunctionScope

private readonly Dictionary<string, List<FunctionDefinitionAst>> definitionsByName;
private readonly HashSet<string> alsoInScopeNames;
private readonly HashSet<string> privateInScopeNames;

private LocalFunctionScope(
Dictionary<string, List<FunctionDefinitionAst>> definitionsByName,
HashSet<string> alsoInScopeNames)
HashSet<string> alsoInScopeNames,
HashSet<string> privateInScopeNames)
{
this.definitionsByName = definitionsByName;
this.alsoInScopeNames = alsoInScopeNames;
this.privateInScopeNames = privateInScopeNames;
}

internal static LocalFunctionScope FromAst(Ast ast) => FromAst(ast, null);
Expand All @@ -41,43 +44,75 @@ internal static LocalFunctionScope FromAst(Ast ast, IEnumerable<string> 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<FunctionDefinitionAst>();
definitionsByName[function.Name] = definitions;
definitionsByName[name] = definitions;
}
definitions.Add(function);
}
}

var alsoInScopeNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var privateInScopeNames = new HashSet<string>(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);
}

/// <summary>
/// Returns the containing function of <paramref name="function"/>, 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.
/// </summary>
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
&& ReferenceEquals(invocation.CommandElements[0], expression))
{
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.
Expand Down Expand Up @@ -106,6 +141,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;
Expand Down
55 changes: 55 additions & 0 deletions Tests/Engine/ScopedDotSourceResolution.tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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 <Scope> 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 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 <Scope> 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
}
}
106 changes: 106 additions & 0 deletions Tests/Engine/ScopedLocalFunctionResolution.tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# 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 <Scope> 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 <Scope> 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 '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)
$diagnostics.Count | Should -Be 1
$diagnostics[0].SuggestedCorrections[0].Text | Should -BeExactly 'Path'
}
}
Loading