Tools/Test-DuplicateParameterBinding.ps1

<#
.SYNOPSIS
    Findet doppelte Parameterbindungen (Splat-Hashtable + gleichnamiger expliziter Parameter).
 
.DESCRIPTION
    Sucht das Muster, das Windows PowerShell 5.1 mit einer ParameterBindingException
    (ParameterAlreadyBound) abbricht, waehrend PowerShell 7 es stillschweigend toleriert
    (der explizite Wert gewinnt):
 
        $connParams = @{ SqlInstance = $s; Database = $db; ErrorAction = 'Stop' }
        Invoke-DbaQuery @connParams -Database 'master' # <- Crash unter 5.1
        Invoke-DbaQuery @connParams -ErrorAction SilentlyContinue # <- Crash unter 5.1
 
    Weil die Entwicklung ueblicherweise unter PS 7 laeuft, die Module aber PowerShellVersion 5.1
    deklarieren und dort ausgeliefert werden, faellt so ein Fehler erst beim Kunden auf. Ein
    reiner Import-Test findet ihn nicht, da er erst zur Aufrufzeit auftritt.
 
    Geprueft wird jeder Schluessel der Splat-Hashtable, nicht nur Common Parameter. Die
    Schluessel werden pro Funktion in Textreihenfolge bis zum Aufruf verfolgt:
      - Hashtable-Literale $p = @{ Database = $db } / $p = [ordered]@{ ... }
      - Index-/Member-Zuweisungen $p['Database'] = 'x' / $p.Database = 'x'
      - Methoden $p.Add('Database', 'x') / $p.Remove('Database') / $p.Clear()
      - Kopien $q = $p.Clone() / $q = $p
      - Ergaenzung $p += @{ Database = 'x' }
    Eine Neuzuweisung oder ein Clear() ersetzt die bisherigen Schluessel nur, wenn sie auf dem
    Weg zum Aufruf sicher ausgefuehrt wird (nicht in if/switch/Schleife/catch/Scriptblock);
    sonst werden die Schluessel vereinigt. Ein Remove() vor dem Aufruf entfernt den Schluessel
    immer (lieber ein uebersehener Treffer als ein Fehlalarm). Zuweisungen im if-Zweig gelten
    nicht fuer Aufrufe im else-Zweig derselben Anweisung.
 
    Abkuerzungen und Aliase:
      - Common Parameter: Aliase (-ea, -wa, -ev, ...) und eindeutige Praefixe (-ErrorAct).
      - Funktionen, die im geprueften Verzeichnis definiert sind: deren param()-Block samt
        [Alias()] wird ausgewertet, eindeutige Praefixe werden aufgeloest.
      - Fremde Befehle (z. B. dbatools): nur exakter Namensvergleich, weil ohne Parameterliste
        nicht entscheidbar ist, ob -Query die Abkuerzung von QueryTimeout oder ein eigener
        Parameter ist.
 
.PARAMETER Root
    Wurzelverzeichnis der Pruefung. Standard ist das uebergeordnete Verzeichnis dieses Skripts,
    also das Repository-Wurzelverzeichnis.
 
.PARAMETER ExcludeDirectory
    Verzeichnisnamen, die uebersprungen werden. Standard: .git, bin, obj, node_modules.
 
.PARAMETER Quiet
    Unterdrueckt die Ausgabe. Nur der Exit-Code zaehlt.
 
.PARAMETER PassThru
    Gibt die Treffer zusaetzlich als Objekte aus (File, Line, Command, Parameter, Explicit, Splat).
 
.OUTPUTS
    Exit-Code 0 = sauber, 1 = mindestens ein Treffer.
 
.NOTES
    Autor : Uwe Janke - powershelldba.de
    Anlass: sqmDataTransfer 0.1.17.1 (Common Parameter -ErrorAction) und sqmSQLTool 1.9.178.0
            (@connParams mit Database + explizites -Database 'master' in den Zertifikatsfunktionen).
#>

[CmdletBinding()]
param(
    [string]$Root,
    [string[]]$ExcludeDirectory = @('.git', 'bin', 'obj', 'node_modules'),
    [switch]$Quiet,
    [switch]$PassThru
)

if (-not $Root)
{
    $Root = Split-Path -Parent $PSScriptRoot
}
$Root = (Resolve-Path -Path $Root).ProviderPath

