modules/shared/FunctionCollision.ps1

# FunctionCollision.ps1 — AST-based duplicate-function detector for the
# modules/shared/ tree. Companion to Errors.ps1.
#
# Background (#529 / #671): AzureAnalyzer.psm1 dot-sources every *.ps1 under
# modules/shared/ (now sorted by FullName for a deterministic load order),
# so two files defining the same top-level function will silently shadow each
# other. The original incident: a stub `function New-FindingError` in
# Schema.ps1 overrode the canonical sanitizing version in Errors.ps1,
# bypassing Remove-Credentials on Reason / Remediation.
#
# This helper walks each file's AST and reports any function name defined more
# than once across files, IGNORING:
# - guarded fallback shims wrapped in `if (-not (Get-Command X)) { function X }`
# - nested helper functions defined inside another function
# Both patterns are intentional and don't cause shadowing of the canonical.

function Test-AzureAnalyzerSharedFunctionCollisions {
    [CmdletBinding()]
    param ([Parameter(Mandatory)] [System.IO.FileInfo[]] $Files)

    # Returns true when Node is nested under Ancestor in the AST parent chain.
    function Test-IsAstDescendant {
        param(
            [Parameter(Mandatory)] $Node,
            [Parameter(Mandatory)] $Ancestor
        )
        $cursor = $Node
        while ($cursor) {
            if ($cursor -eq $Ancestor) { return $true }
            $cursor = $cursor.Parent
        }
        return $false
    }

    # True only for fallback shims of the shape:
    # if (-not (Get-Command <same-function-name> ...)) { function <same-function-name> { ... } }
    function Test-IsGuardedFallbackShim {
        param(
            [Parameter(Mandatory)] [System.Management.Automation.Language.IfStatementAst] $IfAst,
            [Parameter(Mandatory)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst
        )

        foreach ($clause in $IfAst.Clauses) {
            $conditionAst = $clause.Item1
            $blockAst = $clause.Item2
            if (-not (Test-IsAstDescendant -Node $FunctionAst -Ancestor $blockAst)) { continue }

            $conditionText = $conditionAst.Extent.Text
            # Match `-not (Get-Command FunctionName ...)` with flexible spacing.
            # Keep name matching permissive to mirror parser-accepted function names.
            if ($conditionText -notmatch '^\s*-not\s*\(\s*Get-Command\s+([A-Za-z0-9_-]+)\b') { continue }

            if ($matches[1] -ieq $FunctionAst.Name) { return $true }
        }

        return $false
    }

    $seen = @{}
    foreach ($f in $Files) {
        $tokens = $null; $errors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile(
            $f.FullName, [ref]$tokens, [ref]$errors)
        # Fail closed: if a shared file becomes unparseable we must NOT silently
        # skip it — that would let a duplicate definition slip past this guard.
        if ($errors -and $errors.Count -gt 0) {
            $parseMessages = @($errors | ForEach-Object {
                    if ($_.Extent) {
                        '{0} (line {1}, column {2})' -f $_.Message, $_.Extent.StartLineNumber, $_.Extent.StartColumnNumber
                    } else {
                        $_.Message
                    }
                })
            throw ("AzureAnalyzer: failed to parse shared file '{0}' while checking function collisions: {1}" -f `
                    $f.FullName, ($parseMessages -join '; '))
        }
        if ($null -eq $ast) { continue }
        $funcs = $ast.FindAll({ param($n)
            $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
        foreach ($fn in $funcs) {
            $isGuarded = $false
            $p = $fn.Parent
            while ($p) {
                if ($p -is [System.Management.Automation.Language.FunctionDefinitionAst]) {
                    $isGuarded = $true; break
                }
                if ($p -is [System.Management.Automation.Language.IfStatementAst] -and
                    (Test-IsGuardedFallbackShim -IfAst $p -FunctionAst $fn)) {
                    $isGuarded = $true; break
                }
                $p = $p.Parent
            }
            if ($isGuarded) { continue }
            $name = $fn.Name
            if ($seen.ContainsKey($name)) {
                $seen[$name] += , $f.FullName
            } else {
                $seen[$name] = @($f.FullName)
            }
        }
    }
    $collisions = @($seen.GetEnumerator() | Where-Object { @($_.Value).Count -gt 1 })
    foreach ($c in $collisions) {
        Write-Warning ("AzureAnalyzer: function '{0}' is defined in multiple shared files (later wins): {1}" -f `
                $c.Key, ($c.Value -join '; '))
    }
    return $collisions
}