Modules/businessdev.ALbuild.Pipeline/Private/Resolve-AdoTaskScript.ps1

function Resolve-AdoTaskScript {
    <#
    .SYNOPSIS
        Resolves an Azure DevOps task reference ("Name@version") to its task.ps1 path.
    .DESCRIPTION
        Internal helper for the local pipeline runner. Scans the extension Tasks tree for a task.json
        whose "name" matches the referenced task (the "@version" suffix is ignored) and returns the
        sibling task.ps1. Throws when the task is unknown or has no task.ps1.
    .PARAMETER TaskReference
        The pipeline task reference, e.g. "CompileApp@0".
    .PARAMETER TasksRoot
        The extension Tasks folder to search.
    .OUTPUTS
        System.String (full path to task.ps1).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $TaskReference,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $TasksRoot
    )

    if (-not (Test-Path -LiteralPath $TasksRoot)) { throw "Tasks root '$TasksRoot' does not exist." }
    $name = ($TaskReference -split '@', 2)[0].Trim()

    foreach ($taskJson in Get-ChildItem -LiteralPath $TasksRoot -Filter 'task.json' -File -Recurse) {
        # Read the top-level "name" with a regex: it is the first "name" key in task.json and this
        # avoids JSON parser quirks (e.g. empty-string pickList option keys) and stays PS 5.1-safe.
        $raw = Get-Content -LiteralPath $taskJson.FullName -Raw
        $m = [regex]::Match($raw, '"name"\s*:\s*"(?<n>[^"]+)"')
        if ($m.Success -and ($m.Groups['n'].Value -ieq $name)) {
            $scriptPath = Join-Path $taskJson.Directory.FullName 'task.ps1'
            if (-not (Test-Path -LiteralPath $scriptPath)) { throw "Task '$name' has no task.ps1 at '$($taskJson.Directory.FullName)'." }
            return $scriptPath
        }
    }
    throw "No extension task named '$name' was found under '$TasksRoot'."
}