src/Ast.ps1

<#
.SYNOPSIS
    Shared AST helpers for PSComplexity: unit discovery, nearest-unit attribution,
    and nesting-depth computation.

.DESCRIPTION
    A "unit" is anything that owns a body and is gated on its own:

      * a function/filter -> 'name@line'
      * a class method or constructor -> 'Class.Member@line'
      * an initialised class property -> 'Class.Property@line'
      * one synthetic '<script-body>' per file, for top-level code

    Every increment is attributed to the nearest enclosing unit, so nested functions
    -- and methods of a class declared inside a function -- are measured independently.

    Class members are units because the gate is per-unit: folding a class's methods
    into the enclosing scope either hides a genuinely complex method inside a large
    total, or inflates a script body that is itself fine. A property INITIALISER is
    its own unit for the same reason and at the same granularity as a method: it is
    code that runs (at construction), it is not top-level code, and naming the
    property is what lets a gate point at the thing to fix. A property with no
    initialiser has no body and so is not a unit.

    Nesting depth (used by cognitive complexity) counts the flow-structuring ancestors
    between a node and its enclosing unit -- if/loops/switch/catch/trap/ternary AND
    nested script-block lambdas (e.g. a ForEach-Object body). That mirrors SonarSource's
    B3 nesting-level rule, where nested functions/lambdas raise the nesting level.
#>


# Ancestor types that raise the cognitive nesting level (B3).
$script:PSCxNestingTypes = @(
    'IfStatementAst', 'ForEachStatementAst', 'ForStatementAst', 'WhileStatementAst',
    'DoWhileStatementAst', 'DoUntilStatementAst', 'SwitchStatementAst',
    'CatchClauseAst', 'TrapStatementAst', 'TernaryExpressionAst', 'ScriptBlockExpressionAst'
)

# Ancestor types that OWN a body: the walk up stops here, both for attribution and
# for nesting. A method body bounds its contents exactly as a function body does.
# Cmdlets that ARE control flow in PowerShell, with their aliases. ForEach-Object is the
# language's second loop and Where-Object its second conditional; a body written with them
# branches exactly as much as the keyword form, so scoring it lower rewards a rewrite that
# changes nothing a reader would call a decomposition.
#
# `foreach` and `where` appear here as ALIASES: the keyword forms parse to
# ForEachStatementAst and a filter script block, never to a CommandAst, so a CommandAst
# carrying those names can only be the alias.
$script:PSCxFlowCommands = @(
    'ForEach-Object', 'foreach', '%',
    'Where-Object', 'where', '?'
)

# The same two, written as the intrinsic methods every object carries: `$x.ForEach({ })` and
# `$x.Where({ })` take a script block exactly as the cmdlets do and branch exactly as much, so
# scoring them lower made the method form a cheaper spelling of the same logic (#159).
$script:PSCxFlowMethods = @('ForEach', 'Where')

$script:PSCxUnitBoundaryTypes = @(
    # FunctionMemberAst is unreachable TODAY and kept on purpose. Every usage of this list walks
    # UP the parent chain, and PowerShell wraps a class member's body in its own
    # FunctionDefinitionAst -- verified across methods, constructors, static constructors, static
    # and hidden members, overrides, and an empty body. So the body is always met first, and
    # Resolve-PSCxUnitBoundary maps it back to the member without consulting this list at all.
    #
    # It stays because the failure modes are not symmetric. If PowerShell ever produced a member
    # without that inner node, keeping the entry costs nothing and removing it would silently
    # attribute the member's decisions to the script body -- a wrong number, which is the one
    # thing this module must not produce. The invariant it leans on is pinned by a test, so the
    # day it stops holding is a red suite rather than a quiet re-attribution.
    'FunctionDefinitionAst', 'FunctionMemberAst', 'PropertyMemberAst'
)

# The same two vocabularies as lookup tables, built once at load. The lists above stay as the
# declaration -- they carry the reasons, and the vocabulary test reads them -- but the INDEX
# consults them once per node over a whole tree, so how it asks matters.
#
# A hashtable read, not HashSet.Contains: PowerShell reaches an INDEXER through a fast path, and
# a .NET METHOD through its general binder, which costs several microseconds a call (#154).
# Case-insensitive like every hashtable literal, which is harmless here: no two Ast type names
# differ only in case.
$script:PSCxNestingTypeIndex = @{}
foreach ($t in $script:PSCxNestingTypes) { $script:PSCxNestingTypeIndex[$t] = $true }
$script:PSCxUnitBoundaryTypeIndex = @{}
foreach ($t in $script:PSCxUnitBoundaryTypes) { $script:PSCxUnitBoundaryTypeIndex[$t] = $true }


