Using the AST to Find Module Dependencies in PowerShell Functions and Scripts

Earlier this week, Chris Gardner presented a session on Managing dependencies in PowerShell for the Mississippi PowerShell User Group. I mentioned that I had written a function to retrieve PowerShell module dependencies that's part of my ModuleBuildTools module.

Get-MrAST is one of the primary functions that numerous other functions in the module are built on.

  1#Requires -Version 3.0
  2function Get-MrAst {
  3
  4<#
  5.SYNOPSIS
  6    Explores the Abstract Syntax Tree (AST).
  7
  8.DESCRIPTION
  9    Get-MrAST is an advanced function that provides a mechanism for exploring the Abstract Syntax Tree (AST).
 10
 11 .PARAMETER Path
 12    Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory.
 13
 14.PARAMETER Code
 15    The code to view the AST for. If Get-Content is being used to obtain the code, use its -Raw parameter otherwise
 16    the formating of the code will be lost.
 17
 18.PARAMETER ScriptBlock
 19    An instance of System.Management.Automation.ScriptBlock Microsoft .NET Framework type to view the AST for.
 20
 21.PARAMETER AstType
 22    The type of object to view the AST for. If this parameter is ommited, only the top level ScriptBlockAst is returned.
 23
 24.EXAMPLE
 25     Get-MrAST -Path 'C:\Scripts' -AstType FunctionDefinition
 26
 27.EXAMPLE
 28     Get-MrAST -Code 'function Get-PowerShellProcess {Get-Process -Name PowerShell}'
 29
 30.EXAMPLE
 31     Get-MrAST -ScriptBlock ([scriptblock]::Create('function Get-PowerShellProcess {Get-Process -Name PowerShell}'))
 32
 33.NOTES
 34    Author:  Mike F Robbins
 35    Website: http://mikefrobbins.com
 36    Twitter: @mikefrobbins
 37#>
 38
 39    [CmdletBinding(DefaultParameterSetName='Path')]
 40    param(
 41        [Parameter(ValueFromPipeline,
 42                   ValueFromPipelineByPropertyName,
 43                   ValueFromRemainingArguments,
 44                   ParameterSetName = 'Path',
 45                   Position = 1)]
 46        [ValidateNotNull()]
 47        [Alias('FilePath')]
 48        [string[]]$Path = ('.\*.ps1', '.\*.psm1'),
 49
 50        [Parameter(Mandatory,
 51                   ValueFromPipelineByPropertyName,
 52                   ValueFromRemainingArguments,
 53                   ParameterSetName = 'Code')]
 54        [string[]]$Code,
 55
 56        [Parameter(Mandatory,
 57                   ValueFromPipelineByPropertyName,
 58                   ValueFromRemainingArguments,
 59                   ParameterSetName = 'ScriptBlock')]
 60        [scriptblock[]]$ScriptBlock
 61    )
 62
 63    DynamicParam {
 64        $ParameterAttribute = New-Object -TypeName System.Management.Automation.ParameterAttribute
 65        $ParameterAttribute.Position = 0
 66
 67        $ValidationValues = Get-MrAstType
 68        $ValidateSetAttribute = New-Object -TypeName System.Management.Automation.ValidateSetAttribute($ValidationValues)
 69
 70        $AttributeCollection = New-Object -TypeName System.Collections.ObjectModel.Collection[System.Attribute]
 71        $AttributeCollection.Add($ParameterAttribute)
 72        $AttributeCollection.Add($ValidateSetAttribute)
 73
 74        $ParameterName = 'AstType'
 75        $RuntimeParameter = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string], $AttributeCollection)
 76
 77        $RuntimeParameterDictionary = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameterDictionary
 78        $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
 79        $RuntimeParameterDictionary
 80    }
 81
 82    BEGIN {
 83        $AstType = $PsBoundParameters[$ParameterName]
 84    }
 85
 86    PROCESS {
 87        switch ($PSCmdlet.ParameterSetName) {
 88            'Path' {
 89                Write-Verbose -Message 'Path Parameter Set Selected'
 90                Write-Verbose "Path contains $Path"
 91
 92                $Files = Get-ChildItem -Path $Path -Exclude *tests.ps1, *profile.ps1 |
 93                         Select-Object -ExpandProperty FullName
 94
 95                if (-not ($Files)) {
 96                    Write-Warning -Message 'No valid files found.'
 97                    Return
 98                }
 99
100                $AST = foreach ($File in $Files) {
101                    [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$null, [ref]$null)
102                }
103
104                break
105            }
106            'Code' {
107                Write-Verbose -Message 'Code Parameter Set Selected'
108
109                $AST = foreach ($c in $Code) {
110                    [System.Management.Automation.Language.Parser]::ParseInput($c, [ref]$null, [ref]$null)
111                }
112
113                break
114            }
115            'ScriptBlock' {
116                Write-Verbose -Message 'ScriptBlock Parameter Set Selected'
117
118                $AST = $ScriptBlock.Ast
119
120                break
121            }
122            default {
123                Write-Warning -Message 'An unexpected error has occurred'
124            }
125        }
126
127        if ($PsBoundParameters.AstType) {
128            Write-Verbose -Message 'AstType Parameter Entered'
129
130            $AST = $AST.FindAll({$args[0].GetType().Name -like "$($ASTType)Ast"}, $true)
131        }
132
133        Write-Output $AST
134    }
135
136}

