private/Get-InputHash.ps1

function Get-InputHash {
    <#
    .SYNOPSIS
    Computes a SHA256 hash over a task's inputs for caching.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [PsakeTask]$Task,

        [Parameter(Mandatory = $true)]
        [PsakeBuildPlan]$Plan
    )

    Write-Debug "Computing input hash for task '$($Task.Name)'"
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    $hashInput = [System.Text.StringBuilder]::new()

    # Hash the Action scriptblock text
    if ($Task.Action) {
        $null = $hashInput.AppendLine($Task.Action.ToString())
    }

    # Hash the Inputs spec itself when it's a scriptblock (code changes invalidate cache)
    if ($Task.Inputs -is [scriptblock]) {
        $null = $hashInput.AppendLine("inputs-script:$($Task.Inputs.ToString())")
    }

    # Hash sorted input file contents
    $inputFiles = Resolve-TaskFiles -FileSpec $Task.Inputs | Sort-Object
    foreach ($file in $inputFiles) {
        if (Test-Path $file -PathType Leaf) {
            $fileBytes = [System.IO.File]::ReadAllBytes($file)
            $fileHash = [System.BitConverter]::ToString($sha256.ComputeHash($fileBytes)).Replace('-', '')
            $null = $hashInput.AppendLine("$file`:$fileHash")
        }
    }

    # Hash dependency task hashes (cascade invalidation)
    foreach ($dep in $Task.DependsOn) {
        $depKey = $dep.ToLower()
        if ($Plan.InputHashes.ContainsKey($depKey)) {
            $null = $hashInput.AppendLine("dep:$depKey`:$($Plan.InputHashes[$depKey])")
        }
    }

    $bytes = [System.Text.Encoding]::UTF8.GetBytes($hashInput.ToString())
    $hash = [System.BitConverter]::ToString($sha256.ComputeHash($bytes)).Replace('-', '')
    $sha256.Dispose()

    Write-Debug "Input hash for task '$($Task.Name)': sha256:$hash"
    return "sha256:$hash"
}