# Per-node answers, remembered for the file being measured.
#
# Get-PSCxUnitBoundary and Get-PSCxNesting each walk the ancestor chain from a node up to its
# enclosing unit, and between them they are called from twelve sites once per matched node. Where
# node count and depth grow together the total is quadratic: measured, a file nested 200 deep cost
# 1.84s of CPU for 3 KB of source, while 52 KB of ordinary code cost 3.0s.
#
# Keyed on the node OBJECT: two AST nodes with identical content are different nodes and must not
# collide, so entries from one parse can never answer a question about another.
#
# The default comparer already gives that, and passing one explicitly is what NOT to do here. Ast
# overrides neither Equals nor GetHashCode, so EqualityComparer<object>.Default is identity;
# naming ReferenceEqualityComparer to say so costs a .NET 5 type and raises this module's floor
# from PowerShell 7.0 to 7.1 in exchange for nothing. tests/Ast.Tests.ps1 pins the assumption with
# two structurally identical trees that must stay apart -- if Ast ever gains value equality, that
# test is what fails.
#
# Cleared per file by Clear-PSCxAstCache. Without that the tables grow for the life of the process,
# which for a gate over a large tree is every node of every file.
$script:PSCxBoundaryCache = [System.Collections.Generic.Dictionary[object, object]]::new()
$script:PSCxNestingCache = [System.Collections.Generic.Dictionary[object, object]]::new()

# Nodes bucketed by their runtime type, and the pre-order position of each node.
#
# The same pass that computes boundary and nesting already visits every node and already knows
# each one's type, so recording it costs one dictionary write. What it buys is every "find me the
# nodes of type X" question the metrics ask -- and they asked it 32 times per file, each time
# re-walking the whole tree and invoking a PowerShell predicate for every node. Measured on a
# 21k-node file: 32 x 21ms of traversal, for answers this pass already had.
#
# Keyed by [type] rather than by name so an assignability query can be answered without
# reconstructing types from strings; PSCxTypeNameCache holds the SAME lists under the exact type
# name, for the callers that mean GetType().Name -eq rather than -is. Two spellings of one
# question, kept apart on purpose: they differ the moment a construct gains a subclass, and
# InvokeMemberExpressionAst already has one.
#
# PSCxOrderCache exists only so a query spanning more than one bucket can put the union back into
# document order. FindAll returns pre-order, and the rows -Detailed publishes are read in that
# order, so a union that merged buckets end-to-end would reorder two increments on one line.
#
# The two node-keyed tables take the DEFAULT comparer, for the reason the boundary and nesting
# tables above do: Ast overrides neither Equals nor GetHashCode, so the default already is
# identity, and naming ReferenceEqualityComparer to say so costs a .NET 5 type and the module's
# floor. Every type named here predates .NET Core 3.1, which is what PowerShell 7.0 runs.
$script:PSCxTypeCache = [System.Collections.Generic.Dictionary[type, object]]::new()
$script:PSCxTypeNameCache = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
$script:PSCxKindCache = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
$script:PSCxOrderCache = [System.Collections.Generic.Dictionary[object, object]]::new()
$script:PSCxNameCache = [System.Collections.Generic.Dictionary[object, object]]::new()

# Which tree the caches describe, so the index is built once per file rather than once per ask.
# A reference, never a value: two parses of one file are different trees and must not share an
# index.
$script:PSCxIndexedRoot = $null

# A function's scope modifier, which says where it is defined and not which function it is (#158):
# `function global:Get-Fact` and its call `Get-Fact` are one function, and adding the modifier must
# not rename the unit, which is its baseline and acceptance key. The unit name and recursion both
# strip it, and must agree. An operator rather than a helper, because recursion applies it once per
# COMMAND: two helper calls there tripled that collector's time, about 60ms on a 28k-node file.
$script:PSCxScopeModifierPattern = '^(global|script|local|private):'

