Private/Assert-IdleConditionPathsResolvable.ps1

Set-StrictMode -Version Latest

function Assert-IdleConditionPathsResolvable {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [hashtable] $Condition,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Context,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $StepName,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Source,

        [Parameter()]
        [switch] $AllowMissingRequestContextPaths,

        [Parameter()]
        [AllowNull()]
        [object] $WarningSink,

        # When set, skips validation of paths used by the Exists operator.
        # Exists semantics intentionally allow missing paths (returns $false if absent),
        # so strict execution-time path validation should exclude those paths.
        [Parameter()]
        [switch] $ExcludeExistsOperatorPaths
    )

    function Add-IdlePathIfPresent {
        param(
            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Collections.Generic.List[string]] $PathList,

            [Parameter(Mandatory)]
            [AllowNull()]
            [object] $PathCandidate
        )

        if ($null -eq $PathCandidate) {
            return
        }

        $pathText = [string]$PathCandidate
        if ([string]::IsNullOrWhiteSpace($pathText)) {
            return
        }

        if ($pathText.StartsWith('context.', [System.StringComparison]::OrdinalIgnoreCase)) {
            $pathText = $pathText.Substring(8)
        }

        $null = $PathList.Add($pathText)
    }

    function Get-IdleConditionPaths {
        param(
            [Parameter(Mandatory)]
            [System.Collections.IDictionary] $Node,

            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Collections.Generic.List[string]] $PathList,

            [Parameter()]
            [switch] $ExcludeExistsPaths
        )

        if ($Node.Contains('All')) {
            foreach ($child in @($Node.All)) {
                if ($child -is [System.Collections.IDictionary]) {
                    Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths
                }
            }
            return
        }

        if ($Node.Contains('Any')) {
            foreach ($child in @($Node.Any)) {
                if ($child -is [System.Collections.IDictionary]) {
                    Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths
                }
            }
            return
        }

        if ($Node.Contains('None')) {
            foreach ($child in @($Node.None)) {
                if ($child -is [System.Collections.IDictionary]) {
                    Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths
                }
            }
            return
        }

        if ($Node.Contains('Equals')) {
            Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.Equals.Path
            return
        }

        if ($Node.Contains('NotEquals')) {
            Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.NotEquals.Path
            return
        }

        if ($Node.Contains('Exists')) {
            # Exists operator semantics: checking for the presence of a path is intentional.
            # When -ExcludeExistsPaths is set (e.g. strict execution-time validation), skip these
            # so that Exists can still return $false without causing a path-not-found error.
            if (-not $ExcludeExistsPaths) {
                $existsVal = $Node.Exists
                if ($existsVal -is [string]) {
                    Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal
                }
                elseif ($existsVal -is [System.Collections.IDictionary]) {
                    Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path
                }
            }
            return
        }

        if ($Node.Contains('In')) {
            Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.In.Path
            return
        }
    }

    $paths = [System.Collections.Generic.List[string]]::new()
    Get-IdleConditionPaths -Node $Condition -PathList $paths -ExcludeExistsPaths:$ExcludeExistsOperatorPaths

    $uniquePaths = @($paths | Select-Object -Unique)
    if ($uniquePaths.Count -eq 0) {
        return
    }

    $missingPaths = @()
    $softMissingContextPaths = @()
    foreach ($path in $uniquePaths) {
        if (-not (Test-IdlePathExists -Object $Context -Path $path)) {
            if ($AllowMissingRequestContextPaths -and $path.StartsWith('Request.Context.')) {
                $softMissingContextPaths += $path
                continue
            }
            $missingPaths += $path
        }
    }

    if ($softMissingContextPaths.Count -gt 0) {
        $uniqueSoftPaths = @($softMissingContextPaths | Select-Object -Unique)
        $warningMessage = "Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', $uniqueSoftPaths))

        # Emit a visible PowerShell warning for immediate host feedback during planning.
        Write-Warning $warningMessage

        if ($null -ne $WarningSink) {
            $warningItem = [ordered]@{
                Code    = 'PreconditionContextPathUnresolvedAtPlan'
                Type    = 'Warning'
                Step    = $StepName
                Source  = $Source
                Paths   = $uniqueSoftPaths
                Message = $warningMessage
            }

            if ($WarningSink -is [System.Collections.IList]) {
                $null = $WarningSink.Add($warningItem)
            }
        }
    }

    if ($missingPaths.Count -gt 0) {
        $missingPathList = [string]::Join(', ', $missingPaths)
        throw [System.ArgumentException]::new(
            ("Workflow step '{0}' has unresolved condition path(s) in {1}: [{2}]. Check Request/Plan structure or ContextResolvers outputs." -f $StepName, $Source, $missingPathList),
            'Workflow'
        )
    }
}