Public/Get-HDTStepType.ps1

function Get-HDTStepType {
    <#
        .SYNOPSIS
            Discovers every step type in the session, by convention.
 
        .DESCRIPTION
            Third-party step types can be dropped into Modules\ -
            the engine discovers them by convention, so extending HDT does not
            mean forking it". This is that discovery, and it is the whole
            registry: there is no list of step types anywhere in the engine.
 
            A step type <Type> is implemented by:
 
              Invoke-HDT<Type>Step -Step -Context required
              Test-HDT<Type>StepApplicable -Step -Context optional, default true
              Get-HDT<Type>StepDescription -Step optional, default
                                                             '<Type>: <name>'
              Get-HDT<Type>StepTemplate -Name optional; without
                                                             it the type can be
                                                             run but not created
 
            THE TEMPLATE IS WHAT MAKES A TYPE CREATABLE, AND ITS ABSENCE IS A
            REAL ANSWER RATHER THAN AN OVERSIGHT. Running a step needs only the
            Invoke command: the YAML is already on disk and the engine dispatches
            to it. Creating one needs somebody to say what to write, and only the
            step type knows. A vendor who ships the runner alone gets a type that
            executes in an existing sequence and is reported as CanAdd false, so
            every authoring surface - Add-HDTStep, and the console's Add menu
            through it - leaves it out instead of writing YAML it guessed.
 
            It enumerates every function named Invoke-HDT*Step in the session and
            keeps the ones matching ^Invoke-HDT(?<type>[A-Za-z0-9]+)Step$, so a
            module imported out of a workspace's Modules\ contributes without the
            engine knowing it exists. Invoke-HDTStep itself does not match: its
            type part is empty, so the dispatcher never discovers itself.
 
            NO FUTURE HDT FUNCTION MAY BE NAMED Invoke-HDT*Step unless it is a
            step type. The name is the registry.
 
            TWO MODULES EXPORTING ONE TYPE IS A TERMINATING ERROR naming both
            sources. PowerShell would otherwise resolve the name to whichever
            module was imported last, and a third party silently shadowing
            ApplyImage is exactly the failure that must not be quiet.
 
            The registry is sorted by Type, so it is deterministic between runs
            and between engines rather than following whatever order the session
            happens to enumerate functions in.
 
        .PARAMETER Name
            One or more type names to return. Matched case-insensitively. Omit it
            for the whole registry.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject, one per type:
 
              Type the type name, e.g. ApplyImage
              InvokeCommand the CommandInfo for Invoke-HDT<Type>Step
              TestCommand the CommandInfo, or $null
              DescriptionCommand the CommandInfo, or $null
              TemplateCommand the CommandInfo, or $null
              CanAdd [bool] - whether a new step of this type can
                                  be created, which is TemplateCommand present
              Source the module that exported it, or an empty string
 
        .EXAMPLE
            Get-HDTStepType | Format-Table Type, Source
 
        .EXAMPLE
            Get-HDTStepType -Name CommandLine
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [string[]] $Name
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # THE ENUMERATION GOES THROUGH Get-Module, NOT Get-Command -All. Verified on
    # this machine: from inside a module's own session state, Get-Command -All
    # returns only the WINNING definition of a shadowed name, so the duplicate
    # this function exists to report would be invisible from exactly where it
    # runs. A module's ExportedFunctions table is per module and cannot be
    # shadowed, so every definition is seen.
    $byName = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)

    $add = {
        param([string] $CommandName, [object] $Command, [string] $Source)

        if (-not $byName.Contains($CommandName)) {
            $byName[$CommandName] = New-Object -TypeName System.Collections.ArrayList
        }

        if (@($byName[$CommandName] | ForEach-Object { $_.Source }) -notcontains $Source) {
            [void] $byName[$CommandName].Add([pscustomobject] @{ Command = $Command; Source = $Source })
        }
    }

    foreach ($module in @(Get-Module)) {
        foreach ($commandName in @($module.ExportedFunctions.Keys)) {
            if (($commandName -notlike 'Invoke-HDT*Step') -and
                ($commandName -notlike 'Test-HDT*StepApplicable') -and
                ($commandName -notlike 'Get-HDT*StepDescription') -and
                ($commandName -notlike 'Get-HDT*StepTemplate')) {
                continue
            }

            & $add ([string] $commandName) $module.ExportedFunctions[$commandName] ([string] $module.Name)
        }
    }

    # A step type dot-sourced into the session rather than shipped in a module.
    # It has no module name, so it can only be reached this way.
    #
    # -ListImported IS LOAD-BEARING, not tidiness: a wildcard Get-Command without
    # it triggers a scan of every module on every PSModulePath to populate the
    # command-analysis cache, which was observed taking minutes on a cold cache.
    # Discovery runs once per step dispatch when the loop does not pass a
    # registry, so it may not do that.
    foreach ($command in @(Get-Command -CommandType Function -ListImported -ErrorAction SilentlyContinue `
                -Name 'Invoke-HDT*Step', 'Test-HDT*StepApplicable', 'Get-HDT*StepDescription', 'Get-HDT*StepTemplate')) {

        if (-not [string]::IsNullOrWhiteSpace([string] $command.ModuleName)) {
            continue
        }

        & $add ([string] $command.Name) $command ''
    }

    $registry = New-Object -TypeName System.Collections.ArrayList

    foreach ($commandName in @($byName.Keys)) {
        $match = [regex]::Match($commandName, '^Invoke-HDT(?<type>[A-Za-z0-9]+)Step$')
        if (-not $match.Success) {
            continue
        }

        $type = $match.Groups['type'].Value
        $entry = @($byName[$commandName])

        if ($entry.Count -gt 1) {
            $where = @($entry | ForEach-Object {
                    if ([string]::IsNullOrWhiteSpace($_.Source)) { '<no module>' } else { $_.Source }
                }) -join ' and '

            $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord `
                        -Message ("the step type '{0}' is exported by more than one module ({1}). One of them would silently shadow the other, so HDT refuses to guess. Remove one from the workspace Modules directory." -f $type, $where)))
        }

        $source = [string] $entry[0].Source

        # A companion is taken from the SAME source as the Invoke command, so a
        # third party cannot half-override another vendor's step type.
        $test = $null
        $testName = 'Test-HDT{0}StepApplicable' -f $type
        if ($byName.Contains($testName)) {
            $candidate = @($byName[$testName] | Where-Object { $_.Source -eq $source })
            if ($candidate.Count -gt 0) {
                $test = $candidate[0].Command
            }
        }

        $description = $null
        $descriptionName = 'Get-HDT{0}StepDescription' -f $type
        if ($byName.Contains($descriptionName)) {
            $candidate = @($byName[$descriptionName] | Where-Object { $_.Source -eq $source })
            if ($candidate.Count -gt 0) {
                $description = $candidate[0].Command
            }
        }

        $template = $null
        $templateName = 'Get-HDT{0}StepTemplate' -f $type
        if ($byName.Contains($templateName)) {
            $candidate = @($byName[$templateName] | Where-Object { $_.Source -eq $source })
            if ($candidate.Count -gt 0) {
                $template = $candidate[0].Command
            }
        }

        [void] $registry.Add([pscustomobject] @{
                Type               = $type
                InvokeCommand      = $entry[0].Command
                TestCommand        = $test
                DescriptionCommand = $description
                TemplateCommand    = $template
                CanAdd             = ($null -ne $template)
                Source             = $source
            })
    }

    $result = @($registry | Sort-Object -Property Type)

    if ($PSBoundParameters.ContainsKey('Name') -and $null -ne $Name) {
        $result = @($result | Where-Object { $Name -contains $_.Type })
    }

    return $result
}