function Get-PSCxAstRoot {
    # The top of the tree a node belongs to.
    #
    # This is the one upward walk left, and it runs ONCE per file rather than once per node --
    # which is the whole difference between linear and quadratic here.
    [OutputType([System.Management.Automation.Language.Ast])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    $r = $Node
    while ($r.Parent) { $r = $r.Parent }
    return $r
}

function Initialize-PSCxAstIndex {
    # Compute every node's enclosing unit and nesting depth in ONE pre-order pass.
    #
    # Both answers are defined against a node's PARENT:
    #
    # boundary(n) = parent is a body owner ? resolve(parent) : boundary(parent)
    # nesting(n) = parent is a body owner ? 0 : nesting(parent) + (parent raises nesting ? 1 : 0)
    #
    # so knowing the parent's answers is enough to know the child's. FindAll walks in document
    # order and a parent always precedes its children, which is what lets this be a flat loop
    # rather than a recursion -- and a flat loop cannot exhaust the stack on a deeply nested file,
    # which is exactly the shape this exists for.
    #
    # It replaces walking up from every node. Twelve call sites asked for one or both of these
    # once per matched node, and where node count and depth grow together that is quadratic: a
    # file nested 200 deep cost 1.84s of CPU for 3 KB of source.
    #
    # It also records each node's TYPE and its position in the walk, which is what lets every
    # "find the nodes of type X" question be a dictionary read instead of another traversal. The
    # metrics asked that question 32 times per file, each time walking the whole tree and invoking
    # a PowerShell predicate per node.
    #
    # Unconditional: it REBUILDS. Confirm-PSCxAstIndex below is the idempotent entry point, and
    # the split is deliberate rather than tidy -- Get-PSCxUnitBoundary and Get-PSCxNesting call
    # this one on a cache miss, and their tests prove the memo hits by planting a value and
    # checking a rebuild would overwrite it. Guarding here made that rebuild an early return, so
    # the planted value survived and two mutants that used to die started living. Measured, on
    # the run that introduced it.
    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Root)
    # The type-keyed tables describe ONE tree and must be emptied before another is walked.
    # Boundary, nesting and name are keyed by node reference, so entries from two trees can sit
    # side by side and neither can answer for the other; a bucket keyed by TYPE has no such
    # protection, and appending a second tree's nodes to it hands back rows from a file the caller
    # never asked about. The suite caught exactly that -- an `if` reported at line 1 of the
    # previous fixture.
    $script:PSCxTypeCache.Clear()
    $script:PSCxTypeNameCache.Clear()
    $script:PSCxKindCache.Clear()
    $script:PSCxOrderCache.Clear()
    $script:PSCxBoundaryCache[$Root] = $null
    $script:PSCxNestingCache[$Root] = 0
    # Each node's type, so a child reads its parent's from here rather than asking the parent.
    $typeOf = [System.Collections.Generic.Dictionary[object, object]]::new()
    # Materialised once: the loop and the grouping below both walk it.
    $all = [object[]]@($Root.FindAll({ $true }, $true))
    $position = 0
    foreach ($n in $all) {
        # INLINE, not a call per node, and this is why the index was expensive. A PowerShell
        # function with [CmdletBinding()] and mandatory parameters costs about 9us to invoke;
        # over 259,144 nodes that is 2.4s against 62ms for the same work written here -- 39x,
        # measured. Nothing else in this module runs a function once per AST node.
        #
        # And INDEXERS rather than .NET METHODS, for the same kind of reason one level down (#154).
        # PowerShell reaches an indexer or a property through a fast path and a method through its
        # general binder: measured per call, a dictionary read or write well under 1us, while
        # TryGetValue with a [ref] cost 17us, HashSet.Contains 7.6us, List.Add 8.7us and
        # GetType() 4us. This loop made six of those per node; it now makes one GetType(), and
        # the pass went from 51.8us to 12.7us a node with every answer identical.
        $script:PSCxOrderCache[$n] = $position++
        $typeOf[$n] = $n.GetType()
        $p = $n.Parent
        # The root itself comes back from FindAll and is already seeded; anything with no parent
        # is a root by definition. The root is still grouped below: a bucket that silently
        # omitted it would be a whole-tree query that is not one.
        if ($null -eq $p) { continue }
        # A parent the walk never returned is asked directly. There is one: the base() call
        # PowerShell synthesises for a class constructor -- FindAll yields its arguments but not
        # the call itself, so this table has never heard of it.
        $parentType = ($typeOf[$p] ?? $p.GetType()).Name
        if ($script:PSCxUnitBoundaryTypeIndex[$parentType]) {
            $script:PSCxBoundaryCache[$n] = Resolve-PSCxUnitBoundary -Boundary $p
            $script:PSCxNestingCache[$n] = 0
            continue
        }
        $script:PSCxBoundaryCache[$n] = $script:PSCxBoundaryCache[$p]
        $raises = $script:PSCxNestingTypeIndex[$parentType] ? 1 : 0
        $script:PSCxNestingCache[$n] = [int]$script:PSCxNestingCache[$p] + $raises
    }
    # The buckets, in ONE call rather than a List.Add per node. A lookup keeps each group in
    # source order, which is document order -- the order every query and -Detailed rely on.
    foreach ($group in [System.Linq.Enumerable]::ToLookup($all, [System.Func[object, object]] { param($node) $typeOf[$node] })) {
        $script:PSCxTypeCache[$group.Key] = $group
        $script:PSCxTypeNameCache[$group.Key.Name] = $group
    }
    # LAST, so a walk that throws part-way leaves the root unmarked and the next ask rebuilds it
    # rather than reading a half-filled index. Nothing in the loop throws today; the ordering costs
    # nothing and removes the question.
    $script:PSCxIndexedRoot = $Root
}