# Common Parameter, die PowerShell an jedes Advanced Function bindet, mit ihren festen Aliasen.
$commonParameters = @(
    @{ Name = 'ErrorAction'; Aliases = @('ea') },
    @{ Name = 'WarningAction'; Aliases = @('wa') },
    @{ Name = 'InformationAction'; Aliases = @('infa') },
    @{ Name = 'Verbose'; Aliases = @('vb') },
    @{ Name = 'Debug'; Aliases = @('db') },
    @{ Name = 'ErrorVariable'; Aliases = @('ev') },
    @{ Name = 'WarningVariable'; Aliases = @('wv') },
    @{ Name = 'InformationVariable'; Aliases = @('iv') },
    @{ Name = 'OutVariable'; Aliases = @('ov') },
    @{ Name = 'OutBuffer'; Aliases = @('ob') },
    @{ Name = 'PipelineVariable'; Aliases = @('pv') },
    @{ Name = 'WhatIf'; Aliases = @('wi') },
    @{ Name = 'Confirm'; Aliases = @('cf') }
)

function Get-AstAncestorList
{
    param($Node)
    $list = New-Object System.Collections.Generic.List[object]
    while ($Node)
    {
        $list.Add($Node)
        $Node = $Node.Parent
    }
    , $list
}

function Get-AstFunctionScope
{
    # Naechste umschliessende Funktion, sonst die Datei. Variablen anderer Funktionen sind getrennt.
    param($Node)
    $current = $Node.Parent
    while ($current)
    {
        if ($current -is [System.Management.Automation.Language.FunctionDefinitionAst]) { return $current }
        if (-not $current.Parent) { return $current }
        $current = $current.Parent
    }
    $Node
}

function Get-AstCommonAncestorInfo
{
    # Liefert den niedrigsten gemeinsamen Vorfahren und je das Kind darunter auf beiden Seiten.
    param($First, $Second)
    $firstChain = Get-AstAncestorList -Node $First
    $secondChain = Get-AstAncestorList -Node $Second
    for ($i = 0; $i -lt $secondChain.Count; $i++)
    {
        $index = -1
        for ($j = 0; $j -lt $firstChain.Count; $j++)
        {
            if ([object]::ReferenceEquals($firstChain[$j], $secondChain[$i])) { $index = $j; break }
        }
        if ($index -ge 0)
        {
            $firstChild = $null
            $secondChild = $null
            if ($index -gt 0) { $firstChild = $firstChain[$index - 1] }
            if ($i -gt 0) { $secondChild = $secondChain[$i - 1] }
            return [pscustomobject]@{
                Ancestor    = $secondChain[$i]
                FirstChain  = $firstChain
                FirstIndex  = $index
                FirstChild  = $firstChild
                SecondChild = $secondChild
            }
        }
    }
    $null
}

function Test-AstMutuallyExclusive
{
    # $true, wenn beide Knoten in verschiedenen Zweigen derselben if-/switch-Anweisung liegen.
    param($First, $Second)
    $info = Get-AstCommonAncestorInfo -First $First -Second $Second
    if (-not $info) { return $false }
    if (-not ($info.Ancestor -is [System.Management.Automation.Language.IfStatementAst] -or
            $info.Ancestor -is [System.Management.Automation.Language.SwitchStatementAst])) { return $false }
    ($info.FirstChild -is [System.Management.Automation.Language.StatementBlockAst]) -and
    ($info.SecondChild -is [System.Management.Automation.Language.StatementBlockAst]) -and
    -not [object]::ReferenceEquals($info.FirstChild, $info.SecondChild)
}

function Test-AstUnconditionalBefore
{
    # $true, wenn $Node auf jedem Weg zu $Target ausgefuehrt wird (keine Bedingung/Schleife dazwischen).
    param($Node, $Target)
    $info = Get-AstCommonAncestorInfo -First $Node -Second $Target
    if (-not $info) { return $false }
    for ($k = 1; $k -lt $info.FirstIndex; $k++)
    {
        $step = $info.FirstChain[$k]
        if ($step -is [System.Management.Automation.Language.IfStatementAst] -or
            $step -is [System.Management.Automation.Language.SwitchStatementAst] -or
            $step -is [System.Management.Automation.Language.LoopStatementAst] -or
            $step -is [System.Management.Automation.Language.CatchClauseAst] -or
            $step -is [System.Management.Automation.Language.TrapStatementAst] -or
            $step -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -or
            $step -is [System.Management.Automation.Language.BinaryExpressionAst])
        {
            return $false
        }
    }
    $true
}

