Out-PSModuleCallGraph.ps1
<#PSScriptInfo
.VERSION 1.0.0 .GUID a28d780d-1f96-49a2-a964-d3e8bade5440 .AUTHOR Lars Bengtsson | https://github.com/larssb | https://bengtssondd.it/ .DESCRIPTION Use Out-PSModuleCallGraph to generate a call-graph on a PowerShell module. The call-graph helps you get an overview on the inner workings of "X" PowerShell module. What commands are the public commands of the module calling, what are those commands calling and so forth. In other words. A way for you to get a look behind the scenes. And thereby an idea into which commands to go-to in specific situations. Out-PSModuleCallGraph analyzes the scope of the commands in the module. How they call eachother and finally uses the PowerShell module PSGraph to generate the call-graph. The call-graph is styled with colors and the like in order to heigthen the readability of the graph. It is possible to control parts of the process generating the graph. E.g. the direction of the graph. .COMPANYNAME .COPYRIGHT .TAGS analysis call-graph callgraph call graph analyze static analysis help tooling .LICENSEURI https://github.com/larssb/PowerShellTooling/blob/master/LICENSE .PROJECTURI https://github.com/larssb/PowerShellTooling .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES .PRIVATEDATA #> #Requires -Modules @{ ModuleName="PSGraph"; ModuleVersion="2.1.21" } # The above ensures to require v2.1.21+ of PSGraph to be installed. As this is the version where SVG as output format was supported. function Out-PSModuleCallGraph() { <# .DESCRIPTION Out-PSModuleCallGraph generates a call graph on a PowerShell module. The call graph is generated by parsing the public commands/functions of the module being analyzed. Identifying the commands/functions each one utilizes. Noting their scope and the chronological order by which they where called. Finally the graph is generated with the PSGraph module and saved in the format and on the media specified. .INPUTS [String]ModuleName [String]ModuleRoot .OUTPUTS A graphviz graph via the PSGraph module. .NOTES On the analysis: - The analysis is based on parsing the AST of each command/public function in the analyzed module. You therefore do not get a sequence like graph over certain calls made if 'x' path in the program was followed. Rather the generated call graph represents the code written, static as it is, when parsed line by line, as it is read-in from files on disk, to identify the sub-routines called and to be able to present an overview of how parts of the program is thought to interact/can interact with eachother. Other: - Call graphs can be very useful when working with your own PowerShell module or a PS module developed by external parties. A call graph gives you an overview over the calling relationships of commands/functions in a program. Thereby making it possible to get a good overview of a PowerShell module and they its sub-routines interact with eachother. Pre-requisites: - The PSGraph module should already be installed. .EXAMPLE Out-PSModuleCallGraph -ModuleName Pester This will generate a call graph on the Pester module. To go by ModuleName the module being analyzed has to be installed in one of the default PowerShell module installation folders. .EXAMPLE Out-PSModuleCallGraph -ModuleRoot ./PowerShellTooling/ This will generate a call graph on a properly defined PowerShell module in the folder 'PowerShellTooling'. A sub-folder to current folder. Useful if the module is not installed in one of the default PowerShell module installation locations. .EXAMPLE Out-PSModuleCallGraph -ModuleName Plaster -OutputFormat pdf -ShowGraph Analyzes the Plaster module. Installed in one of the systems PowerShell module installation folders. The generated graph will be output as a PDF file. Finally, the graph will be automatically displayed on the screen. .PARAMETER Coloring By default, coloring is used when the output that the graph will be based on is complex enough. The purpose is to make the generated graph more readable. E.g. by coloring the edges of the graph. This parameter let's you control the way coloring is used by the function. You have the following options: 1. Auto. This is the default value. If you do not specify anything to the "Coloring" parameter it is set to "Auto". 2. Colors. Forces the generated graph to be colored. 3. NoColors. Let's you specify that no colors should be used when generating the graph. .PARAMETER ExcludeDebugCommands Used to specify that you wish to exclude common debug commands such as > Write-Verbose & Write-Error. .PARAMETER FoldersToExclude A string array of folders to exclude when analyzing the PowerShell module. This could e.g. be used when you have a temporary folder with draft PowerShell files that you are working on and don't want them included in the generated graph. N.B. public functions will always be included, no matter what you specify here. The thinking being. If you made them public it is the intention that users of your module should see & use the functions. Therefore, it should be included in the generated graph as well. .PARAMETER GraphDirection The direction the generated graph should have. Normally for Graphviz graphs, top to bottom is default. However, left to right suites the output of analyzing PowerShell modules better. You can chose the default direction if you so wishes. The real graph direction names in the DOT (Graphviz) language are: TB (top to bottom), LR (left to right), BT (bottom to top) and RL (right to left). The names used in the set for this parameter are more self explanatory. Therefore used. .PARAMETER IncludeDefaultExcludedFolders Use this parameter to indicate that you wish to include normally excluded folders. They could e.g. be "Build" & "Temp" folders. The full list of per default excluded folders: - Build - Builds - Temp - Test - Tests .PARAMETER ModuleName The name of the module to analyze. Assumes that the module is installed in one of the default PowerShell module installation locations. .PARAMETER ModuleRoot Full path to the root folder of the PowerShell module. .PARAMETER OutputPath The path on which to store the generated call graph. .PARAMETER OutputFormat Used to specify the output format of the Graph. Whether it should be put out as a: - GIF - JPG - PNG - SVG The default is PNG. Set to this as this is what is the default in the PSGraph docs. URI > http://psgraph.readthedocs.io/en/latest/Command-Export-PSGraph/ .PARAMETER ShowGraph A switch parameter used to specify that the path should be opened and shown on screen after it has been generated. #> # Define parameters [CmdletBinding(DefaultParameterSetName = "Default")] [OutputType([Void])] param( [ValidateSet("Auto","Colors","NoColors")] [String]$Coloring = "Auto", [Parameter(ParameterSetName="ByModuleName")] [Parameter(ParameterSetName="ByModuleRoot")] [Switch]$ExcludeDebugCommands, [Parameter(ParameterSetName="ByModuleName")] [Parameter(ParameterSetName="ByModuleRoot")] [ValidateScript({$_.Count -ge 1})] # Validate that an empty array is not given to the parameter. [String[]]$FoldersToExclude, [Parameter(ParameterSetName="ByModuleName")] [Parameter(ParameterSetName="ByModuleRoot")] [ValidateSet("Top-Bottom","Left-Right","Bottom-Top","Right-Left")] [String]$GraphDirection = "Left-Right", [Parameter(ParameterSetName="ByModuleName")] [Parameter(ParameterSetName="ByModuleRoot")] [Switch]$IncludeDefaultExcludedFolders, [Parameter(Mandatory, ParameterSetName="ByModuleName")] [ValidateNotNullOrEmpty()] [String]$ModuleName, [Parameter(Mandatory, ParameterSetName="ByModuleRoot")] [ValidateNotNullOrEmpty()] [String]$ModuleRoot, [Parameter(ParameterSetName="ByModuleName")] [Parameter(ParameterSetName="ByModuleRoot")] [ValidateNotNullOrEmpty()] [String]$OutputPath, [Parameter(ParameterSetName="ByModuleName")] [Parameter(ParameterSetName="ByModuleRoot")] [ValidateSet("gif","jpg","pdf","png","svg")] [String]$OutputFormat = "png", [Parameter(ParameterSetName="ByModuleName")] [Parameter(ParameterSetName="ByModuleRoot")] [Switch]$ShowGraph ) ############# # Execution # ############# Begin { [String]$FileName = "$([System.IO.Path]::GetRandomFileName()).$OutputFormat" if ($PSBoundParameters.ContainsKey('OutputPath')) { # Validate the specified path if (-not (Test-Path -Path $OutputPath)) { Write-Output "The path > $OutputPath, does not exist. Out-PSModuleCallGraph will use a default path instead." $GraphOutputPath = Join-Path -Path $home -ChildPath $FileName } else { # Set the path to the path specified via the OutputPath parameter. $GraphOutputPath = $OutputPath } } else { # Set the path to use to the default home folder on the system $GraphOutputPath = Join-Path -Path $home -ChildPath $FileName } if ($PSBoundParameters.ContainsKey('ModuleName')) { # Import the specified module by name. Therefore, loaded from one of the default PowerShell module path locations. $Module = Import-Module -DisableNameChecking -Force -Name $ModuleName -PassThru -WarningAction SilentlyContinue } else { # Import the specified module by its fullname/path. $Module = Import-Module -DisableNameChecking -Force -Name $ModuleRoot -PassThru } # Get the public commands/functions loaded by the module. Need them in order to control the scope and type of 'x' command/function being analyzed later on. $PublicFunctions = Get-Command -Module $Module.Name Write-Verbose -Message "The public functions retrieved > $($PublicFunctions.Name | Out-String)" # Collection to hold private functions [System.Collections.ArrayList]$PrivateFunctions = New-Object System.Collections.ArrayList # Collection to hold the call hierarchy of the analyzed module [System.Collections.ArrayList]$CallGraphObjects = New-Object System.Collections.ArrayList # Prepare to exclude common debug commands if ($ExcludeDebugCommands) { $DebugCommandsToExclude = @('Write-Debug','Write-Error','Write-Verbose') } else { $DebugCommandsToExclude = @() } <# - Graph element settings #> $PenWidth = 3.0 # Prepare to exclude folders [System.Collections.ArrayList]$FolderExclusionList = New-Object System.Collections.ArrayList if(-not $IncludeDefaultExcludedFolders) { $DefaultExcludedFolders = @("Build","Builds","Temp","Test","Tests") foreach ($DefExcludedFolder in $DefaultExcludedFolders) { # Try getting the folder in the module folder-tree. Confirming that the folder exists. If not it is ridicolous to fetch sub-folders in the folder. $Folder = Get-ChildItem -Directory -Filter "$DefExcludedFolder" -Path $Module.ModuleBase -Recurse if ($null -ne $Folder) { # Add the folder itself to the exclusion list $FolderExclusionList.Add($Folder.BaseName) | Out-Null # Get the sub-folders of the folder, to filter-out these as well $SubFolders = Get-ChildItem -Directory -Path $Folder.FullName -Recurse # Add the sub-folders of the default excluded folder, to the folder exclusion list. If there are any. if ($null -ne $SubFolders) { foreach ($SubFolder in $SubFolders) { $FolderExclusionList.Add($SubFolder.BaseName) | Out-Null } } } } } if ($PSBoundParameters.ContainsKey('FoldersToExclude')) { foreach ($FolderToExclude in $FoldersToExclude) { # Include the folder if it hasn't already been excluded because it is either one of the default excluded folders or a sub-folder to one. if (-not ($FolderExclusionList.Contains($FolderToExclude))) { # Try getting the folder in the module folder-tree. Confirming that the folder exists. If not it is ridicolous to fetch sub-folders in the folder. $Folder = Get-ChildItem -Directory -Filter "$FolderToExclude" -Path $Module.ModuleBase -Recurse if ($null -ne $Folder) { # Add the folder itself to the exclusion list $FolderExclusionList.Add($Folder.BaseName) | Out-Null # Get the sub-folders of the folder, to filter-out these as well $SubFolders = Get-ChildItem -Directory -Path $Folder.FullName -Recurse # Add the sub-folders of the user excluded folder, to the folder exclusion list. If there are any. if ($null -ne $SubFolders) { foreach ($SubFolder in $SubFolders) { $FolderExclusionList.Add($SubFolder.BaseName) | Out-Null } } } } } } # "Translate" the selected Graph direction value to the proper DOT/graphviz value switch ($GraphDirection) { "Top-Bottom" { [String]$RealGraphDirection = "TB" } "Left-Right" { [String]$RealGraphDirection = "LR" } "Bottom-Top" { [String]$RealGraphDirection = "BT" } "Right-Left" { [String]$RealGraphDirection = "RL" } } # Short-hand values for commands to be translated to their fullname counterpart $FullNameCommands = @{ "%" = "ForEach-Object" "?" = "Where-Object" "And" = "GherkinStep" "But" = "GherkinStep" "cd" = "Set-Location" "chdir" = "Set-Location" "clc" = "Clear-Content" "clear" = "Clear-Host" "clhy" = "Clear-History" "cli" = "Clear-Item" "clp" = "Clear-ItemProperty" "cls" = "Clear-Host" "clv" = "Clear-Variable" "copy" = "Copy-Item" "cpi" = "Copy-Item" "cvpa" = "Convert-Path" "dbp" = "Disable-PSBreakpoint" "del" = "Remove-Item" "DiGraph" = "Graph" "dir" = "Get-ChildItem" "ebp" = "Enable-PSBreakpoint" "echo" = "Write-Output" "epal" = "Export-Alias" "epcsv" = "Export-Csv" "erase" = "Remove-Item" "etsn" = "Enter-PSSession" "exsn" = "Exit-PSSession" "fc" = "Format-Custom" "fhx" = "Format-Hex" "fl" = "Format-List" "foreach" = "ForEach-Object" "ft" = "Format-Table" "fw" = "Format-Wide" "gal" = "Get-Alias" "gbp" = "Get-PSBreakpoint" "gc" = "Get-Content" "gci" = "Get-ChildItem" "gcm" = "Get-Command" "gcs" = "Get-PSCallStack" "gdr" = "Get-PSDrive" "ghy" = "Get-History" "gi" = "Get-Item" "Given" = "GherkinStep" "gjb" = "Get-Job" "gl" = "Get-Location" "gm" = "Get-Member" "gmo" = "Get-Module" "gp" = "Get-ItemProperty" "gps" = "Get-Process" "gpv" = "Get-ItemPropertyValue" "group" = "Group-Object" "gsn" = "Get-PSSession" "gtz" = "Get-TimeZone" "gu" = "Get-Unique" "gv" = "Get-Variable" "h" = "Get-History" "history" = "Get-History" "icm" = "Invoke-Command" "iex" = "Invoke-Expression" "ihy" = "Invoke-History" "ii" = "Invoke-Item" "ipal" = "Import-Alias" "ipcsv" = "Import-Csv" "ipmo" = "Import-Module" "irm" = "Invoke-RestMethod" "iwr" = "Invoke-WebRequest" "kill" = "Stop-Process" "md" = "mkdir" "measure" = "Measure-Object" "mi" = "Move-Item" "move" = "Move-Item" "mp" = "Move-ItemProperty" "nal" = "New-Alias" "ndr" = "New-PSDrive" "ni" = "New-Item" "nmo" = "New-Module" "nsn" = "New-PSSession" "nv" = "New-Variable" "oh" = "Out-Host" "popd" = "Pop-Location" "pushd" = "Push-Location" "pwd" = "Get-Location" "r" = "Invoke-History" "rbp" = "Remove-PSBreakpoint" "rcjb" = "Receive-Job" "rcsn" = "Receive-PSSession" "rd" = "Remove-Item" "rdr" = "Remove-PSDrive" "ren" = "Rename-Item" "ri" = "Remove-Item" "rjb" = "Remove-Job" "rmo" = "Remove-Module" "rni" = "Rename-Item" "rnp" = "Rename-ItemProperty" "rp" = "Remove-ItemProperty" "rsn" = "Remove-PSSession" "rv" = "Remove-Variable" "rvpa" = "Resolve-Path" "sajb" = "Start-Job" "sal" = "Set-Alias" "saps" = "Start-Process" "sbp" = "Set-PSBreakpoint" "select" = "Select-Object" "set" = "Set-Variable" "si" = "Set-Item" "sl" = "Set-Location" "sls" = "Select-String" "sp" = "Set-ItemProperty" "spjb" = "Stop-Job" "spps" = "Stop-Process" "sv" = "Set-Variable" "Then" = "GherkinStep" "type" = "Get-Content" "When" = "GherkinStep" "where" = "Where-Object" "wjb" = "Wait-Job" } # AST Queries [String]$ASTQuery_CommandsUsed = "`$_.Type -eq `"Command`"" ############################ # PRIVATE HELPER FUNCTIONS # ############################ function Filter-AST() { <# .DESCRIPTION Filters the AST of a PowerShell file. There is various filtering options. You can exclude specific lines. Set a custom query to hand to the .Where() function on a AST object and more. .INPUTS [System.Collections.ObjectModel.Collection`1[System.Management.Automation.PSToken]]AST representing the AST collection to perform a filter on. [String]ASTQuery representing the query to perfom on the AST. [Hashtable]LinesToExclude. Representing the lines to excllude. ASTQuery and LinesToExclude cannot be used at the same time. They are mutually exclusive. .OUTPUTS A collection of type [System.Collections.ObjectModel.Collection`1[System.Management.Automation.PSToken]] containing the AST objects after filtering on the AST coming via the AST parameter. .NOTES <none> .EXAMPLE PS C:\> Filter-AST -AST $AST -ASTQuery $ASTQuery Calls Filter-AST to run the query specified in the $ASTQuery variable on the AST coming in via the AST parameter. .PARAMETER AST The collection of AST objects to parse/filter. .PARAMETER ASTQuery The query to perform on the AST collection specified via the AST parameter. .PARAMETER LinesToExclude A hashtable of lines to exclude when parsing/filtering the AST #> # Define parameters [CmdletBinding(DefaultParameterSetName = "Default")] [OutputType([System.Management.Automation.PSToken])] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs","")] # Filter-AST is a private inline function & the name is more telling by using "Filter-...." param( [Parameter(Mandatory, ParameterSetName="ASTQuery")] [Parameter(Mandatory, ParameterSetName="LinesToExclude")] [ValidateNotNullOrEmpty()] [System.Collections.ObjectModel.Collection`1[System.Management.Automation.PSToken]]$AST, [Parameter(Mandatory, ParameterSetName="ASTQuery")] [ValidateNotNullOrEmpty()] [String]$ASTQuery, [Parameter(Mandatory, ParameterSetName="LinesToExclude")] [ValidateScript({$_.Count -ge 1})] # Validate that an empty array is not given to the parameter. [System.Collections.ArrayList]$LinesToExclude ) ############# # Execution # ############# Begin {} Process { # Perform the query in ASTQuery on the AST if ($PSBoundParameters.ContainsKey('ASTQuery')) { $ASTObjects = $AST.Where( [scriptblock]::create($ASTQuery) ) } # Filter out the lines specified in the LinesToExclude collection if ($PSBoundParameters.ContainsKey('LinesToExclude')) { # Create collections for holding elements used for filtering the AST based on lines to exclude [System.Collections.ArrayList]$FilteredAST = New-Object System.Collections.ArrayList [System.Collections.ArrayList]$LineNumbersToExclude = New-Object System.Collections.ArrayList # Create number ranges in 1 collection, that can be looked into. To filter or not filter 'x' AST object foreach ($item in $LinesToExclude) { # Generate array of numbers maching StartLine -> EndLine of the current item. [Array]$Numbers = $item.StartLine..$item.EndLine # Add the numbers to the LineNumbersToExclude collection foreach ($Number in $Numbers) { $LineNumbersToExclude.Add($Number) | Out-Null } } # Parse the AST & filter it. foreach ($ASTObject in $AST) { if (-not ($LineNumbersToExclude.Contains($ASTObject.StartLine))) { $FilteredAST.Add($ASTObject) | Out-Null } } } } End { # Return the objects retrieved from filtering the AST if ($PSBoundParameters.ContainsKey('LinesToExclude')) { $FilteredAST } else { $ASTObjects } } } # End of Filter-AST function declaration function Calculate-FunctionBoundary() { <# .DESCRIPTION Calculates the boundaries of a PowerShell function by counting brackets ({}). This is done on the basis of the AST of a PowerShell function. .INPUTS [System.Collections.ObjectModel.Collection`1[System.Management.Automation.PSToken]]AST representing the AST collection to perform a filter on. [int]StartLine representing the starting line of a function (where the function keyword is). .OUTPUTS [int] representing the Endline number of the bracket that ends the function that starts at the StartLine given via the StartLine parameter. .NOTES <none> .EXAMPLE PS C:\> Calculate-FunctionBoundary -AST $AST -StartLine 1 Will parse the AST coming in from the line 1, on which a function keyword is declared, in order to find that functions ending bracket ("}"). .PARAMETER AST The collection of AST objects to parse/filter. .PARAMETER StartLine The starting line number of the function, on which to find an function ending bracket ("}"). #> # Define parameters [CmdletBinding(DefaultParameterSetName = "Default")] [OutputType([int])] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs","")] # Calculate-FunctionBoundary is a private inline function & the name is more telling. param( [Parameter(Mandatory)] [ValidateScript({$_.Count -ge 1})] # Validate that it is not an empty collection. [System.Collections.ObjectModel.Collection`1[System.Management.Automation.PSToken]]$AST, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [int]$StartLine ) ############# # Execution # ############# Begin {} Process { # Get all GroupStart & GroupEnd types, from the StartLine of the last declared function in the file. Where content is either "{" or "}" $ASTObjects = Filter-AST -AST $AST -ASTQuery "(`$_.Type -eq `"GroupStart`" -or `$_.Type -eq `"GroupEnd`") -and (`$_.StartLine -ge $Startline) -and (`$_.Content -match `"{`" -or `$_.Content -eq `"}`")" # Counters for "{" GroupStart's minus "}" GroupEnd's $GroupStartCounter = 0 $GroupEndCounter = 0 # Run over the identified GroupStart's & GroupEnd's of content type "{" or "}" to find our function endline. foreach ($ASTObject in $ASTObjects) { if ($ASTObject.Content -match "@{") { # Skip the next ASTObject as it will be the end "}" of a @{} declaration [Bool]$SkipNextASTObject = $true } else { [Bool]$SkipNextASTObject = $false } if (-not ($SkipNextASTObject)) { if ($ASTObject.Content -eq "{") { $GroupStartCounter++ } else { $GroupEndCounter++ } } else { # There was a "@{" hashtable declaration start. So the next end "}" should be skipped. Simply doing that be subtracting 1 from the GroupEndCounter $GroupEndCounter-- } Write-Verbose -Message "Result of calculating GroupStartCounter and GroupEndCounter = $($GroupStartCounter-$GroupEndCounter)" # Control if we found our closing function bracket ( } ) if ($GroupStartCounter-$GroupEndCounter -eq 0) { # Register the line of the closing bracket. Which is the line to parse by, when looking for commands used by the function $ParseEndLine = $ASTObject.Endline # No need to continue the loop. The closing bracket has been found. break } } # End of foreach ASTObject. {} calculating. Start and end of a function } End { # Return the identified function endline $ParseEndLine } } # End of Calculate-FunctionBoundary function } Process { <# - Analyze private functions in the module #> # Declare a collection to hold folders to include [System.Collections.ArrayList]$FoldersToInclude = New-Object System.Collections.ArrayList # Add the modulebase itself to the FoldersToInclude collection $ModuleBaseFolder = Get-Item -Path $Module.ModuleBase $FoldersToInclude.Add($ModuleBaseFolder) | Out-Null # Add folders underneath the module modulebase $ModuleSubFolders = Get-ChildItem -Directory -Exclude $FolderExclusionList -Path $Module.ModuleBase -Recurse foreach ($ModuleSubFolder in $ModuleSubFolders) { $FoldersToInclude.Add($ModuleSubFolder) | Out-Null } Write-Verbose -Message "Folders to include > $($FoldersToInclude | Out-String)" # Get PS1 files in non-excluded folders [System.Collections.ArrayList]$PS1Files = New-Object System.Collections.ArrayList foreach ($FolderToInclude in $FoldersToInclude) { Write-Verbose -Message "Folder to include fullname > $($FolderToInclude.FullName | Out-String)" $files = (Get-Item -Path "$($FolderToInclude.FullName)\*" -Filter '*.ps1') foreach ($file in $files) { $PS1Files.Add($file) | Out-Null } } Write-Verbose -Message "PS1 files to analyze > $($PS1Files.Name | Out-String)" <# - Derive all the functions in the PS1Files found to be analyzed. By doing this we have a way to, later on, determine if a command >> if not a public command >> is either "External" or "Private". This especially covers the case where a command found is not part of the module being analyzed. So it is "External". However, the module might not, for 'x' reason be on the environment where Out-PSModuleCallGraph is being executed. Therefore, we have to know all the functions declared in the module. To do lookups into a module private functions collection. So if 'x' command is not public >> lookup private function collection >> if not in there == command is "External". #> foreach ($PS1File in $PS1Files) { # Tokenize the content of the file $ast = [System.Management.Automation.PSParser]::Tokenize( (Get-Content -Path $PS1File.FullName), [ref]$null) # Identify the declared functions in the file $DeclaredFunctions = $ast.where( { $_.Type -eq "Keyword" -and $_.Content -eq "function" } ) foreach ($DeclaredFunction in $DeclaredFunctions) { # Derive the name of the declared function [String]$FunctionName = ($ast.where( { $_.Startline -eq $DeclaredFunction.StartLine -and $_.Type -eq "CommandArgument" } )).Content if (-not $PublicFunctions.Name.Contains($FunctionName)) { $PrivateFunctions.Add($FunctionName) | Out-Null Write-Verbose -Message "Found a private function named $FunctionName" } } } # Run through all the PS1Files found to be analyzed. Get commands they use. Scope of these and so forth. foreach ($PS1File in $PS1Files) { # Collection to hold the commands used by the function. Ordered to reflect the point-in-time of each commad invocation. Need to declare it here. If not "the past" iterated PS1File FunctionCommandHierarchy will be added to the CallGraphObjects collection. [System.Collections.ArrayList]$FunctionCommandHierarchy = New-Object System.Collections.ArrayList # Tokenize the content of the file $ast = [System.Management.Automation.PSParser]::Tokenize( (Get-Content -Path $PS1File.FullName), [ref]$null) # Identify the declared functions in the file. Goes from top to bottom. So the first declared function will be at idx 0 [Array]$DeclaredFunctions = $ast.where( { $_.Type -eq "Keyword" -and $_.Content -eq "function" } ) Write-Verbose -Message "There is $($DeclaredFunctions.Count) declared functions in the file named $($PS1File.Name)." foreach ($DeclaredFunction in $DeclaredFunctions) { # Derive the name of the declared function [String]$FunctionName = ($ast.where( { $_.Startline -eq $DeclaredFunction.StartLine -and $_.Type -eq "CommandArgument" } )).Content # Control if the function is a public function in the module being analyzed if (-not $PublicFunctions.Name.Contains($FunctionName)) { # Parse the AST of the private function to find the CommandArguments used if ($DeclaredFunctions.Count -gt 1) { <# - There is more than 1 declared function in the file being parsed. Need to get the commands used by the current function in a controlled way. If not, the risk is that CommandArguments used by other functions in the file will be incorrectly included. #> # Get the index of the current function. $IdxOfThisDeclaredFunction = $DeclaredFunctions.IndexOf($DeclaredFunction) Write-Verbose -Message "Idx of this functions is > $IdxOfThisDeclaredFunction" # Ensure that we do not make out of bounds lookups & control if this the last declared function in the file or not. if (-not ($IdxOfThisDeclaredFunction+1 -gt $DeclaredFunctions.Count-1) ) { # Get the start-line of the next function $NextItemInDeclaredFunctions = $DeclaredFunctions.Item($IdxOfThisDeclaredFunction+1) Write-Verbose -Message "Next item is > $($NextItemInDeclaredFunctions | Out-String)" # Control if the declared function contains inline functions. if ($NextItemInDeclaredFunctions.StartColumn -gt $DeclaredFunction.StartColumn) { <# - There are inline functions in the file. #> # Create a collection to hold the inline functions declared in the "Mother" function. [System.Collections.ArrayList]$InlineFunctions = New-Object System.Collections.Specialized.OrderedDictionary $InlineFunctions.Add($NextItemInDeclaredFunctions) | Out-Null # Put the rest of the inline functions of this function into a collection. If any. if (-not ($IdxOfThisDeclaredFunction+2 -gt $DeclaredFunctions.Count-1)) { for ($i = $IdxOfThisDeclaredFunction+2; $i -le $DeclaredFunctions.Count-1; $i++) { $InlineFunction = $DeclaredFunctions.Item($i) if ($InlineFunction.StartColumn -gt $DeclaredFunction.StartColumn) { $InlineFunctions.Add($InlineFunction) | Out-Null } } } Write-Verbose -Message "The inline functions retrived in $FunctionName > $($InlineFunctions | Out-String)" # Collection to hold start & end info on the inline function/s used by this function [System.Collections.ArrayList]$InlineFunctionsStartEndInfo = New-Object System.Collections.ArrayList # Determine the loc (lines of code) to filter out. When parsing the function that uses the inline functions foreach ($InlineFunction in $InlineFunctions) { # Get the endline of current inline function being iterated over [int]$ParseEndLine = Calculate-FunctionBoundary -AST $AST -StartLine $InlineFunction.StartLine # Add it to the collection holding start & end info on the inline functions found in the "mother" function $InlineFuncStartEndInfo = @{ "Endline" = $ParseEndLine "StartLine" = $InlineFunction.StartLine } $InlineFunctionsStartEndInfo.Add($InlineFuncStartEndInfo) | Out-Null Write-Verbose -Message "InlineFunctionsStartEndInfo now > $($InlineFunctionsStartEndInfo | Out-String)" } # End of foreach on the discovered inline functions. <# - Parse the function containing the inline function/s #> # Get the ASTObjects of the function containing the inline functions. Minus the inline functions themselves. $ContainingFunctionAST = Filter-AST -AST $ast -LinesToExclude $InlineFunctionsStartEndInfo # Get the commands used by the function containing the inline functions. $CommandsUsed = Filter-AST -AST $ContainingFunctionAST -ASTQuery $ASTQuery_CommandsUsed Write-Verbose -Message "Commands used by the containing function > $($CommandsUsed | Out-String)" } else { # Declare the lines to parse to. The boundaries of the function. $ParseStartLine = $DeclaredFunction.StartLine $ParseEndLine = $NextItemInDeclaredFunctions.StartLine-1 Write-Verbose -Message "Parsing by endline. Line to parse to is > $ParseEndLine" # Get the commands used by the function containing the inline functions. $CommandsUsed = Filter-AST -AST $ast -ASTQuery "`$_.Type -eq `"Command`" -and `$_.StartLine -ge $ParseStartLine -and `$_.EndLine -le $ParseEndLine" } } elseif ($DeclaredFunctions.Count -gt 1) { <# - We know that there are more than one funtion declared in the PS1 file being analyzed. The current function being iterated over is the last declared function in the file. However we still need to parse ONLY this function. Dot-sourcing not used as it cannot not help us catch inline functions or functions that in other ways is hidden. #> # Get the endline of the function. [int]$ParseEndLine = Calculate-FunctionBoundary -AST $AST -StartLine $InlineFunction.StartLine Write-Verbose -Message "Parsing by endline. Line to parse to is > $ParseEndLine" # Get the commands used by the function containing the inline functions. $CommandsUsed = Filter-AST -AST $ast -ASTQuery "`$_.Type -eq `"Command`" -and `$_.StartLine -ge $($DeclaredFunction.StartLine) -and `$_.EndLine -le $ParseEndLine" } else { Write-Verbose -Message "Not parsing by endline." # Get the commands used by the function. $CommandsUsed = Filter-AST -AST $ast -ASTQuery $ASTQuery_CommandsUsed } } else { Write-Verbose -Message "Not parsing by endline." # Get the commands used by the function. $CommandsUsed = Filter-AST -AST $ast -ASTQuery $ASTQuery_CommandsUsed } Write-Verbose -Message "Found the following commands used in the private function $FunctionName > $($CommandsUsed | Out-String)" if ($null -ne $CommandsUsed) { # Ordered collection to hold the commands found in the command/function being analyzed [System.Collections.ArrayList]$CommandsUsedInfo = New-Object System.Collections.Specialized.OrderedDictionary foreach ($Command in $CommandsUsed) { # "Translate" command short-hands to their full-length counterpart. if ($FullNameCommands.Contains($Command.Content)) { [String]$CommandName = $FullNameCommands."$($Command.Content)" } else { [String]$CommandName = $Command.Content } if ($DebugCommandsToExclude.Count -eq 0 -or $DebugCommandsToExclude -notcontains $CommandName) { # Control the scope/type of the command. If it is an external, a public or a private command if ($PrivateFunctions.Contains($CommandName)) { [String]$CommandScope = "Private" } elseif ($PublicFunctions.Contains($CommandName)) { [String]$CommandScope = "Public" } else { [String]$CommandScope = "External" } Write-Verbose -Message "The command $($CommandName) found in the private function $FunctionName has the following scope > $CommandScope" # Create a custom object to hold the info on the command analyzed $CommandInfo = @{ "CommandName" = $CommandName "CommandScope" = $CommandScope } $CommandInfoCustomObject = New-Object -TypeName PSCustomObject -Property $CommandInfo # Add the command info to the collection $CommandsUsedInfo.Add($CommandInfoCustomObject) | Out-Null } } # End of foreach on $CommandsUsed in the ps1 file. # Add the analyzed info to the collection that holds all the aggregated info, derived by analyzing the public function/command currently being iterated over $FunctionCommandHierarchy.Add(@{ "Affiliation" = $FunctionName "Commands" = $CommandsUsedInfo "Type" = "PrivateCommands" }) | Out-Null } # End of conditional on "content" in $CommandsUsed } else { Write-Verbose -Message "The function named $FunctionName is a public function" } # End of conditional on the function scope (private or public). } # End of foreach on each $DeclaredFunction in $DeclaredFunctions if ($FunctionCommandHierarchy.Count -gt 0) { # Add the result of analyzing the PS1 file & its commands/functions to the CallGraphObjects collection $CallGraphObjects.Add($FunctionCommandHierarchy) | Out-Null } } Write-Verbose -Message "$($PrivateFunctions.Count) private function/s found in the $($Module.Name) module." Write-Verbose -Message "CallGraphObjects count is > $($CallGraphObjects.Count)" <# - Analyze Public functions in the module. #> # Parse the AST of the public funtions to discover the CommandArguments used foreach ($PublicFunction in $PublicFunctions) { # Collection to hold the commands used by the function. Ordered to reflect the point-in-time of each commad invocation. Need to declare it here. If not "the past" iterated Public function PublicFunctionCommandHierarchy will be added to the CallGraphObjects collection. [System.Collections.ArrayList]$PublicFunctionCommandHierarchy = New-Object System.Collections.ArrayList # Tokenize the AST $ast = [System.Management.Automation.PSParser]::Tokenize( $($PublicFunction.Definition), [ref]$null) # Get the commands used in the code (references to other functions/cmdlets in the code) $CommandsUsed = $ast.where( { $_.Type -eq "Command" } ) if ($null -ne $CommandsUsed) { # Ordered collection to hold the commands found in the command/function being analyzed [System.Collections.ArrayList]$CommandsUsedInfo = New-Object System.Collections.Specialized.OrderedDictionary foreach ($Command in $CommandsUsed) { # "Translate" command short-hands to their full-length counterpart. if ($FullNameCommands.Contains($Command.Content)) { [String]$CommandName = $FullNameCommands."$($Command.Content)" } else { [String]$CommandName = $Command.Content } Write-Verbose -Message "CommandName was derived to > $CommandName" if ($DebugCommandsToExclude.Count -eq 0 -or $DebugCommandsToExclude -notcontains $CommandName) { # Control if it is a private function in the module if ($PrivateFunctions.Contains($CommandName)) { [String]$CommandScope = "Private" } # Control if it is a public function in the module if ($PublicFunctions.Name.Contains($CommandName)) { [String]$CommandScope = "Public" } # Control if the command is defined in an external module if (-not $PrivateFunctions.Contains($CommandName) -and -not $PublicFunctions.Name.Contains($CommandName)) { [String]$CommandScope = "External" } Write-Verbose -Message "The command $($CommandName) found in the public function $PublicFunction has the following scope > $CommandScope" # Create a custom object to hold the info on the command analyzed $CommandInfo = @{ "CommandName" = $CommandName "CommandScope" = $CommandScope } $CommandInfoCustomObject = New-Object -TypeName PSCustomObject -Property $CommandInfo # Add the command info to the collection $CommandsUsedInfo.Add($CommandInfoCustomObject) | Out-Null } # End of conditional on Exclude } # End of foreach on $CommandsUsed # Add the analyzed info to the collection that holds all the aggregated info, derived by analyzing the public function/command currently being iterated over $PublicFunctionCommandHierarchy.Add(@{ "Affiliation" = $PublicFunction.Name "Commands" = $CommandsUsedInfo "Type" = "PublicCommands" }) | Out-Null } if ($null -ne $PublicFunctionCommandHierarchy) { # Add the result of analyzing the Public function to the CallGraphObjects collection $CallGraphObjects.Add($PublicFunctionCommandHierarchy) | Out-Null } } <# - Create the Graph #> # Generate randomized list of colors for the private commands/functions. [Array]$PrivateColorsTemp = "aliceblue","antiquewhite","aquamarine","aquamarine4","bisque","blue","blueviolet","brown1","brown1","cadetblue2","chartreuse1","chartreuse4","chocolate1", "cornflowerblue","cornsilk3","cyan","cyan4","darkolivegreen2","darkorchid","darkorchid4","darkorchid4","darksalmon","darkseagreen3","darkslategray","deeppink","deepskyblue4","firebrick", "gold1","green3","mediumorchid","mediumpurple","mistyrose","moccasin","yellow3" [System.Collections.ArrayList]$PrivateColors = New-Object System.Collections.ArrayList $PrivateColors.AddRange($PrivateColorsTemp) | Out-Null # Initial public Graph element color. if ($Coloring -eq "NoColors") { $PublicFuncColor = "" } else { $PublicFuncColor = "dodgerblue2" } # Generate the graph $graphData = Graph ModuleCallGraph -Attributes @{rankdir=$RealGraphDirection} { # Graph the root node. To which all other nodes will be rooted. Node ProjectRoot -Attribute @{label="$($Module.Name)";shape='invhouse'} # Simple counter for having a number to add to graph elements where duplicate elements are needed. $GraphElementNumber = 0 Write-Verbose -Message "Number of callgraphObjects > $($CallGraphObjects.Count)" # Create nodes on the graph on all the analyzed data foreach ($CallGraphObject in $CallGraphObjects) { # Iterate over each potential CommandHierarchy object (from either the PublicCommandHierarchy or the FunctionCommandHierarchy collection). If there is only object. Still okay, will only iterate that one time (in bandcamp). foreach ($CommandHierarchy in $CallGraphObject) { # Control that the command/function actually used any other commands/functions if ($CommandHierarchy.Commands.CommandsUsedInfo.Count -gt 0) { Write-Verbose -Message "Command count is $($CommandHierarchy.Commands.CommandsUsedInfo.Count) for the command named $($CommandHierarchy.Affiliation)" if ($CommandHierarchy.Type -eq "PublicCommands") { # "Attach" the public command/function to the root node Edge ProjectRoot, $CommandHierarchy.Affiliation -Attributes @{label="Public";color="$PublicFuncColor";penwidth=$PenWidth} } # Create subgraphs for each command/function SubGraph -Attributes @{style='filled';color='lightgrey'} -ScriptBlock { # Counter used to annotate the nodes with the chronological order by which the command was called $CommandCounter = 1 # Create nodes for all the commands/functions the command/function uses $CommandHierarchy.Commands.GetEnumerator() | ForEach-Object { if ($CommandHierarchy.Type -eq "PrivateCommands") { # Create a unique node for the private command/function & style it. Node $CommandHierarchy.Affiliation Node @{style='filled';color='white'} if ($_.CommandScope -eq "Private") { # Create a duplicate node for a private command used by this private command. Duplicate because the command already has its own subgraph. But we want it to be graphed underneath this command also. Node -Name "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$($_.CommandName)"} # Graph a relationship between the private command & the private command that uses it Edge $CommandHierarchy.Affiliation, "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$CommandCounter\n$($_.CommandScope)"} # Graph a relationship between the private command in this subgraph to the subgraph where the private command is fully graphed. Edge "$($_.CommandName)$GraphElementNumber", $_.CommandName -Attributes @{arrowsize=0} } else { # Create a duplicate node for the command used by this command Node -Name "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$($_.CommandName)"} Edge $CommandHierarchy.Affiliation, "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$CommandCounter\n$($_.CommandScope)"} } } else { # Set the style of the nodes created in this section. Node @{style='filled';color='white'} if ($_.CommandScope -eq "Private") { # Set the color to be used on private command edge lines. Set here in order to variate colors as much as possible. if ($Coloring -eq "NoColors") { $PrivateFuncColor = "" } else { $PrivateFuncColor = Get-Random -InputObject $PrivateColors } # Create a duplicate node for a private command used by this public command. Duplicate because the command already has its own subgraph. But we want it to be graphed in relation to this command. Node -Name "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$($_.CommandName)"} # Graph a relationship between the private command & the public command/function that uses it. Edge $CommandHierarchy.Affiliation, "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$CommandCounter\n$($_.CommandScope)";color="$PrivateFuncColor";penwidth=$PenWidth} # Graph a relationship between the private command in this subgraph to the subgraph where the private command is fully graphed. Edge "$($_.CommandName)$GraphElementNumber", $_.CommandName -Attributes @{arrowsize=0;color="$PrivateFuncColor";penwidth=$PenWidth} <# if (-not ($Coloring -eq "NoColor")) { # Remove the private color from the collection. To avoid it being re-used. As the color on each private function should be unique. $PrivateColors.Remove($PrivateFuncColor) } #> } else { # Create a duplicate node for the public command/function. Node -Name "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$($_.CommandName)"} # Graph a relationship between this command & the command it uses Edge $CommandHierarchy.Affiliation, "$($_.CommandName)$GraphElementNumber" -Attributes @{label="$CommandCounter\n$($_.CommandScope)"} } } # Add 1 to the CommandCounter. Has to happen here as it on the each single functions command hierarchy level. $CommandCounter++ } } } else { if ($CommandHierarchy.Type -eq "PublicCommands") { # Create a subgraph. So that the layout of a public function that uses no commands has the same layout as the other public commands. SubGraph -Attributes @{style="filled";color="lightgrey"} -ScriptBlock { Node -Name $CommandHierarchy.Affiliation } # Graph a relationship between the project root & the node created for the command/function which uses no commands Edge ProjectRoot, $CommandHierarchy.Affiliation -Attributes @{label="Public";color="$PublicFuncColor";penwidth=$PenWidth} } } # Add 1 to the GraphElementNumber. This has to happen at this level. If not, Graph elements (function) that uses no commands will cause a GraphElementNumber to be used twice. $GraphElementNumber++ } # End of foreach on either the FunctionCommandHierarchy or PublicCommandHierarchy collection. } # End of of foreach CallGraphObject in the CallGraphObjects collection } # Output the graph $ExportPSGraphSplatting = @{ Destination = $GraphOutputPath OutputFormat = $OutputFormat ShowGraph = $ShowGraph } $GraphOutput = $graphData | Export-PSGraph @ExportPSGraphSplatting Write-Host "The graph was generated. Find it in $($GraphOutput.DirectoryName) with the name $($GraphOutput.Name)" -ForegroundColor Green } } |