function Confirm-PSCxAstIndex {
    # Build the index for this tree unless it is already the one indexed.
    #
    # The idempotent half, kept apart from the rebuild above because the two are asked for by
    # different callers for different reasons. The bucket readers ask on EVERY query -- dozens per
    # file -- and an unguarded rebuild there would cost more than the traversals the index
    # replaced. The boundary and nesting readers ask only on a miss, which for one tree happens
    # once, and they want the rebuild.
    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Root)
    if ([object]::ReferenceEquals($script:PSCxIndexedRoot, $Root)) { return }
    Initialize-PSCxAstIndex -Root $Root
}


function Clear-PSCxAstCache {
    # Forget the per-node answers. Called once per file, before it is walked.
    #
    # Clear-, not Reset-: Reset is a state-changing verb, so PSUseShouldProcessForStateChangingFunctions
    # demands -WhatIf support that emptying an in-memory cache has no use for. Clear- says the same
    # thing and matches Get-PSCxUnitRecord's reason for not being New-.
    #
    # Correctness does not depend on this -- the keys are node references, so a stale entry can
    # never be found by a different parse. Memory does: without it the tables hold every node of
    # every file the process has seen.
    [OutputType([void])]
    [CmdletBinding()]
    param()
    $script:PSCxBoundaryCache.Clear()
    $script:PSCxNestingCache.Clear()
    $script:PSCxTypeCache.Clear()
    $script:PSCxTypeNameCache.Clear()
    $script:PSCxKindCache.Clear()
    $script:PSCxOrderCache.Clear()
    $script:PSCxNameCache.Clear()
    # The mark goes last and must go at all: leaving it set would let the next file read an index
    # that has just been emptied, and every metric would come back zero with nothing raised.
    $script:PSCxIndexedRoot = $null
}

function Resolve-PSCxUnitBoundary {
    # A class method's body is itself a FunctionDefinitionAst, nested inside the
    # FunctionMemberAst. Both are body owners, so without this the same method is
    # discovered twice -- once unqualified. The MEMBER is the unit: it is the node
    # that knows the class name.
    [OutputType([System.Management.Automation.Language.Ast])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Boundary)
    if ($Boundary -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
        $Boundary.Parent -is [System.Management.Automation.Language.FunctionMemberAst]) {
        return $Boundary.Parent
    }
    return $Boundary
}

function Get-PSCxUnitBoundary {
    # Nearest enclosing body-owner, or $null for top-level code.
    #
    # A read of the index, which is built on the first miss for a tree. There is deliberately no
    # second implementation that walks up: two ways to answer one question is how they come to
    # disagree, and the walk was the quadratic half.
    [OutputType([System.Management.Automation.Language.Ast])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    # An indexer, not TryGetValue with a [ref]: every scoring row reaches this, and the [ref] form
    # costs 17us a call against well under 1us (#155). A boundary can be $null -- top-level code
    # has none -- which the indexer cannot tell from a miss, so only then is membership asked.
    $found = $script:PSCxBoundaryCache[$Node]
    if ($null -eq $found -and -not $script:PSCxBoundaryCache.ContainsKey($Node)) {
        Initialize-PSCxAstIndex -Root (Get-PSCxAstRoot -Node $Node)
        $found = $script:PSCxBoundaryCache[$Node]
    }
    return $found
}

function Get-PSCxDisplayName {
    # Map ordered unit keys to the names a caller sees, disambiguating repeats.
    #
    # Qualification makes a nested unit distinct from one of the same name elsewhere, but it
    # cannot separate two units with the SAME name in the SAME scope -- two overloads, or a
    # function defined twice. Those still read alike, so a baseline keyed on the name merges
    # them. An ordinal is appended to every member of such a group, first one included: were
    # only the second suffixed, adding an overload would silently rename the first.
    [OutputType([hashtable])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$OrderedKeys)
    $bare = @{}
    foreach ($k in $OrderedKeys) {
        $n = $k -replace '@\d+$', ''
        $bare[$k] = $n
    }
    $counts = @{}
    foreach ($k in $OrderedKeys) { $counts[$bare[$k]] = 1 + [int]$counts[$bare[$k]] }
    $seen = @{}
    $out = @{}
    foreach ($k in $OrderedKeys) {
        $n = $bare[$k]
        if ($counts[$n] -gt 1) {
            $seen[$n] = 1 + [int]$seen[$n]
            $out[$k] = '{0}#{1}' -f $n, $seen[$n]
        }
        else { $out[$k] = $n }
    }
    return $out
}

function Get-PSCxUnitOwnName {
    # A unit's own name, unqualified: 'Get-Thing' for a function, 'Class.Member' for a class
    # member (whose Parent is the TypeDefinitionAst carrying the class name). A constructor is
    # named after its class, so it reads 'Order.Order'. A function's scope modifier is dropped.
    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Boundary)
    if ($Boundary -is [System.Management.Automation.Language.FunctionDefinitionAst]) {
        return $Boundary.Name -replace $script:PSCxScopeModifierPattern, ''
    }
    return '{0}.{1}' -f $Boundary.Parent.Name, $Boundary.Name
}

