Private/FunctionBuilderParser.ps1
# Cached result of checking if PSScriptAnalyzer is installed $script:ScriptAnalyzerAvailable = $null # List of PSScriptAnalyzer rules to ignore when validating functions $script:ScriptAnalyserIgnoredRules = @( "PSReviewUnusedParameter", "PSAvoidUsingWriteHost", "PSUseSingularNouns", "PSUseShouldProcessForStateChangingFunctions" ) # ScriptAnalyzer rules to return custom error messages for rule names that match the keys of the hashtable because the default errors trip up LLM models $script:ScriptAnalyserCustomRuleResponses = @{ "PSAvoidOverwritingBuiltInCmdlets" = { "The name of the function is reserved, rename the function to not collide with internal PowerShell commandlets." } "PSUseApprovedVerbs" = { "The function name has to start with a valid PowerShell verb like $((Get-Verb | Where-Object { $_.Group -eq 'Common' } | Select-Object -ExpandProperty Verb) -join ', ')." } "*ShouldProcess*" = { "The function has to have the CmdletBinding SupportsShouldProcess and use a process block." } } # ScriptAnalyzer custom error messages for messages matching keys in the hashtable because the default errors trip up LLM models $script:ScriptAnalyserCustomMessageResponses = @{ "Script definition uses Write-Host*" = { "Avoid using Write-Host because it might not work in all hosts." } "*Unexpected attribute 'CmdletBinding'*" = { "CmdletBinding must be followed by a param block." } "*uses a plural noun*" = { "Function name can't be a plural$(Get-AifbUnavailableFunctionNames)" } "*':' was not followed by a valid variable name character*" = { 'A variable inside a PowerShell string cannot be followed by a colon, rewrite $foo: needs to be ${foo}: to delimit the variable.' } } # Simple functions that don't need named parameters to work out if they're being used correctly $script:CommandletsExemptFromNamedParameters = @( "Write-Host", "Write-Output", "Write-Error", "Write-Warning", "Write-Verbose", "Where-Object", "ForEach-Object", "Write-Information", "Write-Verbose", "Select-Object", "Import-Module" ) $script:UnavailableCommandletNames = @() function Get-AifbUnavailableFunctionNames { <# .SYNOPSIS Gets a list of function names that have already been attempted that do not work. #> if($script:UnavailableCommandletNames.Count -gt 0) { return " (other unavailable names are $(($script:UnavailableCommandletNames | Group-Object | Select-Object -ExpandProperty "Name") -join ', '))" } else { return "" } } function Test-AifbScriptAnalyzerAvailable { <# .SYNOPSIS Checks if PSScriptAnalyzer is available on this system and uses a cached response to avoid using get-module all the time. #> if($null -eq $script:ScriptAnalyzerAvailable) { if(Get-Module "PSScriptAnalyzer" -ListAvailable -Verbose:$false) { $script:ScriptAnalyzerAvailable = $true } else { Add-AifbLogMessage -Level "WRN" -Message "This module performs better if you have PSScriptAnalyzer installed" -NoRender $script:ScriptAnalyzerAvailable = $false } } return $script:ScriptAnalyzerAvailable } function Write-AifbFunctionParsingOutput { <# .SYNOPSIS Writes parsing output to the output stream and also to the renderer log output as errors. #> param ( # The message to log and format [string] $Message, [object] $Extent, [int] $Line ) Add-AifbLogMessage -Level "ERR" -Message $Message return @{ Line = $Line Extent = $Extent Message = " - $Message" } } function Write-AifbScriptAnalyzerOutput { <# .SYNOPSIS This function will analyze the function text and return the error details for the first line with errors. #> param ( # A function in a text format to be formatted [string] $FunctionText ) $scriptAnalyzerOutput = Invoke-ScriptAnalyzer -ScriptDefinition $FunctionText ` -Severity @("Warning", "Error", "ParseError") ` -ExcludeRule $script:ScriptAnalyserIgnoredRules ` -Verbose:$false if($null -ne $scriptAnalyzerOutput) { $brokenLines = $scriptAnalyzerOutput | Group-Object Line # This originally returned the whole list of errors but it was too much for the LLM to understand, just return the errors for the first line with issues and then fix other errors on future iterations $firstBrokenLine = $brokenLines[0] $brokenLineErrors = $firstBrokenLine.Group.Message $ruleNames = $firstBrokenLine.Group.RuleName # Write the first custom error message that matches and violated PSScriptAnalyzer rules foreach($ruleResponse in $script:ScriptAnalyserCustomRuleResponses.GetEnumerator()) { if($ruleNames | Where-Object { $_ -like $ruleResponse.Key }) { Write-AifbFunctionParsingOutput -Message (Invoke-Command $ruleResponse.Value) -Line $firstBrokenLine.Name return } } # Write the first custom error message that matches and violated PSScriptAnalyzer message foreach($messageResponse in $script:ScriptAnalyserCustomMessageResponses.GetEnumerator()) { if($brokenLineErrors | Where-Object { $_ -like $messageResponse.Key }) { Write-AifbFunctionParsingOutput -Message (Invoke-Command $messageResponse.Value) -Line $firstBrokenLine.Name return } } # Otherwise dump the raw error messages $brokenLineErrors | ForEach-Object { Write-AifbFunctionParsingOutput -Message $_ -Line $firstBrokenLine.Name } } } function Find-AifbCommandletOnline { <# .SYNOPSIS Finds a commandlet online and installs he module it belongs to if the user wants to. .EXAMPLE Find-AifbCommandletOnline -CommandletName "Get-AzActivityLog" #> param ( # The name of the commandlet to find online [string] $CommandletName ) $command = $null $onlineModules = Find-Module -Command $CommandletName -Verbose:$false $localModules = Get-Module -ListAvailable -Verbose:$false if($onlineModules) { $matchingLocalModules = (Compare-Object -ReferenceObject $onlineModules.Name -DifferenceObject $localModules.Name -ExcludeDifferent).InputObject if($matchingLocalModules) { try { Import-Module $matchingLocalModules -Global -ErrorAction "Stop" $command = Get-Command $CommandletName -ErrorAction "Stop" return $command } catch { Write-Warning "Couldn't import command from local module '$($matchingLocalModules)'" } } Write-Host "There are modules online that include the function '$CommandletName' used by ChatGPT. To validate the usage of commandlets in the function the module needs to be installed locally.`n" Write-Host ($onlineModules | Select-Object Name, ProjectUri | Out-String).Trim() while($null -eq $command) { $onlineModuleToInstall = Read-Host "`nEnter the name of one of the modules to install or press enter to get ChatGPT to try use a different command" if(![string]::IsNullOrEmpty($onlineModuleToInstall)) { Install-Module -Name $onlineModuleToInstall.Trim() -Scope CurrentUser -Verbose:$false Import-Module -Name $onlineModuleToInstall.Trim() -Global -Verbose:$false $command = Get-Command $CommandletName Write-Host "" } else { Write-Host "Asking ChatGPT to use another command instead of installing the module for '$CommandletName'." break } } } else { Write-Verbose "No commands matching the name '$CommandletName' were available online." } return $command } function Test-AifbFunctionParsing { <# .SYNOPSIS This function tests the quality of a PowerShell function using PSScriptAnalyzer module. .DESCRIPTION The Test-AifbFunctionParsing function checks the quality of a PowerShell script by using the PSScriptAnalyzer module. If any errors or warnings are detected, the function outputs a list of lines containing errors and their corresponding error messages. If the module is not installed, the function silently bypasses script quality validation because it's not critical to the operation of the AI Script Builder. #> param ( # A function in a text format to be tested [string] $FunctionText ) $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($FunctionText, [ref]$null, [ref]$null) $functions = $scriptAst.FindAll({$args[0].GetType().Name -eq "FunctionDefinitionAst"}, $true) foreach($function in $functions) { if(Get-Command $function.Name -ErrorAction "SilentlyContinue") { Write-AifbFunctionParsingOutput -Message "The name of the function is reserved, rename the function to not collide with common function names$(Get-AifbUnavailableFunctionNames)." -Line $function.Extent.StartLineNumber $script:UnavailableCommandletNames += $FunctionName.Text } } foreach($function in $functions) { if($function.Name -notlike "*-*") { Write-AifbFunctionParsingOutput -Message "The name of the function should follow the PowerShell format of Verb-Noun$(Get-AifbUnavailableFunctionNames)." -Line $function.Extent.StartLineNumber $script:UnavailableCommandletNames += $FunctionName.Text } } if(Test-AifbScriptAnalyzerAvailable) { Write-Verbose "Using PSScriptAnalyzer to validate script quality" Write-AifbScriptAnalyzerOutput -FunctionText $FunctionText } else { Add-AifbLogMessage -Level "WRN" -Message "PSScriptAnalyzer is not installed so falling back on parsing directly with PS internals." try { [scriptblock]::Create($FunctionText) | Out-Null } catch { $innerExceptionErrors = $_.Exception.InnerException.Errors if($innerExceptionErrors) { Write-AifbFunctionParsingOutput -Message $innerExceptionErrors[0].Message -Line 1 } else { Write-AifbFunctionParsingOutput -Message "The script is invalid because of a $($_.FullyQualifiedErrorId)." -Line 1 } } } } function Test-AifbFunctionCommandletUsage { <# .SYNOPSIS This function tests the usage of commandlets in a PowerShell script. .DESCRIPTION The Test-AifbFunctionCommandletUsage function checks the usage of commandlets in a PowerShell script by analyzing the Abstract Syntax Tree (AST) of the script. For each commandlet found in the script, the function checks whether the commandlet is valid and whether any of the commandlet parameters are invalid. .EXAMPLE $FunctionText = Get-Content -Path "C:\Scripts\MyScript.ps1" -Raw Test-AifbFunctionCommandletUsage -ScriptAst $scriptAst This example tests the usage of commandlets in a PowerShell script. .NOTES This could likely be converted to a set of PSScriptAnalyzer custom rules https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/create-custom-rule?view=ps-modules #> param ( # A function in a text format to be tested [string] $FunctionText ) $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($FunctionText, [ref]$null, [ref]$null) $commandlets = $scriptAst.FindAll({$args[0].GetType().Name -eq "CommandAst"}, $true) $functions = $scriptAst.FindAll({$args[0].GetType().Name -eq "FunctionDefinitionAst"}, $true) # Validate each commandlet and return on the first error found because telling the LLM about too many errors at once results in unpredictable fixes foreach($commandlet in $commandlets) { $commandletName = $commandlet.CommandElements[0].Value $commandletParameterNames = $commandlet.CommandElements.ParameterName $commandletParameterElements = @() $hasPipelineInput = $null -ne $commandlet.Parent -and $commandlet.Parent.GetType().Name -eq "PipelineAst" -and $commandlet.Parent.PipelineElements.Count -gt 1 $extent = $commandlet.Extent if($commandlet.CommandElements.Count -gt 1) { $commandletParameterElements = $commandlet.CommandElements[1..($commandlet.CommandElements.Count - 1)] } if($functions.Name -contains $commandletName) { Add-AifbLogMessage -Message "This function calls one of its own functions." continue } $command = Get-Command $commandletName -ErrorAction "SilentlyContinue" # Check online if no local command is found if($null -eq $command) { $command = Find-AifbCommandletOnline -CommandletName $commandletName } if($null -eq $command) { Write-AifbFunctionParsingOutput -Message "The commandlet $commandletName cannot be found, use a different command or add another function to implement the logic$(Get-AifbUnavailableFunctionNames)." -Extent $extent $script:UnavailableCommandletNames += $commandletName return } # Check for missing parameters foreach($param in $commandletParameterNames) { if(![string]::IsNullOrEmpty($param)) { if(!$command.Parameters.ContainsKey($param)) { Write-AifbFunctionParsingOutput -Message "The commandlet $commandletName does not take a parameter named $param, available parameters are $($command.Parameters.Keys -join ', ')." -Extent $extent return } } } # Check for unnamed parameters, these are harder to validate and makes a generated script less obvious as to what it does if($commandletParameterElements.Count -gt 0 -and $script:CommandletsExemptFromNamedParameters -notcontains $commandletName -and $commandletName -like "*-*") { # Ignoring splatting if($commandletParameterElements[0] -like "@*") { continue } $previousElementWasParameterName = $false foreach($element in $commandletParameterElements) { if($element.GetType().Name -eq "CommandParameterAst") { $previousElementWasParameterName = $true } else { if(!$previousElementWasParameterName) { Write-AifbFunctionParsingOutput -Message "Use a named parameter when passing $element to $commandletName." -Extent $extent return } $previousElementWasParameterName = $false } } } # Check named parameters haven't been specified more than once $duplicateParameters = $commandletParameterNames | Group-Object | Where-Object { $_.Count -gt 1 } foreach($duplicateParameter in $duplicateParameters) { Write-AifbFunctionParsingOutput -Message "The parameter $($duplicateParameter.Name) cannot be provided more than once to $commandletName." -Extent $extent return } # Check at least one parameter set is satisfied if all parameters to this commandlet have been specified by name $availableParameterSets = @() if($script:CommandletsExemptFromNamedParameters -notcontains $commandletName -and $commandletName -like "*-*") { $parameterSetSatisfied = $false if($command.ParameterSets.Count -eq 0) { $parameterSetSatisfied = $true } else { foreach($parameterSet in $command.ParameterSets) { $mandatoryParameters = $parameterSet.Parameters | Where-Object { $_.IsMandatory } $availableParameterSets += "$($parameterSet.Name) ($($mandatoryParameters.Name -join ', '))" $mandatoryParametersUsed = [array]($mandatoryParameters | Where-Object { $commandletParameterNames -contains $_.Name }).Name if($hasPipelineInput -and ($mandatoryParameters | Where-Object { $_.ValueFromPipeline })) { $mandatoryParametersUsed += "Pipeline Input" } if($mandatoryParametersUsed.Count -ge $mandatoryParameters.Count) { Write-Verbose "Parameter set $($parameterSet.Name) was satisfied" $parameterSetSatisfied = $true break } else { Write-Verbose "Parameter set $($parameterSet.Name) wasn't satisfied, expected $($mandatoryParameters.Count) but found $($mandatoryParametersUsed.Count)" } } } if(!$parameterSetSatisfied) { Write-AifbFunctionParsingOutput -Message "Parameter set cannot be resolved using the specified named parameters for $commandletName, available parameter sets are $($availableParameterSets -join ', ')." -Extent $extent return } } # Check if types are real if("Add-Type" -eq $commandletName) { $commandletParameterElementsText = $commandletParameterElements.Extent.Text $typeNameIndex = $commandletParameterElementsText.IndexOf('-AssemblyName') + 1 if($typeNameIndex -gt 0 -and $commandletParameterElementsText.Count -gt $typeNameIndex) { $typeName = $commandletParameterElements.Extent.Text[$typeNameIndex] -replace "(^['`"]|['`"]`$)", "" Write-Verbose "Checking type '$typeName' exists for Add-Type" $typeSections = $typeName -split "\." for($i = ($typeSections.Length - 1); $i -ge 0; $i--) { $assembly = $typeSections[0..$i] -join "." try { Add-Type -AssemblyName $assembly -ErrorAction Stop return } catch { Add-AifbLogMessage -Level "WRN" -Message "Failed to Add-Type '$assembly'." } } Write-AifbFunctionParsingOutput "Failed to Add-Type '$typeName', the type doesn't exist." -Extent $extent return } } if("New-Object" -eq $commandletName) { $commandletParameterElementsText = $commandletParameterElements.Extent.Text $typeNameIndex = $commandletParameterElementsText.IndexOf('-TypeName') + 1 if($typeNameIndex -gt 0 -and $commandletParameterElementsText.Count -gt $typeNameIndex) { $typeName = $commandletParameterElements.Extent.Text[$typeNameIndex] -replace "(^['`"]|['`"]`$)", "" Write-Verbose "Checking type '$typeName' exists for New-Object" $builtinType = [System.Management.Automation.PSTypeName]"$typeName" if($null -ne $builtinType.Type) { Write-Verbose "Built-in type found" return } $typeSections = $typeName -split "\." for($i = ($typeSections.Length - 1); $i -ge 0; $i--) { $assembly = $typeSections[0..$i] -join "." try { Add-Type -AssemblyName $assembly -ErrorAction Stop return } catch { Add-AifbLogMessage -Level "WRN" -Message "Failed to Add-Type '$assembly'." } } Write-AifbFunctionParsingOutput "Failed to find type for New-Object -TypeName '$typeName', the type doesn't exist." -Extent $extent return } } } } function Test-AifbFunctionStaticMethodUsage { <# .SYNOPSIS This function tests the usage .net class static methods. .PARAMETER FunctionText Specifies the text content of the PowerShell script to be tested. .NOTES This could likely be converted to a set of PSScriptAnalyzer custom rules https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/create-custom-rule?view=ps-modules #> param ( # A function in a text format to be tested [string] $FunctionText ) $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($FunctionText, [ref]$null, [ref]$null) $staticMemberCalls = $scriptAst.FindAll({$args[0].Static -eq $true}, $true) # Validate each commandlet and return on the first error found because telling the LLM about too many errors at once results in unpredictable fixes foreach($memberCall in $staticMemberCalls) { $className = $memberCall.Expression.TypeName.FullName $memberName = $memberCall.Member.Value $arguments = $memberCall.Arguments $extent = $memberCall.Extent $instance = Invoke-Expression "[$className]" -ErrorAction "SilentlyContinue" $instanceMembers = $instance | Get-Member -Static -ErrorAction "SilentlyContinue" | Where-Object { $_.Name -eq $memberName } if(!$instance) { Write-AifbFunctionParsingOutput "The class $className doesn't exist." -Extent $extent return } if(!$instanceMembers) { Write-AifbFunctionParsingOutput "The member $memberName doesn't exist on $className." -Extent $extent return } if($instanceMembers[0].MemberType -eq "Property") { Write-Verbose "Member is a property" return } if($memberName -eq "new") { $constructorArgCounts = ($instance.GetConstructors() | Foreach-Object { $_.GetParameters().Count } | Group-Object).Name if($constructorArgCounts -notcontains $arguments.Count) { Write-AifbFunctionParsingOutput "There is no constructor for $className that takes $($arguments.Count) parameters." -Extent $extent return } else { Write-Verbose "Constructor is correct" return } } $methods = $instanceMembers | Where-Object { $_.MemberType -eq "Method" } $methodDefinitions = $methods.Definition -split "static [a-z\.]+ " | Where-Object { ![string]::IsNullOrWhiteSpace($_) } $foundMethodDefinitionThatHasCorrectArgNumber = $false foreach($methodDefinition in $methodDefinitions) { $possibleMethodArgs = @() if($methodDefinition -notlike "*()*") { $possibleMethodArgs = ($methodDefinition | Select-String "\((.+)\)").Matches.Groups[1].Value $possibleMethodArgs = $possibleMethodArgs -split "," | ForEach-Object { $_.Trim() } } if($arguments.Count -eq $possibleMethodArgs.Count) { Write-Verbose "Found a static method that takes the correct number of arguments" $foundMethodDefinitionThatHasCorrectArgNumber = $true break } } if(!$foundMethodDefinitionThatHasCorrectArgNumber) { Write-AifbFunctionParsingOutput "The method $memberName doesn't take $($arguments.Count) arguments." -Extent $extent return } } } |