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

function Get-BcAlProcedure {
    <#
    .SYNOPSIS
        Enumerates the procedures in AL sources and returns their statement lines.
    .DESCRIPTION
        Shared AL scan behind Get-BcAlTestProcedure (and through it Get-BcTestQuality and
        Test-BcTestAssertion), so everything that reasons about AL bodies sees the same set of
        procedures and the same statements and can only differ in the verdict it draws. For every
        procedure it returns the declaring object, the procedure name, whether it carries a [Test]
        attribute, its location, the ALbuild directives that apply to it, and the executable statement
        lines of its body (comments stripped, block keywords and the surrounding begin/end removed).
 
        The body runs from the first 'begin' after the procedure header to its matching 'end', so nested
        begin/end blocks stay inside the procedure and the next one is not swallowed.
 
        Non-test procedures are returned as well, because a test may delegate its assertions to a helper
        (a matrix/factory suite calls one entry point per case); Test-BcTestAssertion resolves those calls
        against this index instead of reporting the test as assertion-free.
    .PARAMETER WorkspaceRoot
        AL source root to scan; tooling folders (.alpackages, output, .git, node_modules, ...) are skipped.
    .PARAMETER Path
        A single .al file to scan instead of a whole workspace.
    .PARAMETER TestsOnly
        Return only procedures carrying a [Test] attribute. The whole file is still parsed (a test is found
        wherever it stands), the other bodies are simply not kept - for callers that never resolve helpers,
        so a large repository is not held in memory twice.
    .OUTPUTS
        PSCustomObject per procedure: Codeunit, Name, TestName, IsTest, FilePath, Line, Statements[], Directives[].
    #>

    [CmdletBinding(DefaultParameterSetName = 'Workspace')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Workspace')] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot,
        [Parameter(Mandatory, ParameterSetName = 'File')] [ValidateNotNullOrEmpty()] [string] $Path,
        [switch] $TestsOnly
    )

    $files = if ($PSCmdlet.ParameterSetName -eq 'File') { , (Get-Item -LiteralPath $Path) }
    else {
        $root = (Resolve-Path -LiteralPath $WorkspaceRoot).Path
        Get-ChildItem -LiteralPath $root -Filter '*.al' -File -Recurse -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName.Substring($root.Length) -notmatch '[\\/](\.alpackages|\.altemplates|\.snapshots|\.output|output|\.git|node_modules)[\\/]' }
    }

    # An ALbuild directive is a comment, so it survives the AL compiler: '// albuild:<name> <argument>'.
    $directiveRx = [regex]::new('(?i)^//\s*albuild:\s*(?<d>\S.*?)\s*$')

    # Blanks out everything the AL compiler reads as data rather than as code, so the begin/end count
    # below cannot fire inside it. String literals first, then quoted identifiers: once the literals are
    # gone every remaining double quote delimits an identifier, whereas the other order would mis-read a
    # double quote that merely sat inside a string.
    #
    # Identifiers are what makes this necessary. A field named "bdev.BNK End-to-End Reference" carries
    # the token `end` TWICE, each time bounded by a space or a hyphen, so the lookarounds accept it. The
    # line then closed two blocks that were never opened, the body was judged to have ended at its own
    # arrangement, and every statement after it was dropped - which for a test means its assertions were
    # dropped and the assertion gate reported a test that does assert.
    #
    # Only the keyword counting reads the masked form. The statements kept below are the original text,
    # because that is what the assertion patterns are matched against.
    $maskLiterals = {
        param([string] $text)
        $masked = [regex]::Replace($text, "'(?:[^']|'')*'", "''")
        [regex]::Replace($masked, '"(?:[^"]|"")*"', '""')
    }

    $result = [System.Collections.Generic.List[object]]::new()
    foreach ($file in $files) {
        $lines = @(Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue)
        if ($lines.Count -eq 0) { continue }

        $cuName = ''
        $m = [regex]::Match(($lines -join "`n"), '(?im)^\s*codeunit\s+\d+\s+("(?<q>[^"]+)"|(?<b>[A-Za-z0-9_]+))')
        if ($m.Success) { $cuName = if ($m.Groups['q'].Success) { $m.Groups['q'].Value } else { $m.Groups['b'].Value } }

        # File-scope directives apply to every procedure in the file - one .al file declares one object,
        # so this is the object-level scope. The scope ends at the first attribute or procedure header: a
        # directive written with the FIRST test belongs to that test, not to the whole file.
        $fileDirectives = [System.Collections.Generic.List[string]]::new()
        $pending = [System.Collections.Generic.List[string]]::new()
        $inFileScope = $true

        $isTestAttr = $false
        for ($i = 0; $i -lt $lines.Count; $i++) {
            $t = $lines[$i].Trim()

            $dm = $directiveRx.Match($t)
            if ($dm.Success) {
                if ($inFileScope) { [void]$fileDirectives.Add($dm.Groups['d'].Value) }
                [void]$pending.Add($dm.Groups['d'].Value)
                continue
            }

            if ($t -match '^\[Test\b') { $isTestAttr = $true; $inFileScope = $false; continue }
            $pm = [regex]::Match($t, '(?i)^(local\s+|internal\s+|protected\s+)*procedure\s+(?<n>[A-Za-z0-9_]+)')
            # Any other attribute ([HandlerFunctions], [TransactionModel], ...) keeps the pending [Test].
            if (-not $pm.Success) {
                if ($t -ne '' -and $t -notmatch '^\[') {
                    $isTestAttr = $false
                    # A plain comment keeps the pending directives: a directive is normally written above
                    # the test together with the sentence explaining it.
                    if ($t -notmatch '^//') { $pending.Clear() }
                }
                continue
            }
            $isTest = $isTestAttr
            $isTestAttr = $false

            $bodyDirectives = [System.Collections.Generic.List[string]]::new()
            $statements = [System.Collections.Generic.List[string]]::new()
            $depth = 0; $started = $false
            for ($j = $i + 1; $j -lt $lines.Count; $j++) {
                $bd = $directiveRx.Match($lines[$j].Trim())
                if ($bd.Success) { [void]$bodyDirectives.Add($bd.Groups['d'].Value) }
                $code = ($lines[$j] -replace '//.*$', '').Trim()
                if ($code -eq '') { continue }
                $lower = (& $maskLiterals $code).ToLowerInvariant()
                $opens = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])begin(?![A-Za-z0-9_])')).Count
                $closes = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])end(?![A-Za-z0-9_])')).Count
                if (-not $started) { if ($opens -gt 0) { $started = $true; $depth = 0 } else { continue } }
                $isKeyword = $lower -match '^(begin|end|end;|else|do|then|var)$'
                if (-not $isKeyword -and $lower -notmatch '^[{}();]+$' -and $lower -notmatch '^(begin|end)\b') {
                    [void]$statements.Add($code)
                }
                $depth += $opens - $closes
                if ($depth -le 0) { break }
            }

            $inFileScope = $false
            $directives = @(@($fileDirectives) + @($pending) + @($bodyDirectives) | Select-Object -Unique)
            $pending.Clear()

            if ($TestsOnly -and -not $isTest) { continue }
            $result.Add([PSCustomObject]@{
                    Codeunit   = $cuName
                    Name       = $pm.Groups['n'].Value
                    TestName   = $pm.Groups['n'].Value
                    IsTest     = $isTest
                    FilePath   = $file.FullName
                    Line       = $i + 1
                    Statements = @($statements)
                    Directives = $directives
                })
        }
    }

    return @($result)
}