function Get-PSCxUnitName {
    # The qualified unit name plus the extent's start offset: 'Outer/Inner@412'.
    #
    # QUALIFIED, because a bare name is not unique. `Get-Inner` defined inside `Get-OuterA`
    # and again inside `Get-OuterB` produced two rows both reading `Get-Inner`, so anything
    # keying on the name -- a committed baseline, a per-file report -- silently merged two
    # different units. Class members were already qualified for exactly this reason; nested
    # functions were not, and the promise that the qualification extends to "any per-unit
    # baseline built from it" was true only for the half that had it.
    #
    # SUFFIXED WITH THE OFFSET, not the line, because a line is not unique either: two
    # overloads written on one physical line shared a key and had their scores ADDED, so the
    # file reported a single unit that exists nowhere in the source. An offset is unique per
    # node however the source is laid out. The reported Line is recorded separately and is
    # display data.
    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Boundary)
    # Memoised on the boundary NODE, because this is a pure function of it and the metrics ask for
    # the same handful of names thousands of times: measured on a 200-function file, 4200 calls
    # produced 200 distinct names. Every collector calls Get-PSCxUnitKey once per matched node,
    # and each of those rebuilds a list, walks to the root and formats a string to arrive back at
    # a name the last node in the same unit already produced.
    #
    # Reference-keyed and cleared per file, exactly like the boundary and nesting tables, so an
    # entry from one parse can never answer a question about another.
    # An indexer: a name is never $null, so $null is the miss (#155).
    $cachedName = $script:PSCxNameCache[$Boundary]
    if ($null -ne $cachedName) { return [string]$cachedName }
    $parts = [System.Collections.Generic.List[string]]::new()
    $parts.Add((Get-PSCxUnitOwnName -Boundary $Boundary))
    # Walk out through every enclosing unit. Resolve each one first: a class method's body is
    # itself a FunctionDefinitionAst inside the FunctionMemberAst, so an unresolved walk
    # would name the same method twice.
    $seen = Resolve-PSCxUnitBoundary -Boundary $Boundary
    $p = $Boundary.Parent
    while ($p) {
        if ($script:PSCxUnitBoundaryTypeIndex[$p.GetType().Name]) {
            $outer = Resolve-PSCxUnitBoundary -Boundary $p
            if (-not [object]::ReferenceEquals($outer, $seen)) {
                $parts.Insert(0, (Get-PSCxUnitOwnName -Boundary $outer))
                $seen = $outer
            }
        }
        $p = $p.Parent
    }
    $script:PSCxNameCache[$Boundary] = '{0}@{1}' -f ($parts -join '/'), $Boundary.Extent.StartOffset
    return [string]$script:PSCxNameCache[$Boundary]
}

function Get-PSCxUnitKey {
    # Nearest enclosing unit key, else '<script-body>'.
    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    $boundary = Get-PSCxUnitBoundary -Node $Node
    if ($boundary) { return Get-PSCxUnitName -Boundary $boundary }
    return '<script-body>'
}

