Modules/businessdev.ALbuild.Apps/Private/Get-BcAlObjectIndex.ps1

function Get-BcAlObjectIndex {
    <#
    .SYNOPSIS
        Indexes every AL object in a workspace with the file and line of each of its procedures.
    .DESCRIPTION
        Internal helper for Resolve-BcTestFailureSource. Keyed by object name (lower-cased) AND by
        "<kind>:<id>", because a callstack frame gives both and either one may be the reliable match:
        names collide across apps, ids do not, but an id is missing from some frames.
 
        Deliberately a line scan rather than a parse. Everything needed - object header, procedure
        declarations and their line numbers - is on a single line each, and a scan cannot fail on syntax
        the parser does not yet cover. A frame that cannot be matched is reported unresolved, so the cost
        of missing something here is a missing answer, never a wrong one.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)] [string] $WorkspaceRoot
    )

    $index = @{}
    $objectHeader = '^\s*(table|page|codeunit|report|query|xmlport|enum|interface|pageextension|tableextension|enumextension|reportextension|permissionset)\s+(\d+)\s+"?([^"\r\n{]+?)"?\s*(?:extends|\{|$)'
    $procHeader = '^\s*(?:local\s+|internal\s+|protected\s+)?procedure\s+([A-Za-z_]\w*)\s*\('
    $triggerHeader = '^\s*trigger\s+([A-Za-z_]\w*)\s*\('

    $files = Get-ChildItem -LiteralPath $WorkspaceRoot -Filter '*.al' -File -Recurse -ErrorAction SilentlyContinue |
        Where-Object { $_.FullName -notmatch '[\\/](\.alpackages|\.snapshots|\.altestrunner)[\\/]' }

    foreach ($file in $files) {
        $lines = Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue
        $current = $null

        for ($i = 0; $i -lt $lines.Count; $i++) {
            $line = $lines[$i]

            $m = [regex]::Match($line, $objectHeader, 'IgnoreCase')
            if ($m.Success) {
                $current = [PSCustomObject]@{
                    Kind       = $m.Groups[1].Value
                    Id         = [int] $m.Groups[2].Value
                    Name       = $m.Groups[3].Value.Trim()
                    File       = $file.FullName
                    Procedures = @{}
                }
                $index[$current.Name.ToLowerInvariant()] = $current
                $index["$($current.Kind.ToLowerInvariant()):$($current.Id)"] = $current
                continue
            }

            if ($null -eq $current) { continue }

            $p = [regex]::Match($line, $procHeader, 'IgnoreCase')
            if (-not $p.Success) { $p = [regex]::Match($line, $triggerHeader, 'IgnoreCase') }
            if ($p.Success) {
                # 1-based file line of the declaration. The frame's own line number is counted FROM here.
                $current.Procedures[$p.Groups[1].Value.ToLowerInvariant()] = $i + 1
            }
        }
    }

    return $index
}