private/implicitwindow/Get-UiScriptHelperName.ps1

function Get-UiScriptHelperName {
    <#
    .SYNOPSIS
        Reads every function the calling script defines, dot sourced files included, and figures
        out which of them build controls and which open a window of their own. A function that
        only stores a block it was handed counts as neither, since nothing written inside that
        block has run. Without this, a function named something like Add-ServerRows is just a
        ame PsUi cannot place.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.Language.Ast]$Root,

        [Parameter(Mandatory)]
        $Commands,

        [Parameter(Mandatory)]
        $Stoppers,

        [Parameter(Mandatory)]
        $Deferrers,

        [AllowNull()]
        [AllowEmptyString()]
        [string]$ScriptName
    )

    if (!$script:uiFunctionAstFilter) {
        $script:uiFunctionAstFilter = {
            param($node)
            $node -is [System.Management.Automation.Language.FunctionDefinitionAst]
        }
    }

    if (!$script:uiVariableAstFilter) {
        $script:uiVariableAstFilter = {
            param($node)
            $node -is [System.Management.Automation.Language.VariableExpressionAst]
        }
    }

    # These go back renamed. $builders is Builders, $openers is Stoppers because the read stops in front of one, and $storers is BlockStorers.
    $builders = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $openers  = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $storers  = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

    $definitions = [System.Collections.Generic.List[object]]::new()
    $definitions.AddRange($Root.FindAll($script:uiFunctionAstFilter, $true))

    $pending = [System.Collections.Generic.Queue[string]]::new()
    $visited = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($path in (Get-UiDotSourcedPath -Root $Root -ScriptName $ScriptName)) { $pending.Enqueue($path) }

    # Twelve files parsed is past any real helper tree, and the cap keeps a deep dot source chain from parsing half the disk on the way to one control.
    while ($pending.Count -gt 0 -and $visited.Count -lt 12) {
        $path = $pending.Dequeue()
        if (!$visited.Add($path)) { continue }
        $parsed = $null
        try { $parsed = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null) } catch { $parsed = $null }
        if (!$parsed) { continue }
        $definitions.AddRange($parsed.FindAll($script:uiFunctionAstFilter, $true))
        foreach ($next in (Get-UiDotSourcedPath -Root $parsed -ScriptName $path)) { $pending.Enqueue($next) }
    }

    # A function that takes a scriptblock and never invokes it is storing it, so nothing written inside the block it was handed has run.
    foreach ($definition in $definitions) {
        if (!$definition.Name -or !$definition.Body) { continue }

        $blockNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        foreach ($parameter in @($definition.Body.ParamBlock.Parameters) + @($definition.Parameters)) {
            if ($parameter -and $parameter.StaticType -eq [scriptblock]) { [void]$blockNames.Add($parameter.Name.VariablePath.UserPath) }
        }

        if (!$blockNames.Count) { continue }

        $runsIt = $false
        foreach ($use in $definition.Body.FindAll($script:uiVariableAstFilter, $true)) {
            if (!$blockNames.Contains($use.VariablePath.UserPath)) { continue }
            if ($use.Parent -is [System.Management.Automation.Language.CommandAst]) { $runsIt = $true; break }
            $member = $use.Parent -as [System.Management.Automation.Language.InvokeMemberExpressionAst]
            if ($member -and $member.Expression -eq $use) { $runsIt = $true; break }
        }
        if (!$runsIt) { [void]$storers.Add($definition.Name) }
    }

    # One AST walk per function, the passes below are string compares.
    $bodies = [System.Collections.Generic.List[object]]::new()
    foreach ($definition in $definitions) {
        if (!$definition.Name -or !$definition.Body) { continue }
        $runs = [System.Collections.Generic.List[string]]::new()

        foreach ($block in @($definition.Body.BeginBlock, $definition.Body.ProcessBlock, $definition.Body.EndBlock)) {
            if (!$block) { continue }
            foreach ($statement in $block.Statements) {
                $runs.AddRange((Get-UiStatementCommandList -Statement $statement -Deferrers $Deferrers -BlockStorers $storers))
            }
        }
        $bodies.Add(@{ Name = $definition.Name; Runs = $runs })
    }

    # Add-Rows calling Add-Row calling New-UiLabel is two hops, so keep going until a pass turns up nothing new.
    $grew = $true
    while ($grew) {
        $grew = $false
        foreach ($body in $bodies) {
            if (!$openers.Contains($body.Name)) {
                foreach ($name in $body.Runs) {
                    if ($Stoppers.Contains($name) -or $openers.Contains($name)) {
                        [void]$openers.Add($body.Name)
                        $grew = $true
                        break
                    }
                }
            }
            if (!$builders.Contains($body.Name)) {
                foreach ($name in $body.Runs) {
                    if ($Commands.Contains($name) -or $builders.Contains($name)) {
                        [void]$builders.Add($body.Name)
                        $grew = $true
                        break
                    }
                }
            }
        }
    }

    # A window opener is never also a window builder. Counting it as both would pull the window it opens into the content that was supposed to stop in front of it.
    foreach ($name in $openers) { [void]$builders.Remove($name) }

    return @{ Builders = $builders; Stoppers = $openers; BlockStorers = $storers }
}