Modules/businessdev.ALbuild.Apps/Private/ConvertTo-BcAlStackFrame.ps1

function ConvertTo-BcAlStackFrame {
    <#
    .SYNOPSIS
        Parses one line of a Business Central AL callstack and resolves it to a file and line.
    .DESCRIPTION
        Internal helper. A frame looks like one of these, measured on BC 28.4:
 
            Library Assert(CodeUnit 130002).AreEqual line 3 - Library Assert by Microsoft version 28.4...
            AL Probe Differential(CodeUnit 50190).Case001_x line 6 - AL Probe Differential by 365 busi...
            Test Runner - Isol. Codeunit(CodeUnit 130450).OnRun(Trigger) line 4 - Test Runner by Micro...
            Command Line Test Tool(Page 130455).RunNextTest - OnAction(Trigger) line 7 - Test Runner b...
 
        THE LINE NUMBER IS NOT A FILE LINE. It is counted from the procedure's own declaration line, so
        the file line is declaration + reported. Verified against a generated test: the failing assertion
        sat at file line 16, its procedure was declared at line 10, and the frame said line 6.
 
        Returns $null for a line that is not a frame (blank lines, wrapped text), so the caller can hand
        the whole stack over without pre-filtering.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [AllowEmptyString()] [string] $Line,
        [Parameter(Mandatory)] [hashtable] $ObjectIndex
    )

    $pattern = '^\s*(?<obj>.+?)\((?<kind>CodeUnit|Codeunit|Page|Table|Report|Query|XmlPort)\s+(?<id>\d+)\)\.' +
               '(?<proc>[^\s(]+)(?<trigger>\s*-\s*\w+)?(?:\(Trigger\))?\s+line\s+(?<line>\d+)\s*-\s*' +
               '(?<app>.+?)\s+by\s+(?<pub>.+?)(?:\s+version\s+(?<ver>[\d.]+))?\s*$'

    $m = [regex]::Match($Line, $pattern)
    if (-not $m.Success) { return $null }

    $objectName = $m.Groups['obj'].Value.Trim()
    $kind = $m.Groups['kind'].Value.ToLowerInvariant()
    $id = [int] $m.Groups['id'].Value
    $reported = [int] $m.Groups['line'].Value

    # A trigger frame writes the member as "RunNextTest - OnAction"; the declaration to look up is the
    # trigger, not the action that led to it.
    $procedure = $m.Groups['proc'].Value
    if ($m.Groups['trigger'].Success) {
        $procedure = ($m.Groups['trigger'].Value -replace '^\s*-\s*', '')
    }

    # Prefer the id: object names repeat across apps, ids do not.
    $entry = $ObjectIndex["${kind}:${id}"]
    if (-not $entry) { $entry = $ObjectIndex[$objectName.ToLowerInvariant()] }

    $file = $null
    $resolvedLine = $null
    if ($entry -and $entry.Procedures.ContainsKey($procedure.ToLowerInvariant())) {
        $file = $entry.File
        $resolvedLine = [int] $entry.Procedures[$procedure.ToLowerInvariant()] + $reported
    }

    return [PSCustomObject]@{
        Object       = $objectName
        Kind         = $m.Groups['kind'].Value
        Id           = $id
        Procedure    = $procedure
        ReportedLine = $reported
        File         = $file
        Line         = $resolvedLine
        App          = $m.Groups['app'].Value.Trim()
        Publisher    = $m.Groups['pub'].Value.Trim()
        # False for anything outside the workspace (Library Assert, the test runner, Microsoft objects).
        # Stated rather than guessed: a wrong file reference costs more than a missing one.
        Resolved     = ($null -ne $resolvedLine)
    }
}