function Get-PSCxNesting {
    # Count of nesting-raising ancestors up to (not crossing) the enclosing unit.
    #
    # The same index, built by the same descent. Memoising the upward walk instead was tried and
    # is wrong in a way worth remembering: nesting answers along a chain are not equal the way
    # boundary answers are -- they decrease going up -- so caching them on the way up returns the
    # right number for the node asked about and the wrong one for every node cached en route. It
    # reported cognitive 200 where the answer was 20100, and all 558 tests passed.
    [OutputType([int])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    # An indexer, for the reason Get-PSCxUnitBoundary gives. A depth is never $null for a node
    # the index has seen, so $null IS the miss here.
    $found = $script:PSCxNestingCache[$Node]
    if ($null -eq $found) {
        Initialize-PSCxAstIndex -Root (Get-PSCxAstRoot -Node $Node)
        $found = $script:PSCxNestingCache[$Node]
    }
    return [int]$found
}

function Get-PSCxNodeByTypeName {
    # Every node whose runtime type is EXACTLY this, in document order.
    #
    # An ARRAY, always, and comma-wrapped so PowerShell does not unroll it on the way out: without
    # the comma an empty result reaches the caller as $null and a one-node result as a bare node.
    # Every caller here is a foreach, which forgives both -- a pipeline would not, since
    # `$null | ForEach-Object` runs its body once. The bucket is a List, so this is one copy per
    # ask, which is what the unroll already cost.
    #
    # Exact, because the caller said exact. This replaces `GetType().Name -eq $tn`, which is a
    # different question from `-is` the moment a construct gains a subclass -- and one already
    # has. Get-PSCxNodeByKind below is the other question, spelled separately so neither can
    # quietly answer for the other.
    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Ast,
        [Parameter(Mandatory)] [string]$TypeName
    )
    Confirm-PSCxAstIndex -Root (Get-PSCxAstRoot -Node $Ast)
    $bucket = $null
    if ($script:PSCxTypeNameCache.TryGetValue($TypeName, [ref]$bucket)) { return , @($bucket) }
    # A type the file contains no instance of: an empty array, never $null -- which the comma is
    # what actually guarantees.
    return , [object[]]@()
}

function Get-PSCxNodeByKind {
    # Every node ASSIGNABLE to one of these types -- the `-is` test -- in document order.
    #
    # Assignability is resolved against the types the file actually holds, not against a list
    # written here, so a subclass this module has never heard of is still matched. That is not
    # hypothetical: BaseCtorInvokeMemberExpressionAst derives from InvokeMemberExpressionAst, so
    # `-is` and `GetType().Name -eq` already disagree on `: base()` chaining, and a bucket lookup
    # that ignored the difference would silently drop a construct.
    #
    # Cached per tree by the set of types asked for, because the answer cannot change while the
    # index stands and the metrics ask the same handful of questions once each.
    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Ast,
        [Parameter(Mandatory)] [type[]]$Type
    )
    Confirm-PSCxAstIndex -Root (Get-PSCxAstRoot -Node $Ast)
    $key = ($Type.FullName -join '|')
    $cached = $null
    # Comma-wrapped for the reason Get-PSCxNodeByTypeName gives. The cache holds the array itself,
    # so an empty answer is cached as an empty array rather than as $null.
    if ($script:PSCxKindCache.TryGetValue($key, [ref]$cached)) { return , $cached }
    $script:PSCxKindCache[$key] = Get-PSCxKindResult -Type $Type
    return , $script:PSCxKindCache[$key]
}

function Get-PSCxKindResult {
    # The union of the buckets assignable to these types, in document order.
    #
    # Its own function so Get-PSCxNodeByKind stays a cache read, and so the ordering contract can
    # be tested without going through a cache that would answer the second call from memory.
    #
    # There is no shortcut for the single-bucket case, and that is a decision the mutation gate
    # forced. It looked free -- hand back the stored list untouched -- but a PowerShell function
    # UNROLLS a returned list into a fresh array anyway, so the caller cannot tell the two apart
    # by reference, by content or by order. A branch nothing can observe is one no test can
    # distinguish from its own absence, which is the shape this module refuses in other people's
    # code. The union is computed once per type per file and cached, so the copy costs a single
    # pass over one bucket.
    [OutputType([object[]])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [type[]]$Type)
    $merged = [System.Collections.Generic.List[object]]::new()
    foreach ($t in $script:PSCxTypeCache.Keys) {
        if (Test-PSCxAssignable -Type $Type -Candidate $t) { $merged.AddRange([object[]]$script:PSCxTypeCache[$t]) }
    }
    # Back into document order. A dictionary enumerates its keys in no defined order, so a union
    # spanning two buckets would otherwise group every node of one type before every node of the
    # other -- reordering the rows -Detailed publishes, which a reader scans by line.
    #
    # Sorted on the recorded walk position rather than on an extent offset: a node and the node it
    # CONTAINS can start at the same offset, and pre-order puts the container first, which an
    # offset comparison has no way to know.
    #
    # [array]::Sort over a key array rather than Sort-Object, which invokes a PowerShell
    # scriptblock per comparison -- the exact cost this index exists to stop paying. The keys are
    # walk positions and so are unique, which is why an unstable sort is safe here.
    $nodes = $merged.ToArray()
    # A foreach STATEMENT, not ForEach-Object: the pipeline form invokes a script block per node
    # (#155). And no index counter, which would be a literal no test could pin: the first node of
    # a union is always its earliest, so starting the count at 1 sorts identically.
    $keys = [int[]]@(foreach ($node in $nodes) { $script:PSCxOrderCache[$node] })
    [array]::Sort($keys, $nodes)
    return , $nodes
}