function Get-UnwrappedExpression
{
    param($Node)
    if ($Node -is [System.Management.Automation.Language.CommandExpressionAst]) { $Node = $Node.Expression }
    while ($true)
    {
        if ($Node -is [System.Management.Automation.Language.AttributedExpressionAst]) { $Node = $Node.Child; continue }
        if ($Node -is [System.Management.Automation.Language.ParenExpressionAst] -and
            $Node.Pipeline -is [System.Management.Automation.Language.PipelineAst] -and
            $Node.Pipeline.PipelineElements.Count -eq 1)
        {
            $Node = $Node.Pipeline.PipelineElements[0]
            if ($Node -is [System.Management.Automation.Language.CommandExpressionAst]) { $Node = $Node.Expression }
            continue
        }
        break
    }
    $Node
}

function Get-HashtableLiteralKey
{
    param($Hashtable)
    foreach ($pair in $Hashtable.KeyValuePairs)
    {
        if ($pair.Item1 -is [System.Management.Automation.Language.StringConstantExpressionAst])
        {
            $pair.Item1.Value
        }
    }
}

function Get-SplatKeyAt
{
    # Schluessel der Variable $VariableName unmittelbar vor $Position (gleiche Funktion).
    param([object[]]$Events, [string]$VariableName, $Position, [int]$Depth = 0)

    $keys = New-Object System.Collections.Generic.List[string]
    if ($Depth -gt 10) { return , $keys }

    $scope = Get-AstFunctionScope -Node $Position
    $offset = $Position.Extent.StartOffset

    foreach ($change in $Events)
    {
        if ($change.Variable -ne $VariableName) { continue }
        if ($change.Offset -gt $offset) { continue }
        if (-not [object]::ReferenceEquals($change.Scope, $scope)) { continue }
        if (Test-AstMutuallyExclusive -First $change.Node -Second $Position) { continue }

        switch ($change.Kind)
        {
            'Reset'
            {
                if (Test-AstUnconditionalBefore -Node $change.Node -Target $Position) { $keys.Clear() }
                $newKeys = $change.Keys
                if ($change.CopyFrom)
                {
                    $newKeys = Get-SplatKeyAt -Events $Events -VariableName $change.CopyFrom -Position $change.Node -Depth ($Depth + 1)
                }
                foreach ($key in $newKeys)
                {
                    if (-not ($keys | Where-Object { $_ -eq $key })) { $keys.Add($key) }
                }
            }
            'Add'
            {
                foreach ($key in $change.Keys)
                {
                    if (-not ($keys | Where-Object { $_ -eq $key })) { $keys.Add($key) }
                }
            }
            'Remove'
            {
                foreach ($key in $change.Keys)
                {
                    for ($r = $keys.Count - 1; $r -ge 0; $r--)
                    {
                        if ($keys[$r] -eq $key) { $keys.RemoveAt($r) }
                    }
                }
            }
            'Clear'
            {
                if (Test-AstUnconditionalBefore -Node $change.Node -Target $Position) { $keys.Clear() }
            }
        }
    }
    , $keys
}

function Resolve-BoundParameterName
{
    # Bildet einen geschriebenen Parameternamen (Abkuerzung, Alias) auf den echten Namen ab.
    param([string]$Name, [object[]]$Candidates)

    foreach ($candidate in $Candidates)
    {
        if ($candidate.Name -eq $Name) { return $candidate.Name }
    }
    foreach ($candidate in $Candidates)
    {
        if ($candidate.Aliases -contains $Name) { return $candidate.Name }
    }
    if ($Name.Length -lt 2) { return $Name }

    $prefixMatches = @($Candidates | Where-Object {
            $candidateName = $_.Name
            ($candidateName -like "$Name*") -or ($_.Aliases | Where-Object { $_ -like "$Name*" })
        } | ForEach-Object { $_.Name } | Select-Object -Unique)

    if ($prefixMatches.Count -eq 1)
    {
        # Bei fremden Befehlen kennen wir nur die Common Parameter. Ein Praefix wie -Ver koennte
        # dort ebenso ein eigener Parameter (-Version) sein - trotzdem wie bisher als Common werten.
        return $prefixMatches[0]
    }
    $Name
}