It retrieves the AST from one or more PS1 and/or PSM1 files, script blocks, or random arbitrary code.

1Get-MrAst -Path .\Hyper-V\MrHyperV\public\

module-depends1a.jpg

It can also be used to only retrieve specific AST types such as the definition of one or more functions.

1Get-MrAst -Path .\Hyper-V\MrHyperV\public\ -AstType FunctionDefinition

module-depends2a.jpg

Get-MrPrivateCommand is another function in the module which is used to show private functions in a module.

 1#Requires -Version 3.0
 2function Get-MrPrivateCommand {
 3
 4<#
 5.SYNOPSIS
 6    Returns a list of private (unexported) commands from the specified module or snap-in.
 7
 8.DESCRIPTION
 9    Get-MrPrivateFunction is an advanced function that returns a list of private commands
10    that are not exported from the specified module or snap-in.
11
12.PARAMETER Module
13    Specify the name of a module. Enter the name of a module or snap-in, or a snap-in or module
14    object. This parameter takes string values, but the value of this parameter can also be a
15    PSModuleInfo or PSSnapinInfo object, such as the objects that the Get-Module, Get-PSSnapin,
16    and Import-PSSession cmdlets return.
17
18.EXAMPLE
19     Get-MrPrivateCommand -Module Pester
20
21.NOTES
22    Author:  Mike F Robbins
23    Website: http://mikefrobbins.com
24    Twitter: @mikefrobbins
25#>
26
27    [CmdletBinding()]
28    param (
29        [Parameter(Mandatory)]
30        [Alias('PSSnapin')]
31        [string]$Module
32    )
33
34    if (-not((Get-Module -Name $Module -OutVariable ModuleInfo))){
35        try {
36            $ModuleInfo = Import-Module -Name $Module -Force -PassThru -ErrorAction Stop
37        }
38        catch {
39            Write-Warning -Message "$_.Exception.Message"
40            Break
41        }
42    }
43
44    $Global:ModuleName = $Module
45
46    $All = $ModuleInfo.Invoke({Get-Command -Module $ModuleName -All})
47
48    $Exported = (Get-Module -Name $Module -All).ExportedCommands |
49                Select-Object -ExpandProperty Values
50
51    if ($All -and $Exported) {
52        Compare-Object -ReferenceObject $All -DifferenceObject $Exported |
53        Select-Object -ExpandProperty InputObject |
54        Add-Member -MemberType NoteProperty -Name Visibility -Value Private -Force -PassThru
55    }
56}

Notice the private function named Get-MrFunctionRequirment.

1Get-MrPrivateCommand -Module MrModuleBuildTools

module-depends4a.jpg

Get-MrFunctionRequirment is a helper function that's sandwiched between Get-MrAST and Get-MrRequiredModule.

 1function Get-MrFunctionRequirement {
 2    [CmdletBinding(DefaultParameterSetName='File')]
 3    param(
 4        [Parameter(ValueFromPipeline,
 5                   ValueFromPipelineByPropertyName,
 6                   ValueFromRemainingArguments,
 7                   ParameterSetName = 'File',
 8                   Position = 0)]
 9        [ValidateNotNullOrEmpty()]
10        [Alias('FilePath')]
11        [string[]]$Path = ('.\*.ps1', '.\*.psm1'),
12
13        [Parameter(ValueFromPipelineByPropertyName,
14                   ValueFromRemainingArguments,
15                   ParameterSetName = 'Code',
16                   Position = 0)]
17        [ValidateNotNull()]
18        [Alias('ScriptBlock')]
19        [string[]]$Code
20    )
21
22    PROCESS {
23        if ($PSBoundParameters.Path) {
24            Write-Verbose 'Path'
25            $Results = Get-MrAST -Path $Path
26        }
27        elseif ($PSBoundParameters.Code) {
28            Write-Verbose 'Code'
29            $Results = Get-MrAST -Code $Code
30        }
31        else {
32            Write-Verbose -Message 'Valid input not received.'
33        }
34
35        $Results | Select-Object -ExpandProperty ScriptRequirements | Sort-Object -Property * -Unique
36    }
37
38}