function Test-PSCxAssignable {
    # Whether a candidate type satisfies any of the types asked for.
    [OutputType([bool])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [type[]]$Type,
        [Parameter(Mandatory)] [type]$Candidate
    )
    foreach ($t in $Type) {
        if ($t.IsAssignableFrom($Candidate)) { return $true }
    }
    return $false
}

function Get-PSCxUnitTable {
    # Baseline unit -> start-line map: every function, every class method and
    # initialised property, plus the script body -- so a decision-free unit still
    # reports (cyclomatic 1 / cognitive 0).
    [OutputType([hashtable])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Ast)
    $units = @{ '<script-body>' = 1 }
    # From the index's type buckets, not a FindAll of its own: the index already walked the tree
    # and filed every node by type, and a second walk invoking a PowerShell predicate per node
    # was a fifth of a file's measurement (#155). The buckets are by EXACT type name, which is
    # what the predicate it replaced tested.
    foreach ($typeName in $script:PSCxUnitBoundaryTypes) {
        foreach ($node in (Get-PSCxNodeByTypeName -Ast $Ast -TypeName $typeName)) {
            # An ENUM member is a PropertyMemberAst too, and an initialised one -- `Red = 1` --
            # would otherwise become a unit while a bare `Green` would not, making an enum's
            # complexity depend on whether anyone numbered its members. A label is not code;
            # there is nothing in it to measure. A property with no initialiser has no body.
            if ($node -is [System.Management.Automation.Language.PropertyMemberAst] -and
                (($node.Parent -is [System.Management.Automation.Language.TypeDefinitionAst] -and $node.Parent.IsEnum) -or
                -not $node.InitialValue)) { continue }
            $unit = Resolve-PSCxUnitBoundary -Boundary $node
            $units[(Get-PSCxUnitName -Boundary $unit)] = $unit.Extent.StartLineNumber
        }
    }
    return $units
}

