WhoCalled.psm1
#region Classes enum CallDirection { Calls = 0 CalledBy = 1 } class CallInfo { <# .SYNOPSIS A pseudo-child of System.Management.Automation.CommandInfo that's also a node in a graph of calls. We can't inherit because all the constructors of CommandInfo are marked internal. #> # Hot path - we'll implement directly [string]$Name [string]$Source [psmoduleinfo]$Module # This class is a tree node [Collections.Generic.ISet[CallInfo]]$CalledBy [Collections.Generic.IList[CallInfo]]$Calls hidden [int]$Depth # Inner object; we'll delegate calls to this hidden [Management.Automation.CommandInfo]$Command hidden [string]$Id #region Constructors CallInfo([string]$Name) { $this.Name = $Name $this.Initialise() } CallInfo([Management.Automation.CommandInfo]$Command) { $this.Command = $Command $this.Name = $Command.Name $this.Source = $Command.Source $this.Module = $Command.Module $this.Initialise() } hidden [void] Initialise() { $InheritedProperties = ( 'CmdletBinding', 'CommandType', 'DefaultParameterSet', 'Definition', 'Description', 'HelpFile', # 'Module', 'ModuleName', # 'Name', 'Noun', 'Options', 'OutputType', 'Parameters', 'ParameterSets', 'RemotingCapability', 'ScriptBlock', # 'Source', 'Verb', 'Version', 'Visibility', 'HelpUri' ) $InheritedProperties | ForEach-Object { Add-Member ScriptProperty -InputObject $this -Name $_ -Value ([scriptblock]::Create("`$this.Command.$_")) } $this.CalledBy = [Collections.Generic.HashSet[CallInfo]]::new() $this.Calls = [Collections.Generic.List[CallInfo]]::new() $this.Id = switch ($this.CommandType) { $null { '<not found>' } 'Function' { $Qualifier = if ($this.Module) { # https://github.com/PowerShell/PowerShell/blob/8cc39848bcd4fb98517adc79cdbe60234b375c59/src/System.Management.Automation/engine/Modules/PSModuleInfo.cs#L1596-L1599 $this.Module.Name, $this.Module.Guid, $this.Module.Version -join ':' -replace '::' } else { $this.Command.Scriptblock.GetHashCode() } $Qualifier, $this.Name -join '\' } 'Cmdlet' { $Qualifier = $this.Command.ImplementingType.FullName $Qualifier, $this.Name -join '\' } 'Alias' { 'Alias', $this.Name -join ':\' } 'Application' { $this.Command.Path } default { throw [NotImplementedException]::new("No implementation for '$_'.") } } } #endregion Constructors hidden [System.Collections.Generic.IList[CallInfo]] AsList([int]$Depth, [CallDirection]$Direction) { [CallDirection]$OtherDirection = [int](-not $Direction) $Cloned = if ($this.Command) {[CallInfo]$this.Command} else {[CallInfo]$this.Name} $Cloned.Depth = $Depth $List = [System.Collections.Generic.List[CallInfo]]::new() $List.Add($Cloned) $Depth++ foreach ($Call in ($this.$Direction | Sort-Object Name, Id)) { $RecursedList = $Call.AsList($Depth, $Direction) [void]$Cloned.$Direction.Add($Call) [void]$RecursedList[0].$OtherDirection.Add($Cloned) $List.AddRange($RecursedList) } return $List } #region Overrides [string] ToString() { return $this.Name } [bool] Equals([object]$obj) { return $obj -is [CallInfo] -and $obj.Id -eq $this.Id } [int] GetHashCode() { return $this.Id.GetHashCode() } [Management.Automation.ParameterMetadata] ResolveParameter([string]$name) { if ($null -eq $this.Command) { throw [InvalidOperationException]::new("Cannot resolve parameter '$Name' for unresolved comand '$($this.Name)'.") } return $this.Command.ResolveParameter($name) } #endregion Overrides } #endregion Classes #region Private function Find-CallNameFromDefinition { <# .DESCRIPTION Parse a function definition to find all commands called from the function. #> [Diagnostics.CodeAnalysis.SuppressMessage('PSReviewUnusedParameter', 'TokenFlags', Justification = "It's used in a scriptblock")] [OutputType([string[]])] [CmdletBinding(DefaultParameterSetName = 'FromFunction')] param ( [Parameter(Mandatory, ValueFromPipeline)] [Management.Automation.FunctionInfo]$Function, [Management.Automation.Language.TokenFlags]$TokenFlags = 'CommandName' ) process { $Tokens = @() [void][Management.Automation.Language.Parser]::ParseInput($Function.Definition, [ref]$Tokens, [ref]$null) $CommandTokens = $Tokens | Where-Object {$_.TokenFlags -band $TokenFlags} $CommandTokens.Text | Sort-Object -Unique } } function Resolve-Command { <# .DESCRIPTION Find commands. Aliases are optionally resolved to the command they alias. If a module is provided, and it is not null, command resolution is done in the module's scope. This allows resolution of private commands. #> [Diagnostics.CodeAnalysis.SuppressMessage('PSReviewUnusedParameter', 'ResolveAlias', Justification = "It's used in a scriptblock")] [OutputType([CallInfo[]])] [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [SupportsWildcards()] [string]$Name, [AllowNull()] [psmoduleinfo]$Module, [switch]$ResolveAlias ) begin { $Resolver = { param ([string]$Name, [string]$ModuleName, [switch]$ResolveAlias) try { return Get-Command $Name -ErrorAction Stop | ForEach-Object {if ($ResolveAlias -and $_.CommandType -eq 'Alias') {$_.ResolvedCommand} else {$_}} } catch [Management.Automation.CommandNotFoundException] { Write-Warning "Command resolution failed for command '$Name'$(if ($ModuleName) {" in module '$ModuleName'"})." } catch { Write-Error -ErrorRecord $_ } return $Name } } process { [CallInfo[]]$Calls = if ($Module) { # Running Get-Command for a non-imported module gives an uninitialised module object $Module = Import-Module $Module -PassThru -DisableNameChecking $Module.Invoke($Resolver, @($Name, $Module.Name, $ResolveAlias)) } else { & $Resolver $Name '' $ResolveAlias } $Calls } } #endregion Private #region Public function Find-Call { <# .SYNOPSIS For a given function, find what functions it calls. .DESCRIPTION For the purposes of working out dependencies, it may be good to know what a function depends on at the function scale. This command takes a function and builds a tree of functions called by that function. .INPUTS [string] [System.Management.Automation.CommandInfo] .OUTPUTS [CallInfo] This command outputs an object similar to System.Management.Automation.CommandInfo. Note that this is not a child class of CommandInfo. .EXAMPLE Find-Call Install-Module CommandType Name Version Source ----------- ---- ------- ------ Function Install-Module 2.2.5 PowerShellGet Function Get-ProviderName 2.2.5 PowerShellGet Function Get-PSRepository 2.2.5 PowerShellGet Function New-ModuleSourceFromPackageSource 2.2.5 PowerShellGet Cmdlet Get-PackageSource 1.4.7 PackageManagement Function Install-NuGetClientBinaries 2.2.5 PowerShellGet Function Get-ParametersHashtable 2.2.5 PowerShellGet Cmdlet Get-PackageProvider 1.4.7 PackageManagement Cmdlet Import-PackageProvider 1.4.7 PackageManagement Cmdlet Install-PackageProvider 1.4.7 PackageManagement Function Test-RunningAsElevated 2.2.5 PowerShellGet Function ThrowError 2.2.5 PowerShellGet Function New-PSGetItemInfo 2.2.5 PowerShellGet Function Get-EntityName 2.2.5 PowerShellGet Function Get-First 2.2.5 PowerShellGet Function Get-SourceLocation 2.2.5 PowerShellGet For the 'Install-Module' command from the PowerShellGet module, determine the call tree. .EXAMPLE Find-Call Import-Plugz -Depth 2 -ResolveAlias -All WARNING: Resulting output is truncated as call tree has exceeded the set depth of 2. CommandType Name Version Source ----------- ---- ------- ------ Function Import-Plugz 0.2.0 Plugz Cmdlet Export-ModuleMember 7.2.5.500 Microsoft.PowerShell.Core Function Get-PlugzConfig 0.2.0 Plugz Cmdlet Add-Member 7.0.0.0 Microsoft.PowerShell.Utility Function Import-Configuration 1.5.1 Configuration Cmdlet Join-Path 7.0.0.0 Microsoft.PowerShell.Management Cmdlet New-Module 7.2.5.500 Microsoft.PowerShell.Core Cmdlet Select-Object 7.0.0.0 Microsoft.PowerShell.Utility Cmdlet Set-Alias 7.0.0.0 Microsoft.PowerShell.Utility Cmdlet Set-Item 7.0.0.0 Microsoft.PowerShell.Management Cmdlet Set-Variable 7.0.0.0 Microsoft.PowerShell.Utility Function Test-CalledFromProfile 0.2.0 Plugz Cmdlet Get-PSCallStack 7.0.0.0 Microsoft.PowerShell.Utility Cmdlet Select-Object 7.0.0.0 Microsoft.PowerShell.Utility Cmdlet Where-Object 7.2.5.500 Microsoft.PowerShell.Core Cmdlet Test-Path 7.0.0.0 Microsoft.PowerShell.Management Cmdlet Where-Object 7.2.5.500 Microsoft.PowerShell.Core Cmdlet Write-Error 7.0.0.0 Microsoft.PowerShell.Utility Cmdlet Write-Verbose 7.0.0.0 Microsoft.PowerShell.Utility Find calls made by the 'Import-Plugz' command. Depth is limited to 2. Built-in commands are included. Aliases are resolved to the resolved commands. #> [Diagnostics.CodeAnalysis.SuppressMessage('PSReviewUnusedParameter', 'All', Justification = "It's used in a scriptblock")] [OutputType([CallInfo[]])] [CmdletBinding(DefaultParameterSetName = 'FromCommand', PositionalBinding = $false)] param ( # Provide the name of a function to analyse. Wildcards are accepted. [Parameter(ParameterSetName = 'ByName', Mandatory, ValueFromPipeline, Position = 0)] [SupportsWildcards()] [string]$Name, # Provide a command object as input. This will be the output of Get-Command. [Parameter(ParameterSetName = 'FromCommand', Mandatory, ValueFromPipeline, Position = 0)] [Management.Automation.CommandInfo]$Command, # Maximum level of nesting to analyse. If this depth is exceeded, a warning will be emitted. [ValidateRange(0, 100)] [int]$Depth = 4, # Specifies to resolve aliases to the aliased command. [switch]$ResolveAlias, # Specifies to return all commands. By default, built-in modules are excluded. [switch]$All, # Only populate the cache [Parameter(DontShow)] [switch]$NoOutput, # For recursion [Parameter(DontShow, ParameterSetName = 'Recursing', Mandatory, ValueFromPipeline)] [CallInfo]$Caller, # For recursion [Parameter(DontShow, ParameterSetName = 'Recursing')] [int]$_CallDepth = 0, # For detecting loops when recursing [Parameter(DontShow, ParameterSetName = 'Recursing')] [Collections.Generic.ISet[string]]$_StackSeen = [Collections.Generic.HashSet[string]]::new() ) begin { if (-not $Script:CACHE) { $Script:CACHE = [Collections.Generic.Dictionary[string, CallInfo]]::new() } } process { #region Unify parameter sets if ($PSCmdlet.ParameterSetName -eq 'ByName') { $Params = [hashtable]$PSBoundParameters $Params.Remove('Name') return Get-Command $Name -ErrorAction Stop | Find-Call @Params } if ($PSCmdlet.ParameterSetName -eq 'Recursing') { $Command = $Caller.Command } else { $Caller = [CallInfo]$Command } #endregion Unify parameter sets #region Early exit conditions $_StackSeen = [Collections.Generic.HashSet[string]]::new($_StackSeen) if (-not $_StackSeen.Add($Caller.Id)) { Write-Debug "Already seen: $Caller" return } if ($_CallDepth -ge $Depth) { Write-Warning "Resulting output is truncated as call tree has exceeded the set depth of $Depth`: $Caller" # ...since we always return the original caller, return it when depth is 0... if ($Depth -eq 0) {return $Caller} else {return} } if (-not ($Command -as [Management.Automation.FunctionInfo])) { $Message = if ($Command) {"Not a function, cannot parse for calls: $Caller"} else {"Command not found: $Caller"} Write-Verbose $Message Write-Debug $Message return } #endregion Early exit conditions #region Cache hit or parse and cache # The call may have bottomed out on depth when it was first cached. # A cache hit saves parsing; it doesn't save recursion. $Found = $Script:CACHE[$Caller.Id] if ($Found) { Write-Debug "$Caller`: cache hit" $Caller.CalledBy | ForEach-Object {[void]$Found.CalledBy.Add($_)} $Caller = $Found } else { $CallNames = $Command | Where-Object Name | Find-CallNameFromDefinition $CallNames | Resolve-Command -Module $Command.Module -ResolveAlias:$ResolveAlias | Write-Output | Where-Object Id -NE $Caller.Id | # Don't include recursive calls ForEach-Object { [void]$_.CalledBy.Add($Caller) $Caller.Calls.Add($_) } Write-Debug "$Caller`: caching" $Script:CACHE[$Caller.Id] = $Caller } #endregion Cache hit or parse and cache #region Recurse $Calls = $Caller.Calls if (-not $All) { $Calls = $Calls | Where-Object Source -notmatch '^Microsoft.PowerShell' } if ($Calls) { $RecurseParams = @{ Depth = $Depth ResolveAlias = $ResolveAlias All = $All _CallDepth = $_CallDepth + 1 _StackSeen = $_StackSeen WarningAction = 'SilentlyContinue' WarningVariable = 'Warnings' } $Calls | Find-Call @RecurseParams } #endregion Recurse #region Output if ($PSCmdlet.ParameterSetName -ne 'Recursing' -and -not $NoOutput) { if ($Warnings) { $Warnings | Sort-Object -Unique | Write-Warning } $Caller.AsList(0, 'Calls') | Where-Object { $_.Depth -le $Depth -and ($All -or $_.Source -notmatch '^Microsoft.PowerShell') } } #endregion Output } } function Find-Caller { <# .SYNOPSIS For a given function, find functions that call it. .DESCRIPTION For the purposes of working out dependencies, it may be good to know what depends on a function at the function scale. This command takes a function and builds a tree of functions that call it. .INPUTS [string] [System.Management.Automation.CommandInfo] .OUTPUTS [CallInfo] This command outputs an object similar to System.Management.Automation.CommandInfo. Note that this is not a child class of CommandInfo. .EXAMPLE Find-Caller Get-ModuleDependencies -Module PowerShellGet CommandType Name Version Source ----------- ---- ------- ------ Function Get-ModuleDependencies 2.2.5 PowerShellGet Function Publish-PSArtifactUtility 2.2.5 PowerShellGet Function Publish-Module 2.2.5 PowerShellGet Function Publish-Script 2.2.5 PowerShellGet Find all calls made to the 'Get-ModuleDependencies' command from commands in the PowerShellGet module. Note that the 'Get-ModuleDependencies' command is not exported; it is a private command in the PowerShellGet module. This command will import modules in order to resolve private commands. .EXAMPLE Find-Caller Import-* -Module Plugz, Metadata, Configuration, '' -Depth 2 CommandType Name Version Source ----------- ---- ------- ------ Function Import-Configuration 1.5.1 Configuration Function Get-PlugzConfig 0.2.0 Plugz Function Export-PlugzProfile 0.2.0 Plugz Function Import-Plugz 0.2.0 Plugz Function Import-Metadata 1.5.3 Metadata Function Import-Configuration 1.5.1 Configuration Function Get-PlugzConfig 0.2.0 Plugz Function Import-ParameterConfiguration 1.5.1 Configuration Function Import-Plugz 0.2.0 Plugz Function Import-ParameterConfiguration 1.5.1 Configuration Function Import-GitModule Function Import-CommonModule Find calls made to any commands matching 'Import-*' from commands in the Plugz, Metadata, or Configuration modules, or from commands in the current scope that are not exported from any module. Depth is limited to 2. The module parameter includes an empty string argument. This causes the search to include functions that are not defined in a module; in this case, the 'Import-GitModule' and 'Import-CommonModule' functions, which are defined in the user's profile. Note that the modules will be imported. #> param ( # The name of a command to find callers of. [Parameter(ParameterSetName = 'ByName', Mandatory, ValueFromPipeline, Position = 0)] [string]$Name, # The command object to find callers of. [Parameter(ParameterSetName = 'FromCommand', Mandatory, ValueFromPipeline, Position = 0)] [Management.Automation.CommandInfo]$Command, # Modules to search for callers. Include a null or an empty string to include functions that # are not defined in a module. [Parameter(Mandatory, Position = 1)] [AllowEmptyString()] [string[]]$Module, # Maximum level of nesting to analyse. If this depth is exceeded, a warning will be emitted. [ValidateRange(0, 100)] [int]$Depth = 4 ) begin { $Module = $Module | Where-Object Length $IncludeCurrentScope = $Module.Count -ne $PSBoundParameters.Module.Count $ToImport = @() [psmoduleinfo[]]$Modules = $Module | ForEach-Object { $_Module = Get-Module $_ -ErrorAction Ignore if ($_Module) {$_Module} else {$ToImport += $_} } if ($ToImport) { $i = 0 $Activity = "Importing modules" Write-Progress -Activity $Activity -PercentComplete 0 $ToImport | ForEach-Object { $Percent = 100 * $i++ / $ToImport.Count Write-Progress -Activity $Activity -Status $_ -PercentComplete $Percent $Modules += Import-Module $_ -PassThru -DisableNameChecking } Write-Progress -Activity $Activity -Completed } $Commands = $Modules | ForEach-Object { $_.Invoke({Get-Command -Module $args[0]}, $_) } if ($IncludeCurrentScope) { $Commands += Get-Command -CommandType Function | Where-Object Module -eq $null } $Params = @{ NoOutput = $true # Only populate cache ResolveAlias = $true Depth = $Depth WarningAction = 'SilentlyContinue' WarningVariable = 'Warnings' } $i = 0 $Activity = "Finding function calls" $Commands | ForEach-Object { $Percent = 100 * $i++ / $Commands.Count Write-Progress -Activity $Activity -Status $_ -PercentComplete $Percent Find-Call $_ @Params } -End { Write-Progress -Activity $Activity -Completed } if ($Warnings) { $Warnings | Sort-Object -Unique | Write-Warning } } process { if ($PSCmdlet.ParameterSetName -eq 'FromCommand') { $Calls = [CallInfo]$Command } else { if ($Name -match '(?<Source>.*)\\(?<Name>.*?)') { $Name = $Matches.Name $Source = $Matches.Source } else {$Source = ''} $CallIds = $Script:CACHE.Keys -like "*$Name" if ($Source) { $CallIds = @($CallIds) -like "$Source`:*" } $Calls = $Script:CACHE[$CallIds] } if (-not $Calls) { Write-Error "Could not find command '$_'." -ErrorAction Stop } $Calls | ForEach-Object { $_.AsList(0, 'CalledBy') | Where-Object Depth -le $Depth } } } #endregion Public |