I'll use a little known trick in this example to run the Get-MrFunctionRequirment private function from outside of the module by invoking it within module scope. This is the same trick I use to determine the private functions in a module by comparing the exported commands to the ones that exist in module scope.

1(Get-Module -Name MrModuleBuildTools).Invoke({Get-MrFunctionRequirement -Path .\Hyper-V\MrHyperV\public\})

module-depends5a.jpg

The Get-MrRequiredModule function is used to determine the required modules for one or more files or blocks of code.

 1#Requires -Version 3.0
 2function Get-MrRequiredModule {
 3
 4<#
 5.SYNOPSIS
 6    Gets a list of the required modules.
 7
 8.DESCRIPTION
 9    Get-MrRequiredModule is an advanced function that returns a list of the required module dependencies from one or more
10    PS1 and/or PSM1 files.
11
12 .PARAMETER Path
13    Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory.
14
15.PARAMETER Code
16    The code to get the required modules for. If Get-Content is being used to obtain the code, use its -Raw parameter
17    otherwise the formating of the code will be lost.
18
19.PARAMETER Detailed
20    Return a detailed list of all of the modules including built-in modules that are required. This option does not
21    reply on a Requires statement.
22
23.EXAMPLE
24     Get-MrRequiredModule -Path 'C:\Scripts'
25
26.EXAMPLE
27     Get-MrRequiredModule -Code 'function Get-PowerShellProcess {Get-Process -Name PowerShell}' -Detailed
28
29.NOTES
30    Author:  Mike F Robbins
31    Website: http://mikefrobbins.com
32    Twitter: @mikefrobbins
33#>
34
35    [CmdletBinding(DefaultParameterSetName='File')]
36    param(
37        [Parameter(ValueFromPipeline,
38                   ValueFromPipelineByPropertyName,
39                   ValueFromRemainingArguments,
40                   ParameterSetName = 'File',
41                   Position = 0)]
42        [ValidateNotNullOrEmpty()]
43        [Alias('FilePath')]
44        [string[]]$Path = ('.\*.ps1', '.\*.psm1'),
45
46        [Parameter(ValueFromPipelineByPropertyName,
47                   ValueFromRemainingArguments,
48                   ParameterSetName = 'Code',
49                   Position = 0)]
50        [ValidateNotNull()]
51        [Alias('ScriptBlock')]
52        [string[]]$Code,
53
54        [switch]$Detailed
55    )
56
57    PROCESS{
58        if (-not($PSBoundParameters.Detailed)) {
59            (Get-MrFunctionRequirement -Path $Path |
60             Select-Object -ExpandProperty RequiredModules -Unique).Name
61        }
62        else {
63            $PSBoundParameters.Remove('Detailed') | Out-Null
64            $AllAST = Get-MrAst @PSBoundParameters
65
66            foreach ($AST in $AllAST){
67                $FunctionDefinition = $AST.FindAll({$args[0].GetType().Name -like 'FunctionDefinitionAst'}, $true)
68                $Commands = $AST.FindAll({$args[0].GetType().Name -like 'CommandAst'}, $true) | ForEach-Object {$_.CommandElements[0].Value} | Select-Object -Unique
69
70                foreach ($Command in $Commands){
71                    [pscustomobject]@{
72                        Function = $FunctionDefinition.Name
73                        Dependency = $Command
74                        Module = (Get-Command -Name $Command -ErrorAction SilentlyContinue).Source
75                    }
76                }
77
78            }
79
80        }
81    }
82}

When the Get-MrRequiredModule is used without its Detailed parameter, it simply returns what's listed in the Requires statement of the individual PS1 and/or PSM1 files.

1Get-MrRequiredModule -Path .\Hyper-V\MrHyperV\public\

module-depends6a.jpg

When its Detailed parameter is specified, it retrieves a list of all the commands used in the files and then returns the name of the function along with the module that the command is part of including built-in modules. This does require the module to exist on your system, but it's great for figuring out what to add to the Requires statement. Of course, there's no reason to add built-in modules to the requires statement.

1Get-MrRequiredModule -Path .\Hyper-V\MrHyperV\public\ -Detailed

module-depends7a.jpg

As you can see, the previous example also shows how easy it would be to retrieve a list of all the commands used in a function or script with the AST for auditing purposes. Want to know if someone wrote a function that uses Write-Host? That would be no problem whatsoever for an entire module or hundreds of functions or scripts.

The MrModuleBuildTools PowerShell module along with all code found in this blog article can be found in my ModuleBuildTools repository on GitHub.

µ