function Test-PSCxSelfCall {
    # True when a command calls the FUNCTION it sits in, for direct-recursion detection.
    # False when the nearest body-owner is a class member: a bare command inside a method is a
    # command lookup, never a call to the method, so walking past the method to an outer
    # function would attribute recursion to the wrong unit. False at file scope too.
    #
    # Compared as the function each side NAMES, not as written (#158): `MyMod\Get-Fact` and
    # `global:Get-Fact` both call Get-Fact, and so does `Get-Fact` inside `function global:Get-Fact`
    # -- an exact match on the text missed all three. A module qualifier is ONE segment before a
    # backslash; anything reading as a path -- `.\Get-Fact`, `C:\Get-Fact`, `~\x`, two segments --
    # runs a script rather than this function, so it is left as written and never matches.
    #
    # A command with no name can still be the function (#182): `& ${function:Get-Fact}` runs its
    # body, and `& $MyInvocation.MyCommand` (or `.Name`) runs whatever is executing. At file scope
    # the unit is the script, and Test-PSCxScriptSelfCall answers instead.
    [OutputType([bool])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    $boundary = Get-PSCxUnitBoundary -Node $Node
    if ($null -eq $boundary) { return Test-PSCxScriptSelfCall -Node $Node }
    if ($boundary -isnot [System.Management.Automation.Language.FunctionDefinitionAst]) { return $false }
    $own = $boundary.Name -replace $script:PSCxScopeModifierPattern, ''
    $called = $Node.GetCommandName()
    if ($null -ne $called) {
        return ($called -replace '^[^\\/:.~][^\\/:]*\\', '' -replace $script:PSCxScopeModifierPattern, '') -eq $own
    }
    # `$function:Get-Fact` is NOT this: it parses as `$function:Get` followed by the text `-Fact`,
    # and fails at run time. Only the braced form names a hyphenated function, and the variable
    # path carries it whole either way.
    $first = $Node.CommandElements[0]
    if ($first -is [System.Management.Automation.Language.VariableExpressionAst]) {
        return ($first.VariablePath.DriveName -eq 'function') -and
            (($first.VariablePath.UserPath -replace '^function:', '') -eq $own)
    }
    return ($first.Extent.Text -match '^\$MyInvocation\.MyCommand(\.Name)?$') -and (Test-PSCxOwnInvocation -Node $Node)
}

function Test-PSCxScriptSelfCall {
    # True when a command at file scope runs the script it sits in (#182) -- the `<script-body>`
    # unit's recursion. The forms are the idioms, matched as text, and nothing is resolved:
    #
    # $PSCommandPath the running script's path
    # $MyInvocation.MyCommand[.Path] the running script, when no script block intervenes
    # .\Name.ps1, $PSScriptRoot\Name.ps1 the file's OWN name, either separator, quoted or not
    #
    # `.\` is relative to the current location rather than the script's folder, so it is the same
    # file only by convention -- the one every script written to call itself follows. The file name
    # comes from the tree: Parser::ParseFile records it on every extent, and a tree parsed from text
    # has none, so the third form cannot match there.
    [OutputType([bool])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    $text = $Node.CommandElements[0].Extent.Text -replace '^["'']|["'']$', ''
    if ($text -eq '$PSCommandPath') { return $true }
    if ($text -match '^\$MyInvocation\.MyCommand(\.Path)?$') { return Test-PSCxOwnInvocation -Node $Node }
    $file = $Node.Extent.File
    if (-not $file) { return $false }
    return $text -match ('^(\.|\$PSScriptRoot)[\\/]' + [regex]::Escape([System.IO.Path]::GetFileName($file)) + '$')
}

function Test-PSCxOwnInvocation {
    # True when no script block lies between a command and the body of its unit, so that
    # `$MyInvocation` there is the unit's own. `& { & $MyInvocation.MyCommand }` runs the block,
    # which has an invocation of its own -- measured, not assumed. A block passed to
    # ForEach-Object shares its caller's, but a static walk cannot tell which command a block
    # will be handed to, so every block stops the match: a missed recursion rather than an
    # invented one.
    [OutputType([bool])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    for ($p = $Node.Parent; $p -isnot [System.Management.Automation.Language.FunctionDefinitionAst]; $p = $p.Parent) {
        if ($null -eq $p) { return $true }
        if ($p -is [System.Management.Automation.Language.ScriptBlockExpressionAst]) { return $false }
    }
    return $true
}

function Get-PSCxEnclosingMethod {
    # Nearest enclosing class method/constructor, else $null. Method recursion goes
    # through a member invocation ($this.X()), not a command, so it needs the AST
    # node -- the class name on it is what tells a static self-call apart.
    [OutputType([System.Management.Automation.Language.Ast])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    $boundary = Get-PSCxUnitBoundary -Node $Node
    if ($boundary -is [System.Management.Automation.Language.FunctionMemberAst]) { return $boundary }
    return $null
}

function Test-PSCxFlowCommand {
    # True when a node is a call to a cmdlet, or an intrinsic method, that acts as control flow.
    #
    # Matched on the name as WRITTEN, because that is all a static walk has: `%` and
    # ForEach-Object are the same command at run time and different text here, so both
    # spellings are listed rather than resolved.
    [OutputType([bool])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Node)
    # The method form is matched by name alone, as the cmdlet is: `.ForEach('Name')` counts as
    # `ForEach-Object Name` does. INSTANCE calls only -- the intrinsics are never static, so
    # `[Type]::Where()` is somebody else's method. A computed name (`$x.$m()`) has no Value,
    # and -contains answers False for it, as it does for a nameless command below.
    if ($Node -is [System.Management.Automation.Language.InvokeMemberExpressionAst]) {
        return (-not $Node.Static) -and ($script:PSCxFlowMethods -contains $Node.Member.Value)
    }
    if ($Node -isnot [System.Management.Automation.Language.CommandAst]) { return $false }
    # No guard for a null name: a command invoked through a variable (`& $cmd`) has none,
    # and -contains already answers False for it. A guard here would be unreachable by any
    # observation, which is how it was found -- both of its mutants survived.
    return $script:PSCxFlowCommands -contains $Node.GetCommandName()
}