Refactor.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\Refactor.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName Refactor.Import.DoDotSource -Fallback $false if ($Refactor_dotsourcemodule) { $script:doDotSource = $true } <# Note on Resolve-Path: All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. This is important when testing for paths. #> # Detect whether at some level loading individual module files, rather than the compiled module was enforced $importIndividualFiles = Get-PSFConfigValue -FullName Refactor.Import.IndividualFiles -Fallback $false if ($Refactor_importIndividualFiles) { $importIndividualFiles = $true } if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true } function Import-ModuleFile { <# .SYNOPSIS Loads files into the module on module import. .DESCRIPTION This helper function is used during module initialization. It should always be dotsourced itself, in order to proper function. This provides a central location to react to files being imported, if later desired .PARAMETER Path The path to the file to load .EXAMPLE PS C:\> . Import-ModuleFile -File $function.FullName Imports the file stored in $function according to import policy #> [CmdletBinding()] Param ( [string] $Path ) $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath if ($doDotSource) { . $resolvedPath } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } } #region Load individual files if ($importIndividualFiles) { # Execute Preimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) { . Import-ModuleFile -Path $path } # Import all internal functions foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Import all public functions foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Execute Postimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) { . Import-ModuleFile -Path $path } # End it here, do not load compiled code below return } #endregion Load individual files #region Load compiled code <# This file loads the strings documents from the respective language folders. This allows localizing messages and errors. Load psd1 language files for each language you wish to support. Partial translations are acceptable - when missing a current language message, it will fallback to English or another available language. #> Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'Refactor' -Language 'en-US' function Find-BreakingChange { <# .SYNOPSIS Search a given AST for any breaking change contained. .DESCRIPTION Search a given AST for any breaking change contained. Use Import-ReBreakingChange to load definitions of breaking changes to look for. .PARAMETER Ast The AST to search .PARAMETER Name The name of the file being searched. Use this to identify non-filesystem code. .PARAMETER Changes The breaking changes to look out for. .EXAMPLE PS C:\> Find-BreakingChange -Ast $ast -Changes $changes Find all instances of breaking changes found within $ast. #> [OutputType([Refactor.BreakingChange])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.Language.Ast] $Ast, [string] $Name, [Parameter(Mandatory = $true)] [hashtable] $Changes ) if (-not $Name) { $Name = $Ast.Extent.File } $filePath = $Name $fileName = ($Name -split "\\|/")[-1] $commands = Read-ReScriptCommand -Ast $Ast foreach ($commandToken in $commands) { foreach ($change in $Changes[$commandToken.Name]) { if ($change.Parameters.Count -lt 1) { [Refactor.BreakingChange]@{ Path = $filePath Name = $fileName Line = $commandToken.Line Command = $commandToken.Name Type = 'Error' Description = $change.Description Module = $change.Module Version = $change.Version Tags = $change.Tags } continue } foreach ($parameter in $change.Parameters.Keys) { if ($commandToken.Parameters.Keys -contains $parameter) { [Refactor.BreakingChange]@{ Path = $filePath Name = $fileName Line = $commandToken.Line Command = $commandToken.Name Parameter = $parameter Type = 'Error' Description = $change.Parameters.$parameter Module = $change.Module Version = $change.Version Tags = $change.Tags } continue } if ($commandToken.ParametersKnown) { continue } [Refactor.BreakingChange]@{ Path = $filePath Name = $fileName Line = $commandToken.Line Command = $commandToken.Name Parameter = $parameter Type = 'Warning' Description = "Not all parameters on command resolveable - might be in use. $($change.Parameters.$parameter)" Module = $change.Module Version = $change.Version Tags = $change.Tags } } } } } function Get-AstCommand { <# .SYNOPSIS Parses out all commands contained in an AST. .DESCRIPTION Parses out all commands contained in an Abstract Syntax Tree. Will also resolve all parameters used as able and indicate, whether all could be identified. .PARAMETER Ast The Ast object to scan. .PARAMETER Splat Splat Data to use for parameter mapping .EXAMPLE PS C:\> Get-AstCommand -Ast $parsed.Ast -Splat $splats Returns all commands in the specified AST, mapping to the splats contained in $splats #> [OutputType([Refactor.CommandToken])] [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [System.Management.Automation.Language.Ast] $Ast, [AllowNull()] $Splat ) process { $splatHash = @{ } foreach ($splatItem in $Splat) { $splatHash[$splatItem.Ast] = $splatItem } $allCommands = Search-ReAst -Ast $Ast -Filter { $args[0] -is [System.Management.Automation.Language.CommandAst] } foreach ($command in $allCommands) { $result = [Refactor.CommandToken]::new($command.Data) # Splats foreach ($splatted in $command.Data.CommandElements | Where-Object Splatted) { $result.HasSplat = $true $splatItem = $splatHash[$splatted] if (-not $splatItem.ParametersKnown) { $result.ParametersKnown = $false } foreach ($parameterName in $splatItem.Parameters.Keys) { $result.parameters[$parameterName] = $parameterName } $result.Splats[$splatted] = $splatItem } $result } } } function Clear-ReTokenTransformationSet { <# .SYNOPSIS Remove all registered transformation sets. .DESCRIPTION Remove all registered transformation sets. .EXAMPLE PS C:\> Clear-ReTokenTransformationSet Removes all registered transformation sets. #> [CmdletBinding()] Param ( ) process { $script:tokenTransformations = @{ } } } function Convert-ReScriptFile { <# .SYNOPSIS Perform AST-based replacement / refactoring of scriptfiles .DESCRIPTION Perform AST-based replacement / refactoring of scriptfiles This process depends on two factors: + Token Provider + Token Transformation Sets The provider is a plugin that performs the actual AST analysis and replacement. For example, by default the "Command" provider allows renaming commands or their parameters. Use Register-ReTokenprovider to define your own plugin. Transformation Sets are rules that are applied to the tokens of a specific provider. For example, the "Command" provider could receive a rule that renames the command "Get-AzureADUser" to "Get-MgUser" Use Import-ReTokenTransformationSet to provide such rules. .PARAMETER Path Path to the scriptfile to modify. .PARAMETER ProviderName Name of the Token Provider to apply. Defaults to: '*' .PARAMETER Backup Whether to create a backup of the file before modifying it. .PARAMETER OutPath Folder to which to write the converted scriptfile. .PARAMETER Force Whether to update files that end in ".backup.ps1" By default these are skipped, as they would be the backup-files of previous conversions ... or even the current one, when providing input via pipeline! .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .EXAMPLE PS C:\> Get-ChildItem C:\scripts -Recurse -Filter *.ps1 | Convert-ReScriptFile Converts all scripts under C:\scripts according to the provided transformation sets. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [OutputType([Refactor.TransformationResult])] [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'inplace')] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] [Alias('FullName')] [string[]] $Path, [PsfArgumentCompleter('Refactor.TokenProvider')] [string[]] $ProviderName = '*', [Parameter(ParameterSetName = 'inplace')] [switch] $Backup, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')] [PsfValidateScript('PSFramework.Validate.FSPath.Folder', ErrorString = 'PSFramework.Validate.FSPath.Folder')] [string] $OutPath, [switch] $Force ) begin { $lastResolvedPath = "" } process { if ($OutPath -ne $lastResolvedPath) { $resolvedOutPath = Resolve-PSFPath -Path $OutPath $lastResolvedPath = $OutPath } foreach ($file in $Path | Resolve-PSFPath) { if (-not $Force -and -not $OutPath -and $file -match '\.backup\.ps1$|\.backup\.psm1$') { continue } Write-PSFMessage -Message 'Processing file: {0}' -StringValues $file $scriptfile = [Refactor.ScriptFile]::new($file) try { $result = $scriptfile.Transform($scriptfile.GetTokens($ProviderName)) } catch { Write-PSFMessage -Level Error -Message 'Failed to convert file: {0}' -StringValues $file -Target $scriptfile -ErrorRecord $_ -EnableException $true -PSCmdlet $PSCmdlet } if ($OutPath) { Invoke-PSFProtectedCommand -Action 'Replacing content of script' -Target $file -ScriptBlock { $scriptfile.WriteTo($resolvedOutPath, "") } -PSCmdlet $PSCmdlet -EnableException $EnableException -Continue } else { Invoke-PSFProtectedCommand -Action 'Replacing content of script' -Target $file -ScriptBlock { $scriptfile.Save($Backup.ToBool()) } -PSCmdlet $PSCmdlet -EnableException $EnableException -Continue } $result Write-PSFMessage -Message 'Finished processing file: {0} | Transform Count {1} | Success {2}' -StringValues $file, $result.Count, $result.Success } } } function Convert-ReScriptToken { <# .SYNOPSIS Converts a token using the conversion logic defined per token type. .DESCRIPTION Converts a token using the conversion logic defined per token type. This could mean renaming a command, changing a parameter, etc. The actual logic happens in the converter scriptblock provided by the Token Provider. This should update the changes in the Token object, as well as returning a summary object as output. .PARAMETER Token The token to transform. .PARAMETER Preview Instead of returning the new text for the token, return a metadata object providing additional information. .EXAMPLE PS C:\> Convert-ReScriptToken -Token $token Returns an object, showing what would have been done, had this been applied. #> [OutputType([Refactor.Change])] [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Refactor.ScriptToken[]] $Token ) process { foreach ($tokenObject in $Token) { $provider = Get-ReTokenProvider -Name $tokenObject.Type if (-not $provider) { Stop-PSFFunction -Message "No provider found for type $($tokenObject.Type)" -Target $tokenObject -EnableException $true -Cmdlet $PSCmdlet } & $provider.Converter $tokenObject } } } function Get-ReScriptFile { <# .SYNOPSIS Reads a scriptfile and returns an object representing it. .DESCRIPTION Reads a scriptfile and returns an object representing it. Use this for custom transformation needs - for example to only process some select token kinds. .PARAMETER Path Path to the scriptfile to read. .PARAMETER Name The name of the script. Used for identifying scriptcode that is not backed by an actual file. .PARAMETER Content The code of the script. Used to provide scriptcode without requiring the backing of a file. .EXAMPLE PS C:\> Get-ReScriptFile -Path C:\scripts\script.ps1 Reads in the specified scriptfile #> [OutputType([Refactor.ScriptFile])] [CmdletBinding(DefaultParameterSetName = 'Path')] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')] [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] [Alias('FullName')] [string[]] $Path, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Content')] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Content')] [AllowEmptyString()] [string] $Content ) process { if ($Path) { foreach ($file in $Path | Resolve-PSFPath) { [Refactor.ScriptFile]::new($file) } } if ($Name -and $PSBoundParameters.ContainsKey('Content')) { [Refactor.ScriptFile]::new($Name, $Content) } } } function Get-ReSplat { <# .SYNOPSIS Resolves all splats in the offered Ast. .DESCRIPTION Resolves all splats in the offered Ast. This will look up any hashtable definitions and property-assignments to that hashtable, whether through property notation, index assignment or add method. It will then attempt to define an authorative list of properties assigned to that hashtable. If the result is unclear, that will be indicated accordingly. Return Objects include properties: + Splat : The original Ast where the hashtable is used for splatting + Parameters : A hashtable containing all properties clearly identified + ParametersKnown : Whether we are confident of having identified all properties passed through as parameters .PARAMETER Ast The Ast object to search. Use "Read-ReAst" to parse a scriptfile into an AST object. .EXAMPLE PS C:\> Get-ReSplat -Ast $ast Returns all splats used in the Abstract Syntax Tree object specified #> [OutputType([Refactor.Splat])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.Language.Ast] $Ast ) $splats = Search-ReAst -Ast $Ast -Filter { if ($args[0] -isnot [System.Management.Automation.Language.VariableExpressionAst]) { return $false } $args[0].Splatted } if (-not $splats) { return } foreach ($splat in $splats) { # Select the last variable declaration _before_ the splat is being used $assignments = Search-ReAst -Ast $Ast -Filter { if ($args[0] -isnot [System.Management.Automation.Language.AssignmentStatementAst]) { return $false } if ($args[0].Left -isnot [System.Management.Automation.Language.VariableExpressionAst]) { return $false } $args[0].Left.VariablePath.UserPath -eq $splat.Data.VariablePath.UserPath } $declaration = $assignments | Where-Object { $_.Start -lt $splat.Start } | Sort-Object { $_.Start } -Descending | Select-Object -First 1 $result = [Refactor.Splat]@{ Ast = $splat.Data } if (-not $declaration) { $result.ParametersKnown = $false $result continue } $propertyAssignments = Search-ReAst -Ast $Ast -Filter { if ($args[0].Extent.StartLineNumber -le $declaration.Start) { return $false } if ($args[0].Extent.StartLineNumber -ge $splat.Start) { return $false } $isAssignment = $( ($args[0] -is [System.Management.Automation.Language.AssignmentStatementAst]) -and ( ($args[0].Left.VariablePath.UserPath -eq $splat.Data.VariablePath.UserPath) -or ($args[0].Left.Expression.VariablePath.UserPath -eq $splat.Data.VariablePath.UserPath) -or ($args[0].Left.Target.VariablePath.UserPath -eq $splat.Data.VariablePath.UserPath) ) ) $isAddition = $( ($args[0] -is [System.Management.Automation.Language.InvokeMemberExpressionAst]) -and ($args[0].Expression.VariablePath.UserPath -eq $splat.Data.VariablePath.UserPath) -and ($args[0].Member.Value -eq 'Add') ) $isAddition -or $isAssignment } if ($declaration.Data.Right.Expression -isnot [System.Management.Automation.Language.HashtableAst]) { $result.ParametersKnown = $false } foreach ($pair in $declaration.Data.Right.Expression.KeyValuePairs) { if ($pair.Item1 -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $result.Parameters[$pair.Item1.Value] = $pair.Item1.Value } else { $result.ParametersKnown = $false } } foreach ($assignment in $propertyAssignments) { switch ($assignment.Type) { 'AssignmentStatementAst' { if ($assignment.Data.Left.Member -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $result.Parameters[$assignment.Data.Left.Member.Value] = $assignment.Data.Left.Member.Value continue } if ($assignment.Data.Left.Index -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $result.Parameters[$assignment.Data.Left.Index.Value] = $assignment.Data.Left.Index.Value continue } $result.ParametersKnown = $false } 'InvokeMemberExpressionAst' { if ($assignment.Data.Arguments[0] -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $result.Parameters[$assignment.Data.Arguments[0].Value] = $assignment.Data.Arguments[0].Value continue } $result.ParametersKnown = $false } } } # Include all relevant Ast objects $result.Assignments = @($declaration.Data) + @($propertyAssignments.Data) | Remove-PSFNull -Enumerate $result } } function Get-ReToken { <# .SYNOPSIS Scans a scriptfile for all tokens contained within. .DESCRIPTION Scans a scriptfile for all tokens contained within. .PARAMETER Path Path to the file to scan .PARAMETER ProviderName Names of the providers to use. Defaults to '*' .EXAMPLE PS C:\> Get-ChildItem C:\scripts | Get-ReToken Returns all tokens for all scripts under C:\scripts #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] [Alias('FullName')] [string[]] $Path, [PsfArgumentCompleter('Refactor.TokenProvider')] [string[]] $ProviderName = '*' ) process { foreach ($file in $Path | Resolve-PSFPath) { Write-PSFMessage -Message 'Processing file: {0}' -StringValues $file $scriptfile = [Refactor.ScriptFile]::new($file) $scriptfile.GetTokens($ProviderName) } } } function Get-ReTokenProvider { <# .SYNOPSIS List registered token providers. .DESCRIPTION List registered token providers. Token providers are scriptblocks that will parse an Abstract Syntax Tree, searching for specific types of code content. These can then be used for code analysis or refactoring. .PARAMETER Name Name of the provider to filter by. Defaults to "*" .PARAMETER Component Return only the specified component: + All: Return the entire provider + Tokenizer: Return only the scriptblock, that parses out the Ast + Converter: Return only the scriptblock, that applies transforms to tokens Default: All .EXAMPLE PS C:\> Get-ReTokenProvider List all token providers #> [OutputType([Refactor.TokenProvider])] [CmdletBinding()] Param ( [PsfArgumentCompleter('Refactor.TokenProvider')] [string[]] $Name = '*', [ValidateSet('All','Tokenizer','Converter')] [string] $Component = 'All' ) process { foreach ($provider in $script:tokenProviders.GetEnumerator()) { $matched = $false foreach ($nameFilter in $Name) { if ($provider.Key -like $nameFilter) { $matched = $true } } if (-not $matched) { continue } if ($Component -eq 'Tokenizer') { $provider.Value.Tokenizer continue } if ($Component -eq 'Converter') { $provider.Value.Converter continue } $provider.Value } } } function Get-ReTokenTransformationSet { <# .SYNOPSIS List the registered transformation sets. .DESCRIPTION List the registered transformation sets. .PARAMETER Type The type of token to filter by. Defaults to '*' .EXAMPLE PS C:\> Get-ReTokenTransformationSet Return all registerd transformation sets. #> [CmdletBinding()] param ( [string] $Type = '*' ) process { foreach ($pair in $script:tokenTransformations.GetEnumerator()) { if ($pair.Key -notlike $Type) { continue } $pair.Value.Values } } } function Import-ReTokenTransformationSet { <# .SYNOPSIS Imports a token transformation file. .DESCRIPTION Imports a token transformation file. Can be either json or psd1 format Root level must contain at least three nodes: + Version: The schema version of this file. Should be 1 + Type: The type of token being transformed. E.g.: "Command" + Content: A hashtable containing the actual sets of transformation. The properties required depend on the Token Provider. Example: @{ Version = 1 Type = 'Command' Content = @{ "Get-AzureADUser" = @{ Name = "Get-AzureADUser" NewName = "Get-MgUser" Comment = "Filter and search parameters cannot be mapped straight, may require manual attention" Parameters = @{ Search = "Filter" # Rename Search on "Get-AzureADUser" to "Filter" on "Get-MgUser" } } } } .PARAMETER Path Path to the file to import. Must be json or psd1 format .EXAMPLE PS C:\> Import-ReTokenTransformationSet -Path .\azureAD-to-graph.psd1 Imports all the transformationsets stored in "azureAD-to-graph.psd1" in the current folder. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('FullName')] [string[]] $Path ) begin { function Import-TransformV1 { [CmdletBinding()] param ( $Data, $Path ) $msgDefault = @{ Level = "Warning" FunctionName = 'Import-ReTokenTransformationSet' PSCmdlet = $PSCmdlet StringValues = $Path } $defaultType = $Data.Type $contentHash = $Data.Content | ConvertTo-PSFHashtable foreach ($entry in $contentHash.Values) { $entryHash = $entry | ConvertTo-PSFHashtable if ($defaultType -and -not $entryHash.Type) { $entryHash.Type = $defaultType } if (-not $entryHash.Type) { Write-PSFMessage @msgDefault -Message "Invalid entry within file - No Type defined: {0}" -Target $entryHash continue } try { Register-ReTokenTransformation @entryHash -ErrorAction Stop } catch { Write-PSFMessage @msgDefault -Message "Error processing entry within file: {0}" -ErrorRecord $_ -Target $entryHash continue } } } } process { :main foreach ($filePath in $Path | Resolve-PSFPath -Provider FileSystem) { if (Test-Path -LiteralPath $filePath -PathType Container) { continue } $fileInfo = Get-Item -LiteralPath $filePath $data = switch ($fileInfo.Extension) { '.json' { Get-Content -LiteralPath $fileInfo.FullName | ConvertFrom-Json } '.psd1' { Import-PSFPowerShellDataFile -LiteralPath $fileInfo.FullName } default { $exception = [System.ArgumentException]::new("Unknown file extension: $($fileInfo.Extension)") Write-PSFMessage -Message "Error importing $($fileInfo.FullName): Unknown file extension: $($fileInfo.Extension)" -Level Error -Exception $exception -EnableException $true -Target $fileInfo -OverrideExceptionMessage continue main } } switch ("$($data.Version)") { "1" { Import-TransformV1 -Data $data -Path $fileInfo.FullName } default { $exception = [System.ArgumentException]::new("Unknown schema version: $($data.Version)") Write-PSFMessage -Message "Error importing $($fileInfo.FullName): Unknown schema version: $($data.Version)" -Level Error -Exception $exception -EnableException $true -Target $fileInfo -OverrideExceptionMessage continue main } } } } } function New-ReToken { <# .SYNOPSIS Creates a new, generic token object. .DESCRIPTION Creates a new, generic token object. Use this in script-only Token Providers, trading the flexibility of a custom Token type for the simplicity of not having to deal with C# or classes. .PARAMETER Type The type of the token. Must match the name of the provider using it. .PARAMETER Name The name of the token. Used to match the token against transforms. .PARAMETER Ast An Ast object representing the location in the script the token deals with. Purely optional, so long as your provider knows how to deal with the token. .PARAMETER Data Any additional data to store with the token. .EXAMPLE PS C:\> New-ReToken -Type variable -Name ComputerName Creates a new token of type variable with name ComputerName. Assumes you have registered a Token Provider of name variable. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [OutputType([Refactor.GenericToken])] [CmdletBinding()] param ( [parameter(Mandatory = $true)] [string] $Type, [parameter(Mandatory = $true)] [string] $Name, [System.Management.Automation.Language.Ast] $Ast, [object] $Data ) process { $token = [Refactor.GenericToken]::new($Type, $Name) $token.Ast = $Ast $token.Data = $Data $token } } function Read-ReAst { <# .SYNOPSIS Parse the content of a script .DESCRIPTION Uses the powershell parser to parse the content of a script or scriptfile. .PARAMETER ScriptCode The scriptblock to parse. .PARAMETER Path Path to the scriptfile to parse. Silently ignores folder objects. .EXAMPLE PS C:\> Read-Ast -ScriptCode $ScriptCode Parses the code in $ScriptCode .EXAMPLE PS C:\> Get-ChildItem | Read-ReAst Parses all script files in the current directory #> [CmdletBinding()] param ( [Parameter(Position = 0, ParameterSetName = 'Script', Mandatory = $true)] [string] $ScriptCode, [Parameter(Mandatory = $true, ParameterSetName = 'File', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('FullName')] [string[]] $Path ) process { foreach ($file in $Path) { Write-PSFMessage -Level Verbose -Message "Processing $file" -Target $file $item = Get-Item $file if ($item.PSIsContainer) { Write-PSFMessage -Level Verbose -Message "is folder, skipping $file" -Target $file continue } $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($item.FullName, [ref]$tokens, [ref]$errors) [pscustomobject]@{ Ast = $ast Tokens = $tokens Errors = $errors File = $item.FullName } } if ($ScriptCode) { if (-not $content) { $content = $ScriptCode } $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$errors) [pscustomobject]@{ Ast = $ast Tokens = $tokens Errors = $errors Source = $ScriptCode } } } } function Read-ReAstComponent { <# .SYNOPSIS Search for instances of a given AST type. .DESCRIPTION Search for instances of a given AST type. This command - together with its sibling command "Write-ReAstComponent" - is designed to simplify code updates. Use the data on the object, update its "NewText" property and use the "Write"-command to apply it back to the original document. .PARAMETER Name Name of the "file" to search. Use this together with the 'ScriptCode' parameter when you do not actually have a file object and just the code itself. Usually happens when scanning a git repository or otherwise getting the data from some API/service. .PARAMETER ScriptCode Code of the "file" to search. Use this together with the 'Name' parameter when you do not actually have a file object and just the code itself. Usually happens when scanning a git repository or otherwise getting the data from some API/service. .PARAMETER Path Path to the file to scan. Uses wildcards to interpret results. .PARAMETER LiteralPath Literal path to the file to scan. Does not interpret the path and instead use it as it is written. Useful when there are brackets in the filename. .PARAMETER Select The AST types to select for. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .EXAMPLE PS C:\> Get-ChildItem -Recurse -Filter *.ps1 | Read-ReAstComponent -Select FunctionDefinitionAst, ForEachStatementAst Reads all ps1 files in the current folder and subfolders and scans for all function definitions and foreach statements. #> [OutputType([Refactor.Component.AstResult])] [CmdletBinding(DefaultParameterSetName = 'File')] param ( [Parameter(Position = 0, ParameterSetName = 'Script', Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Position = 1, ParameterSetName = 'Script', Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [Alias('Content')] [string] $ScriptCode, [Parameter(Mandatory = $true, ParameterSetName = 'File', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('FullName')] [string[]] $Path, [Parameter(Mandatory = $true, ParameterSetName = 'Literal')] [string[]] $LiteralPath, [Parameter(Mandatory = $true)] [PsfArgumentCompleter('Refactor.AstTypes')] [PsfValidateSet(TabCompletion = 'Refactor.AstTypes')] [string[]] $Select, [switch] $EnableException ) process { #region Resolve Targets $targets = [System.Collections.ArrayList]@() if ($Name) { $null = $targets.Add( [PSCustomObject]@{ Name = $Name Content = $ScriptCode Path = '' } ) } foreach ($pathEntry in $Path) { try { $resolvedPaths = Resolve-PSFPath -Path $pathEntry -Provider FileSystem } catch { Write-PSFMessage -Level Warning -Message 'Failed to resolve path: {0}' -StringValues $pathEntry -ErrorRecord $_ -EnableException $EnableException continue } foreach ($resolvedPath in $resolvedPaths) { $null = $targets.Add( [PSCustomObject]@{ Name = Split-Path -Path $resolvedPath -Leaf Path = $resolvedPath } ) } } foreach ($pathEntry in $LiteralPath) { try { $resolvedPath = (Get-Item -LiteralPath $pathEntry -ErrorAction Stop).FullName } catch { Write-PSFMessage -Level Warning -Message 'Failed to resolve path: {0}' -StringValues $pathEntry -ErrorRecord $_ -EnableException $EnableException continue } $null = $targets.Add( [PSCustomObject]@{ Name = Split-Path -Path $resolvedPath -Leaf Path = $resolvedPath } ) } #endregion Resolve Targets Clear-ReTokenTransformationSet Register-ReTokenTransformation -Type ast -TypeName $Select foreach ($target in $targets) { # Create ScriptFile object if ($target.Path) { $scriptFile = [Refactor.ScriptFile]::new($target.Path) } else { $scriptFile = [Refactor.ScriptFile]::new($target.Name, $target.Content) } # Generate Tokens $tokens = $scriptFile.GetTokens('Ast') # Profit! $result = [Refactor.Component.ScriptResult]::new() $result.File = $scriptFile $result.Types = $Select foreach ($token in $tokens) { $result.Tokens.Add($token) } foreach ($token in $tokens) { [Refactor.Component.AstResult]::new($token, $scriptFile, $result) } } Clear-ReTokenTransformationSet } } function Read-ReScriptCommand { <# .SYNOPSIS Reads a scriptfile and returns all commands contained within. .DESCRIPTION Reads a scriptfile and returns all commands contained within. Includes parameters used and whether all parameters could be resolved. .PARAMETER Path Path to the file to scan .PARAMETER Ast An already provided Abstract Syntax Tree object to process .EXAMPLE Get-ChildItem C:\scripts -Recurse -Filter *.ps1 | Read-ReScriptCommand Returns all commands in all files under C:\scripts #> [OutputType([Refactor.CommandToken])] [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')] [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] [Alias('FullName')] [string[]] $Path, [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Ast')] [System.Management.Automation.Language.Ast] $Ast ) process { if ($Path) { foreach ($file in $Path | Resolve-PSFPath) { $parsed = Read-ReAst -Path $file $splats = Get-ReSplat -Ast $parsed.Ast Get-AstCommand -Ast $parsed.Ast -Splat $splats } } foreach ($astObject in $Ast) { $splats = Get-ReSplat -Ast $astObject Get-AstCommand -Ast $astObject -Splat $splats } } } function Register-ReTokenProvider { <# .SYNOPSIS Register a Token Provider, that implements scanning and refactor logic. .DESCRIPTION Register a Token Provider, that implements scanning and refactor logic. For example, the "Command" Token Provider supports: - Finding all commands called in a script, resolving all parameters used as possible. - Renaming commands and their parameters. For examples on how to implement this, see: Provider: https://github.com/FriedrichWeinmann/Refactor/blob/development/Refactor/internal/tokenProvider/command.token.ps1 Token Class: https://github.com/FriedrichWeinmann/Refactor/blob/development/library/Refactor/Refactor/CommandToken.cs Note: Rather than implementing your on Token Class, you can use New-ReToken and the GenericToken class. This allows you to avoid the need for coding your own class, but offers no extra functionality. .PARAMETER Name Name of the token provider. .PARAMETER TransformIndex The property name used to map a transformation rule to a token. .PARAMETER ParametersMandatory The parameters a transformation rule MUST have to be valid. .PARAMETER Parameters The parameters a transformation rule accepts / supports. .PARAMETER Tokenizer Code that provides the required tokens when executed. Accepts one argument: An Ast object. .PARAMETER Converter Code that applies the registered transformation rule to a given token. Accepts two arguments: A Token and a boolean. The boolean argument representing, whether a preview object, representing the expected changes should be returned. .EXAMPLE PS C:\> Register-ReTokenProvider @param Registers a token provider. A useful example for what to provide is a bit more than can be fit in an example block, See an example provider here: Provider: https://github.com/FriedrichWeinmann/Refactor/blob/development/Refactor/internal/tokenProvider/command.token.ps1 Token Class: https://github.com/FriedrichWeinmann/Refactor/blob/development/library/Refactor/Refactor/CommandToken.cs #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $TransformIndex, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $ParametersMandatory, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Parameters, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ScriptBlock] $Tokenizer, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ScriptBlock] $Converter ) process { $script:tokenProviders[$Name] = [Refactor.TokenProvider]@{ Name = $Name TransformIndex = $TransformIndex TransformParametersMandatory = $ParametersMandatory TransformParameters = $Parameters Tokenizer = $Tokenizer Converter = $Converter } } } function Register-ReTokenTransformation { <# .SYNOPSIS Register a transformation rule used when refactoring scripts. .DESCRIPTION Register a transformation rule used when refactoring scripts. Rules are specific to their token type. Different types require different parameters, which are added via dynamic parameters. For more details, look up the documentation for the specific token type you want to register a transformation for. .PARAMETER Type The type of token to register a transformation over. .EXAMPLE PS C:\> Register-ReTokenTransformation -Type Command -Name Get-AzureADUser -NewName Get-MGUser -Comment "The filter parameter requires manual adjustments if used" Registers a transformation rule, that will convert the Get-AzureADUser command to Get-MGUser #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Type ) DynamicParam { $parameters = (Get-ReTokenProvider -Name $Type).TransformParameters if (-not $parameters) { return } $results = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary foreach ($parameter in $parameters) { $parameterAttribute = New-Object System.Management.Automation.ParameterAttribute $parameterAttribute.ParameterSetName = '__AllParameterSets' $attributesCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute] $attributesCollection.Add($parameterAttribute) $RuntimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter($parameter, [object], $attributesCollection) $results.Add($parameter, $RuntimeParam) } $results } begin { $commonParam = 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable' } process { $provider = Get-ReTokenProvider -Name $Type if (-not $provider) { Stop-PSFFunction -Message "No provider found for type $Type" -Target $PSBoundParameters -EnableException $true -Cmdlet $PSCmdlet } $hash = $PSBoundParameters | ConvertTo-PSFHashtable -Exclude $commonParam $missingMandatory = $provider.TransformParametersMandatory | Where-Object { $_ -notin $hash.Keys } if ($missingMandatory) { Stop-PSFFunction -Message "Error defining a $($Type) transformation: $($provider.TransformParametersMandatory -join ",") must be specified! Missing: $($missingMandatory -join ",")" -Target $PSBoundParameters -EnableException $true -Cmdlet $PSCmdlet } if (-not $script:tokenTransformations[$Type]) { $script:tokenTransformations[$Type] = @{ } } $script:tokenTransformations[$Type][$hash.$($provider.TransformIndex)] = [PSCustomObject]$hash } } function Search-ReAst { <# .SYNOPSIS Tool to search the Abstract Syntax Tree .DESCRIPTION Tool to search the Abstract Syntax Tree .PARAMETER Ast The Ast to search .PARAMETER Filter The filter condition to apply .EXAMPLE PS C:\> Search-ReAst -Ast $ast -Filter { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] } Searches for all function definitions #> [OutputType([Refactor.SearchResult])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.Language.Ast] $Ast, [Parameter(Mandatory = $true)] [ScriptBlock] $Filter ) process { $results = $Ast.FindAll($Filter, $true) foreach ($result in $results) { [Refactor.SearchResult]::new($result) } } } function Test-ReSyntax { <# .SYNOPSIS Tests whether the syntax of a given scriptfile or scriptcode is valid. .DESCRIPTION Tests whether the syntax of a given scriptfile or scriptcode is valid. This uses the PowerShell syntax validation. Some cases - especially around PowerShell classes - may evaluate as syntax error when missing dependencies. .PARAMETER Path Path to the file to test. .PARAMETER LiteralPath Non-interpreted path to the file to test. .PARAMETER Code Actual code to test. .PARAMETER Not Reverses the returned logic: A syntax error found returns as $true, an error-free script returns $false. .EXAMPLE PS C:\> Test-ReSyntax .\script.ps1 Verifies the syntax of the file 'script.ps1' in the current path. #> [OutputType([bool])] [CmdletBinding(DefaultParameterSetName = 'path')] param ( [Parameter(Mandatory = $true, ParameterSetName = 'path', Position = 0)] [string] $Path, [Parameter(Mandatory = $true, ParameterSetName = 'literal')] [string] $LiteralPath, [Parameter(Mandatory = $true, ParameterSetName = 'code')] [string] $Code, [switch] $Not ) process { if ($Code) { $result = Read-ReAst -ScriptCode $Code ($result.Errors -as [bool]) -eq (-not $Not) } if ($Path) { try { $resolvedPath = Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem } catch { return $false -eq (-not $Not) } $fileItem = Get-Item -LiteralPath $resolvedPath } if ($LiteralPath) { try { $fileItem = Get-Item -LiteralPath $LiteralPath -ErrorAction Stop } catch { return $false -eq (-not $Not)} } $tokens = $null $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseFile($fileItem.FullName, [ref]$tokens, [ref]$errors) ($errors -as [bool]) -eq (-not $Not) } } function Write-ReAstComponent { <# .SYNOPSIS Updates a scriptfile that was read from using Read-ReAstComponent. .DESCRIPTION Updates a scriptfile that was read from using Read-ReAstComponent. Automatically picks up the file to update from the scan results. Expects the caller to first apply changes on the test results outside of the Refactor module. This command processes all output in end, to support sane pipeline processing of multiple findings from a single file. .PARAMETER Components Component objects scanned from the file to update. Use Read-ReAstComponent. Pass all objects from the search in one go (or pipe them into the command) .PARAMETER PassThru Return result objects from the conversion. By default, this command updates the files in situ or in the target location (OutPath). Whether you use this parameter or not, scan results that were provided input from memory - and are thus not backed by a file - will always be returned as output. .PARAMETER Backup Whether to create a backup of the file before modifying it. .PARAMETER OutPath Folder to which to write the converted scriptfile. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER EnableException This parameters disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts. .EXAMPLE PS C:\> Write-ReAstComponent -Components $scriptParts Writes back the components in $scriptParts, which had previously been generated using Read-ReAstComponent, then had their content modified. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [OutputType([Refactor.Component.ScriptFileConverted])] [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'default')] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [Refactor.Component.AstResult[]] $Components, [switch] $PassThru, [Parameter(ParameterSetName = 'inplace')] [switch] $Backup, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')] [PsfValidateScript('PSFramework.Validate.FSPath.Folder', ErrorString = 'PSFramework.Validate.FSPath.Folder')] [string] $OutPath, [switch] $EnableException ) begin { $componentObjects = [System.Collections.ArrayList]@() if ($OutPath) { $resolvedOutPath = Resolve-PSFPath -Path $OutPath } } process { $null = $componentObjects.AddRange($Components) } end { $grouped = $componentObjects | Group-Object { $_.Result.Id } foreach ($tokenGroup in $grouped) { $scriptFile = $tokenGroup.Group[0].File $before = $scriptFile.Text $null = $scriptFile.Transform($tokenGroup.Group.Token) if (-not $OutPath -and $before -eq $scriptFile.Text) { continue } if ($PassThru) { [Refactor.Component.ScriptFileConverted]::new($tokenGroup.Group[0].Result) } #region From File if ($scriptFile.FromFile) { if ($OutPath) { Invoke-PSFProtectedCommand -Action "Writing updated script to $resolvedOutPath" -Target $scriptFile.Path -ScriptBlock { $scriptfile.WriteTo($resolvedOutPath, "") } -PSCmdlet $PSCmdlet -EnableException $EnableException -Continue continue } Invoke-PSFProtectedCommand -Action 'Replacing content of script' -Target $scriptFile.Path -ScriptBlock { $scriptfile.Save($Backup.ToBool()) } -PSCmdlet $PSCmdlet -EnableException $EnableException -Continue } #endregion From File #region From Content else { if ($OutPath) { Invoke-PSFProtectedCommand -Action "Writing updated script to $resolvedOutPath" -Target $scriptFile.Path -ScriptBlock { $scriptfile.WriteTo($resolvedOutPath, $scriptFile.Path) } -PSCmdlet $PSCmdlet -EnableException $EnableException -Continue continue } # Since it's already returned once for $PassThru, let's not double up here if (-not $PassThru) { [Refactor.Component.ScriptFileConverted]::new($tokenGroup[0].Result) } } #endregion From Content } } } function Clear-ReBreakingChange { <# .SYNOPSIS Removes entire datasets of entries from the list of registered breaking changes. .DESCRIPTION Removes entire datasets of entries from the list of registered breaking changes. .PARAMETER Module The module to unregister. .PARAMETER Version The version of the module to unregister. If not specified, ALL versions are unregistered. .EXAMPLE PS C:\> Clear-ReBreakingChange -Module MyModule Removes all breaking changes of all versions of "MyModule" from the in-memory configuration set. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Module, [Parameter(ValueFromPipelineByPropertyName = $true)] [Version] $Version ) process { if (-not $script:breakingChanges[$Module]) { return } if (-not $Version) { $script:breakingChanges.Remove($Module) return } $script:breakingChanges[$Module].Remove($Version) if ($script:breakingChanges[$Module].Count -lt 1) { $script:breakingChanges.Remove($Module) } } } function Get-ReBreakingChange { <# .SYNOPSIS Searches for a breaking change configuration entry that has previously registered. .DESCRIPTION Searches for a breaking change configuration entry that has previously registered. .PARAMETER Module The module to search by. Defaults to '*' .PARAMETER Version The version of the module to search for. By default, changes for all versions are returned. .PARAMETER Command The affected command to search for. Defaults to '*' .PARAMETER Tags Only include changes that contain at least one of the listed tags. .EXAMPLE PS C:\> Get-ReBreakingChange Returns all registered breaking change configuration entries. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $Module = '*', [Parameter(ValueFromPipelineByPropertyName = $true)] [Version] $Version, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $Command = '*', [Parameter(ValueFromPipelineByPropertyName = $true)] [AllowEmptyCollection()] [string[]] $Tags ) process { $script:breakingChanges.Values.Values | Write-Output | Where-Object { if ($_.Module -notlike $Module) { return } if ($_.Command -notlike $Command) { return } if ($Version -and $_.Version -ne $Version) { return } if ($Tags -and -not ($_.Tags | Where-Object { $_ -in $Tags })) { return } $true } } } function Import-ReBreakingChange { <# .SYNOPSIS Imports a set of Breaking Change configurations from file. .DESCRIPTION Imports a set of Breaking Change configurations from file. Expects a PowerShell Document File (.psd1) Example layout of import file: @{ MyModule = @{ '2.0.0' = @{ 'Get-Something' = @{ Description = 'Command was fully redesigned' } 'Get-SomethingElse' = @{ Parameters @{ Param1 = 'Parameter was dropped' Param2 = 'Accepts string only now and will not try to parse custom objects anymore' Param3 = 'Was renamed to Param4' } Labels = @('primary') } } } } .PARAMETER Path Path to the file(s) to import. .PARAMETER EnableException Replaces user friendly yellow warnings with bloody red exceptions of doom! Use this if you want the function to throw terminating errors you want to catch. .EXAMPLE PS C:\> Import-ReBreakingChange -Path .\mymodule.break.psd1 Imports the mymodule.break.psd1 #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'File')] [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] [Alias('FullName')] [string[]] $Path, [switch] $EnableException ) process { foreach ($file in $Path | Resolve-PSFPath) { $dataSet = Import-PSFPowerShellDataFile -Path $file foreach ($module in $dataSet.Keys) { foreach ($version in $dataSet.$module.Keys) { if (-not ($version -as [version])) { Stop-PSFFunction -Message "Invalid Version node $($version) for module $($module). Ensure it is a valid version number, prerelease version notations are not supported!" -EnableException $EnableException -Continue -Cmdlet $PSCmdlet } foreach ($command in $dataSet.$module.$version.Keys) { $commandData = $dataSet.$module.$version.$command $param = @{ Module = $module Version = $version Command = $command } if ($commandData.Description) { $param.Description = $commandData.Description } if ($commandData.Parameters) { $param.Parameters = $commandData.Parameters } if ($commandData.Tags) { $param.Tags = $commandData.Tags } Register-ReBreakingChange @param } } } } } } function Register-ReBreakingChange { <# .SYNOPSIS Register a breaking change. .DESCRIPTION Register a breaking change. A breaking change is a definition of a command or its parameters that were broken at a given version of the module. This can include tags to classify the breaking change. .PARAMETER Module The name of the module the breaking change occured in. .PARAMETER Version The version of the module in which the breaking change was applied. .PARAMETER Command The command that was changed in a breaking manner. .PARAMETER Description A description to show when reporting the command itself as being broken. This is the message shown in the report when finding this breaking change, so make sure it contains actionable information for the user. .PARAMETER Parameters A hashtable containing parameters that were broken, maping parametername to a description of what was changed. That description will be shown to the user, so make sure it contains actionable information. Defining parameters will cause the command to only generate scan results when the parameter is being used or the total parameters cannot be determined. It is possible to assign multiple breaking changes to the same command - one for the command and one for parameters. .PARAMETER Tags Any tags to assign to the breaking change. Breaking Change scans can be filtered by tags. .EXAMPLE PS C:\> Register-ReBreakingChange -Module MyModule -Version 2.0.0 -Command Get-Something -Description 'Redesigned command' Adds a breaking change for the Get-Something command in the module MyModule at version 2.0.0 #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Module, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [Version] $Version, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Command, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $Description, [Parameter(ValueFromPipelineByPropertyName = $true)] [Hashtable] $Parameters = @{ }, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $Tags = @() ) process { if (-not $script:breakingChanges[$Module]) { $script:breakingChanges[$Module] = @{ } } if (-not $script:breakingChanges[$Module][$Version]) { $script:breakingChanges[$Module][$Version] = [System.Collections.Generic.List[object]]::new() } $object = [PSCustomObject]@{ Module = $Module Version = $Version Command = $Command Description = $Description Parameters = $Parameters Tags = $Tags } $script:breakingChanges[$Module][$Version].Add($object) } } function Search-ReBreakingChange { <# .SYNOPSIS Search script files for breaking changes. .DESCRIPTION Search script files for breaking changes. Use Import-ReBreakingChange or Register-ReBreakingChange to define which command was broken in what module and version. .PARAMETER Path Path to the file(s) to scan. .PARAMETER Content Script Content to scan. .PARAMETER Name Name of the scanned content .PARAMETER Module The module(s) to scan for. This can be either a name (and then use the version definitions from -FromVersion and -ToVersion parameters), or a Hashtable with three keys: Name, FromVersion and ToVersion. Example inputs: MyModule @{ Name = 'MyModule'; FromVersion = '1.0.0'; ToVersion = '2.0.0' } .PARAMETER FromVersion The version of the module for which the script was written. .PARAMETER ToVersion The version of the module to which the script is being migrated .PARAMETER Tags Only include breaking changes that include one of these tags. This allows targeting a specific subset of breaking changes. .EXAMPLE PS C:\> Get-ChildItem -Path C:\scripts -Recurse -Filter *.ps1 | Search-ReBreakingChange -Module Az -FromVersion 5.0 -ToVersion 7.0 Return all breaking changes in all scripts between Az v5.0 and v7.0. Requires a breaking change definition file for the Az Modules to be registered, in order to work. #> [CmdletBinding(DefaultParameterSetName = 'File')] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'File')] [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')] [Alias('FullName')] [string[]] $Path, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Content')] [string] $Content, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Content')] [string] $Name, [Parameter(Mandatory = $true)] [object[]] $Module, [Version] $FromVersion, [Version] $ToVersion, [string[]] $Tags = @() ) begin { #region Collect Changes to apply $changeObjects = foreach ($moduleItem in $Module) { $fromV = $FromVersion $toV = $ToVersion if ($moduleItem.FromVersion) { $fromV = $moduleItem.FromVersion } if ($moduleItem.ToVersion) { $toV = $moduleItem.ToVersion } $moduleName = $moduleItem.Name if (-not $moduleName) { $moduleName = $moduleItem.ModuleName } if (-not $moduleName) { $moduleName = $moduleItem -as [string] } if (-not $fromV) { Write-PSFMessage -Level Warning -Message "Unable to identify the starting version from which the module $moduleItem is being migrated! be sure to specify the '-FromVersion' parameter." -Target $moduleItem } if (-not $toV) { Write-PSFMessage -Level Warning -Message "Unable to identify the destination version from which the module $moduleItem is being migrated! be sure to specify the '-ToVersion' parameter." -Target $moduleItem } if (-not $fromV) { Write-PSFMessage -Level Warning -Message "Unable to identify the name of the module being migrated! Be sure to specify a legitimate name to the '-Module' parameter." -Target $moduleItem } if (-not ($fromV -and $toV -and $moduleName)) { Stop-PSFFunction -Message "Failed to resolve the migration metadata - provide a module, the source and the destination version number!" -EnableException $true -Cmdlet $PSCmdlet } Get-ReBreakingChange -Module $moduleName -Tags $Tags | Where-Object { $fromV -lt $_.Version -and $toV -ge $_.Version } } $changes = @{ } foreach ($group in $changeObjects | Group-Object Command) { $changes[$group.Name] = $group.Group } #endregion Collect Changes to apply } process { switch ($PSCmdlet.ParameterSetName) { File { foreach ($filePath in $Path) { $ast = Read-ReAst -Path $filePath Find-BreakingChange -Ast $ast.Ast -Changes $changes } } Content { $ast = Read-ReAst -ScriptCode $Content Find-BreakingChange -Ast $ast.Ast -Name $Name -Changes $changes } } } } <# This is an example configuration file By default, it is enough to have a single one of them, however if you have enough configuration settings to justify having multiple copies of it, feel totally free to split them into multiple files. #> <# # Example Configuration Set-PSFConfig -Module 'Refactor' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'" #> Set-PSFConfig -Module 'Refactor' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." Set-PSFConfig -Module 'Refactor' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." <# Stored scriptblocks are available in [PsfValidateScript()] attributes. This makes it easier to centrally provide the same scriptblock multiple times, without having to maintain it in separate locations. It also prevents lengthy validation scriptblocks from making your parameter block hard to read. Set-PSFScriptblock -Name 'Refactor.ScriptBlockName' -Scriptblock { } #> Register-PSFTeppScriptblock -Name 'Refactor.AstTypes' -ScriptBlock { 'Ast' 'SequencePointAst' 'ScriptBlockAst' 'ParamBlockAst' 'NamedBlockAst' 'NamedAttributeArgumentAst' 'AttributeBaseAst' 'AttributeAst' 'TypeConstraintAst' 'ParameterAst' 'StatementBlockAst' 'StatementAst' 'TypeDefinitionAst' 'UsingStatementAst' 'FunctionDefinitionAst' 'IfStatementAst' 'DataStatementAst' 'LabeledStatementAst' 'LoopStatementAst' 'ForEachStatementAst' 'ForStatementAst' 'DoWhileStatementAst' 'DoUntilStatementAst' 'WhileStatementAst' 'SwitchStatementAst' 'TryStatementAst' 'TrapStatementAst' 'BreakStatementAst' 'ContinueStatementAst' 'ReturnStatementAst' 'ExitStatementAst' 'ThrowStatementAst' 'PipelineBaseAst' 'ErrorStatementAst' 'ChainableAst' 'PipelineChainAst' 'PipelineAst' 'AssignmentStatementAst' 'CommandBaseAst' 'CommandAst' 'CommandExpressionAst' 'ConfigurationDefinitionAst' 'DynamicKeywordStatementAst' 'BlockStatementAst' 'MemberAst' 'PropertyMemberAst' 'FunctionMemberAst' 'CompilerGeneratedMemberFunctionAst' 'CatchClauseAst' 'CommandElementAst' 'CommandParameterAst' 'ExpressionAst' 'ErrorExpressionAst' 'TernaryExpressionAst' 'BinaryExpressionAst' 'UnaryExpressionAst' 'AttributedExpressionAst' 'ConvertExpressionAst' 'MemberExpressionAst' 'InvokeMemberExpressionAst' 'BaseCtorInvokeMemberExpressionAst' 'TypeExpressionAst' 'VariableExpressionAst' 'ConstantExpressionAst' 'StringConstantExpressionAst' 'ExpandableStringExpressionAst' 'ScriptBlockExpressionAst' 'ArrayLiteralAst' 'HashtableAst' 'ArrayExpressionAst' 'ParenExpressionAst' 'SubExpressionAst' 'UsingExpressionAst' 'IndexExpressionAst' 'RedirectionAst' 'MergingRedirectionAst' 'FileRedirectionAst' 'AssignmentTarget' } Register-PSFTeppScriptblock -Name 'Refactor.TokenProvider' -ScriptBlock { (Get-ReTokenProvider).Name } <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name Refactor.alcohol #> New-PSFLicense -Product 'Refactor' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2022-03-05") -Text @" Copyright (c) 2022 Friedrich Weinmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "@ # Module-wide storage for token provider scriptblocks $script:tokenProviders = @{ } # Transformation rules for tokens $script:tokenTransformations = @{ } # Container for Breaking Change data $script:breakingChanges = @{ } $tokenizer = { param ( $Ast ) $astTypes = Get-ReTokenTransformationSet -Type Ast | ForEach-Object TypeName $astObjects = Search-ReAst -Ast $Ast -Filter { $args[0].GetType().Name -in $astTypes } foreach ($astObject in $astObjects.Data) { [Refactor.AstToken]::new($astObject) } } $converter = { param ( [Refactor.ScriptToken] $Token, $Preview ) <# The AST Token is special in that it expects the actual changes to be applied not by configuration but manually outside of the process. As such it is pointless to use in the full, config-only driven workflow of Convert-ReScriptFile. Instead, manually creating the scriptfile object and executing the workflows is the way to go here. #> # Return changes $Token.GetChanges() } $parameters = @( 'TypeName' ) $param = @{ Name = 'Ast' TransformIndex = 'TypeName' ParametersMandatory = 'TypeName' Parameters = $parameters Tokenizer = $tokenizer Converter = $converter } Register-ReTokenProvider @param $tokenizer = { Read-ReScriptCommand -Ast $args[0] } $converter = { param ( [Refactor.ScriptToken] $Token ) $transform = Get-ReTokenTransformationSet -Type Command | Where-Object Name -EQ $Token.Name if ($transform.MsgInfo) { $Token.WriteMessage('Information', $transform.MsgInfo, $transform) } if ($transform.MsgWarning) { $Token.WriteMessage('Warning', $transform.MsgWarning, $transform) } if ($transform.MsgError) { $Token.WriteMessage('Error', $transform.MsgError, $transform) } $changed = $false $items = foreach ($commandElement in $Token.Ast.CommandElements) { # Command itself if ($commandElement -eq $Token.Ast.CommandElements[0]) { if ($transform.NewName) { $transform.NewName; $changed = $true } else { $commandElement.Value } continue } if ($commandElement -isnot [System.Management.Automation.Language.CommandParameterAst]) { $commandElement.Extent.Text continue } if (-not $transform.Parameters) { $commandElement.Extent.Text continue } # Not guaranteed to be a hashtable $transform.Parameters = $transform.Parameters | ConvertTo-PSFHashtable if (-not $transform.Parameters[$commandElement.ParameterName]) { $commandElement.Extent.Text continue } "-$($transform.Parameters[$commandElement.ParameterName])" $changed = $true } #region Conditional Messages if ($transform.InfoParameters) { $transform.InfoParameters | ConvertTo-PSFHashtable } foreach ($parameter in $transform.InfoParameters.Keys) { if ($Token.Parameters[$parameter]) { $Token.WriteMessage('Information', $transform.InfoParameters[$parameter], $transform) } } if ($transform.WarningParameters) { $transform.WarningParameters | ConvertTo-PSFHashtable } foreach ($parameter in $transform.WarningParameters.Keys) { if ($Token.Parameters[$parameter]) { $Token.WriteMessage('Warning', $transform.WarningParameters[$parameter], $transform) } } if ($transform.ErrorParameters) { $transform.ErrorParameters | ConvertTo-PSFHashtable } foreach ($parameter in $transform.ErrorParameters.Keys) { if ($Token.Parameters[$parameter]) { $Token.WriteMessage('Error', $transform.ErrorParameters[$parameter], $transform) } } if (-not $Token.ParametersKnown) { if ($transform.UnknownInfo) { $Token.WriteMessage('Information', $transform.UnknownInfo, $transform) } if ($transform.UnknownWarning) { $Token.WriteMessage('Warning', $transform.UnknownInfo, $transform) } if ($transform.UnknownError) { $Token.WriteMessage('Error', $transform.UnknownInfo, $transform) } } #endregion Conditional Messages $Token.NewText = $items -join " " if (-not $changed) { $Token.NewText = $Token.Text } #region Add changes for splat properties foreach ($property in $Token.Splats.Values.Parameters.Keys) { if ($transform.Parameters.Keys -notcontains $property) { continue } foreach ($ast in $Token.Splats.Values.Assignments) { #region Case: Method Invocation if ($ast -is [System.Management.Automation.Language.InvokeMemberExpressionAst]) { if ($ast.Arguments[0].Value -ne $property) { continue } $Token.AddChange($ast.Arguments[0].Extent.Text, ("'{0}'" -f ($transform.Parameters[$property] -replace "^'|'$|^`"|`"$")), $ast.Arguments[0].Extent.StartOffset, $ast) continue } #endregion Case: Method Invocation #region Case: Original assignment if ($ast.Left -is [System.Management.Automation.Language.VariableExpressionAst]) { foreach ($hashKey in $ast.Right.Expression.KeyValuePairs.Item1) { if ($hashKey.Value -ne $property) { continue } $Token.AddChange($hashKey.Extent.Text, ("'{0}'" -f ($transform.Parameters[$property] -replace "^'|'$|^`"|`"$")), $hashKey.Extent.StartOffset, $hashKey) } continue } #endregion Case: Original assignment #region Case: Property assignment if ($ast.Left -is [System.Management.Automation.Language.MemberExpressionAst]) { if ($ast.Left.Member.Value -ne $property) { continue } $Token.AddChange($ast.Left.Member.Extent.Text, $transform.Parameters[$property], $ast.Left.Member.Extent.StartOffset, $ast) continue } #endregion Case: Property assignment #region Case: Index assignment if ($ast.Left -is [System.Management.Automation.Language.IndexExpressionAst]) { if ($ast.Left.Index.Value -ne $property) { continue } $Token.AddChange($ast.Left.Index.Extent.Text, ("'{0}'" -f ($transform.Parameters[$property] -replace "^'|'$|^`"|`"$")), $ast.Left.Index.Extent.StartOffset, $ast) continue } #endregion Case: Index assignment } } #endregion Add changes for splat properties # Return changes $Token.GetChanges() } $parameters = @( 'Name' 'NewName' 'Parameters' 'MsgInfo' 'MsgWarning' 'MsgError' 'InfoParameters' 'WarningParameters' 'ErrorParameters' 'UnknownInfo' 'UnknownWarning' 'UnknownError' ) $param = @{ Name = 'Command' TransformIndex = 'Name' ParametersMandatory = 'Name' Parameters = $parameters Tokenizer = $tokenizer Converter = $converter } Register-ReTokenProvider @param $tokenizer = { param ( $Ast ) $functionAsts = Search-ReAst -Ast $Ast -Filter { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] } foreach ($functionAst in $functionAsts.Data) { [Refactor.FunctionToken]::new($functionAst) } } $converter = { param ( [Refactor.ScriptToken] $Token, $Preview ) $transform = Get-ReTokenTransformationSet -Type Function | Where-Object Name -EQ $Token.Name if (-not $transform) { return } #region Function Name if ($transform.NewName) { $startIndex = $Token.Ast.Extent.Text.IndexOf($Token.Ast.Name) + $Token.Ast.Extent.StartOffset $Token.AddChange($Token.Ast.Name, $transform.NewName, $startIndex, $null) $helpData = $Token.Ast.GetHelpContent() $endOffset = $Token.Ast.Body.ParamBlock.Extent.StartOffset if (-not $endOffset) { $Token.Ast.Body.DynamicParamBlock.Extent.StartOffset } if (-not $endOffset) { $Token.Ast.Body.BeginBlock.Extent.StartOffset } if (-not $endOffset) { $Token.Ast.Body.ProcessBlock.Extent.StartOffset } if (-not $endOffset) { $Token.Ast.Body.EndBlock.Extent.StartOffset } foreach ($example in $helpData.Examples) { foreach ($line in $example -split "`n") { if ($line -notmatch "\b$($Token.Ast.Name)\b") { continue } $lineIndex = $Token.Ast.Extent.Text.Indexof($line) $commandIndex = ($line -split "\b$($Token.Ast.Name)\b")[0].Length # Hard-Prevent editing in function body. # Renaming references, including recursive references, is responsibility of the Command token if (($lineIndex + $line.Length) -gt $endOffset) { continue } $Token.AddChange($Token.Ast.Name, $transform.NewName, ($lineIndex + $commandIndex), $null) } } } #endregion Function Name # Return changes $Token.GetChanges() } $parameters = @( 'Name' 'NewName' ) $param = @{ Name = 'Function' TransformIndex = 'Name' ParametersMandatory = 'Name' Parameters = $parameters Tokenizer = $tokenizer Converter = $converter } Register-ReTokenProvider @param #endregion Load compiled code |