$files = Get-ChildItem -Path $Root -Recurse -Include '*.ps1', '*.psm1' -File |
Where-Object {
    $relative = $_.FullName.Substring($Root.Length).TrimStart('\', '/')
    $segments = $relative -split '[\\/]'
    -not ($segments | Where-Object { $ExcludeDirectory -contains $_ })
}

# 1. Alle Dateien parsen und die Parameterlisten der hier definierten Funktionen sammeln.
$parsed = @()
$knownFunctions = @{ }

foreach ($file in $files)
{
    $parseErrors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$parseErrors)
    if (-not $ast) { continue }
    $parsed += [pscustomobject]@{ File = $file; Ast = $ast }

    $definitions = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
    foreach ($definition in $definitions)
    {
        $parameterAsts = @()
        if ($definition.Body.ParamBlock) { $parameterAsts = $definition.Body.ParamBlock.Parameters }
        elseif ($definition.Parameters) { $parameterAsts = $definition.Parameters }

        $parameterList = @()
        foreach ($parameterAst in $parameterAsts)
        {
            $aliases = @()
            foreach ($attribute in $parameterAst.Attributes)
            {
                if ($attribute -is [System.Management.Automation.Language.AttributeAst] -and $attribute.TypeName.Name -eq 'Alias')
                {
                    foreach ($argument in $attribute.PositionalArguments)
                    {
                        $argument.FindAll({ $args[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true) |
                        ForEach-Object { $aliases += $_.Value }
                    }
                }
            }
            $parameterList += @{ Name = $parameterAst.Name.VariablePath.UserPath; Aliases = $aliases }
        }
        $knownFunctions[$definition.Name.ToLowerInvariant()] = $parameterList
    }
}

$findings = @()

foreach ($entry in $parsed)
{
    $file = $entry.File
    $ast = $entry.Ast

    # 2. Ereignisse sammeln, die die Schluessel einer Hashtable-Variable veraendern.
    $events = New-Object System.Collections.Generic.List[object]

    $assignments = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)
    foreach ($assignment in $assignments)
    {
        $left = $assignment.Left
        while ($left -is [System.Management.Automation.Language.AttributedExpressionAst]) { $left = $left.Child }

        $change = $null

        if ($left -is [System.Management.Automation.Language.VariableExpressionAst])
        {
            $variableName = $left.VariablePath.UserPath.ToLowerInvariant()
            $right = Get-UnwrappedExpression -Node $assignment.Right
            $operator = $assignment.Operator.ToString()

            if ($operator -eq 'Equals')
            {
                $change = @{ Variable = $variableName; Kind = 'Reset'; Keys = @(); CopyFrom = $null }
                if ($right -is [System.Management.Automation.Language.HashtableAst])
                {
                    $change.Keys = @(Get-HashtableLiteralKey -Hashtable $right)
                }
                elseif ($right -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and
                    $right.Member -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
                    $right.Member.Value -eq 'Clone' -and
                    $right.Expression -is [System.Management.Automation.Language.VariableExpressionAst])
                {
                    $change.CopyFrom = $right.Expression.VariablePath.UserPath.ToLowerInvariant()
                }
                elseif ($right -is [System.Management.Automation.Language.VariableExpressionAst] -and -not $right.Splatted)
                {
                    $change.CopyFrom = $right.VariablePath.UserPath.ToLowerInvariant()
                }
            }
            elseif ($operator -eq 'PlusEquals' -and $right -is [System.Management.Automation.Language.HashtableAst])
            {
                $change = @{ Variable = $variableName; Kind = 'Add'; Keys = @(Get-HashtableLiteralKey -Hashtable $right) }
            }
        }
        elseif ($left -is [System.Management.Automation.Language.IndexExpressionAst] -and
            $left.Target -is [System.Management.Automation.Language.VariableExpressionAst] -and
            $left.Index -is [System.Management.Automation.Language.StringConstantExpressionAst])
        {
            $change = @{ Variable = $left.Target.VariablePath.UserPath.ToLowerInvariant(); Kind = 'Add'; Keys = @($left.Index.Value) }
        }
        elseif ($left -is [System.Management.Automation.Language.MemberExpressionAst] -and
            $left.Expression -is [System.Management.Automation.Language.VariableExpressionAst] -and
            $left.Member -is [System.Management.Automation.Language.StringConstantExpressionAst])
        {
            $change = @{ Variable = $left.Expression.VariablePath.UserPath.ToLowerInvariant(); Kind = 'Add'; Keys = @($left.Member.Value) }
        }

        if ($change)
        {
            $change.Node = $assignment
            $change.Offset = $assignment.Extent.EndOffset
            $change.Scope = Get-AstFunctionScope -Node $assignment
            $events.Add([pscustomobject]$change)
        }
    }

    $invocations = $ast.FindAll({
            $args[0] -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and
            $args[0].Expression -is [System.Management.Automation.Language.VariableExpressionAst] -and
            $args[0].Member -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
            @('Add', 'Remove', 'Clear') -contains $args[0].Member.Value
        }, $true)
    foreach ($invocation in $invocations)
    {
        $kind = $invocation.Member.Value
        $keys = @()
        if ($kind -ne 'Clear')
        {
            if (-not $invocation.Arguments -or $invocation.Arguments[0] -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) { continue }
            $keys = @($invocation.Arguments[0].Value)
        }
        $events.Add([pscustomobject]@{
                Variable = $invocation.Expression.VariablePath.UserPath.ToLowerInvariant()
                Kind     = $kind
                Keys     = $keys
                CopyFrom = $null
                Node     = $invocation
                Offset   = $invocation.Extent.EndOffset
                Scope    = Get-AstFunctionScope -Node $invocation
            })
    }

    if ($events.Count -eq 0) { continue }
    $sortedEvents = @($events | Sort-Object Offset)
    $trackedVariables = @($sortedEvents | ForEach-Object { $_.Variable } | Select-Object -Unique)

    # 3. Aufrufe finden, die so eine Variable splatten UND einen ihrer Schluessel explizit setzen.
    $commands = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)

    foreach ($command in $commands)
    {
        $splattedVariables = @()
        $explicitParameters = @()

        foreach ($element in $command.CommandElements)
        {
            if ($element -is [System.Management.Automation.Language.VariableExpressionAst] -and $element.Splatted)
            {
                $splattedVariables += $element.VariablePath.UserPath
            }
            elseif ($element -is [System.Management.Automation.Language.CommandParameterAst])
            {
                $explicitParameters += $element.ParameterName
            }
        }

        if ($splattedVariables.Count -eq 0 -or $explicitParameters.Count -eq 0) { continue }

        $commandName = $command.GetCommandName()
        $candidates = $commonParameters
        if ($commandName -and $knownFunctions.ContainsKey($commandName.ToLowerInvariant()))
        {
            $candidates = @($knownFunctions[$commandName.ToLowerInvariant()]) + $commonParameters
        }

        foreach ($variableName in $splattedVariables)
        {
            if ($trackedVariables -notcontains $variableName) { continue }

            $keys = Get-SplatKeyAt -Events $sortedEvents -VariableName $variableName -Position $command
            foreach ($key in $keys)
            {
                $resolvedKey = Resolve-BoundParameterName -Name $key -Candidates $candidates
                foreach ($explicit in $explicitParameters)
                {
                    $resolvedExplicit = Resolve-BoundParameterName -Name $explicit -Candidates $candidates
                    if ($resolvedKey -eq $resolvedExplicit)
                    {
                        $findings += [pscustomobject]@{
                            File      = $file.FullName.Substring($Root.Length).TrimStart('\', '/')
                            Line      = $command.Extent.StartLineNumber
                            Command   = $commandName
                            Parameter = $resolvedKey
                            Explicit  = '-' + $explicit
                            Splat     = '@' + $variableName
                        }
                    }
                }
            }
        }
    }
}

if ($findings)
{
    if (-not $Quiet)
    {
        Write-Host ""
        Write-Host "Doppelte Parameterbindung gefunden - crasht unter Windows PowerShell 5.1:" -ForegroundColor Red
        $findings | Sort-Object File, Line | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
        Write-Host "Behebung: den Schluessel vor dem Aufruf aus einer Kopie der Splat-Hashtable entfernen" -ForegroundColor Yellow
        Write-Host "bzw. dort ueberschreiben (`$p = `$connParams.Clone(); `$p['Database'] = 'master')," -ForegroundColor Yellow
        Write-Host "oder den expliziten Parameter weglassen." -ForegroundColor Yellow
        Write-Host ""
    }
    if ($PassThru) { $findings | Sort-Object File, Line }
    exit 1
}

if (-not $Quiet)
{
    Write-Host "Keine doppelten Parameterbindungen in '$Root' ($($files.Count) Datei(en) geprueft)." -ForegroundColor Green
}
exit 0