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

function Get-BcAlObjectMap {
    <#
    .SYNOPSIS
        Maps AL object identity (type + id) to its source file by scanning a workspace's .al files.
 
    .DESCRIPTION
        Reads the first object declaration in each .al file (e.g. `codeunit 50100 "My Codeunit"`) and
        returns a hashtable keyed "<Type>:<Id>" -> { Type; Id; Name; File }. Used to attach BC code
        coverage rows (which carry only object type + id) to source files. Skips symbol/output/tooling
        folders (relative to the root), like Get-BcProjectBuildOrder.
 
        Objects belonging to a TEST app are skipped. The coverage gate exists to show how much PRODUCT
        code is covered, and test code is covered by construction - it runs when the test runs. Measuring
        it mixes a near-100% block into the number and rewards writing more test code over testing more
        deeply. In one real repository this was 42% of the denominator and 8.7 percentage points of the
        reported figure - enough to decide a 80% gate on its own. The app an object belongs to is
        resolved from the nearest app.json (Resolve-BcAlAppContext), so this works for every repository
        without any pipeline change.
 
        Projects named in -ExcludeProjects are skipped as well. A repository that excludes an app from the
        build - a demo or sample app, a migration helper - excludes it for a reason: it is not shipped and
        no test drives it. Measuring it anyway adds a block that is uncovered by construction. In
        businessdev.Api.Banking the 'demo' app is 21 objects and 1226 executable lines, all of them dead
        weight in the denominator.
 
    .PARAMETER WorkspaceRoot
        AL source root to scan.
 
    .PARAMETER ExcludeProjects
        Project folder LEAF names to skip, the same names and the same meaning as
        Get-BcProjectBuildOrder -ExcludeProjects and the repo-root albuild.json 'excludeProjects'. Matched
        against the leaf of the app folder that owns each file, so it also works for apps nested below the
        workspace root.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot,
        [string[]] $ExcludeProjects = @()
    )

    $root = (Resolve-Path -LiteralPath $WorkspaceRoot).Path
    $map = @{}
    # Case-insensitive: folder names on Windows are, and a mismatch here would silently measure an app the
    # repository asked to leave out.
    $excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    # Plain foreach, no '@()': an unbound [string[]] is a typed null and wrapping it collapses on Windows
    # PowerShell 5.1, which is the edition the DevOps tasks run on.
    foreach ($name in $ExcludeProjects) { if ("$name".Trim()) { [void] $excluded.Add("$name".Trim()) } }
    # First object declaration: <type> <id> <name> ; name may be quoted or bare.
    $rx = [regex]::new(
        '(?im)^\s*(codeunit|table|page|report|xmlport|query|enum|controladdin|pageextension|tableextension|reportextension|enumextension|permissionset|permissionsetextension|profile|interface|entitlement|dotnet)\s+(\d+)\s+("(?<q>[^"]+)"|(?<b>[A-Za-z0-9_]+))')

    foreach ($file in Get-ChildItem -LiteralPath $root -Filter '*.al' -File -Recurse -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName.Substring($root.Length) -notmatch '[\\/](\.alpackages|\.altemplates|\.snapshots|\.output|output|\.git|\.claude|node_modules)[\\/]' }) {
        # Test apps never enter the measurement (see the description). Checked before reading the file:
        # the app context is cached per app folder, so this costs one lookup per app, not per file.
        $context = Resolve-BcAlAppContext -Path $file.FullName
        if ($context.IsTestApp) { continue }
        # An excluded project is not built and not published, so nothing can ever cover it.
        if ($excluded.Count -gt 0 -and $context.AppFolder) {
            if ($excluded.Contains((Split-Path -Path $context.AppFolder -Leaf))) { continue }
        }

        $text = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
        if (-not $text) { continue }
        $m = $rx.Match($text)
        if (-not $m.Success) { continue }
        $type = (Get-Culture).TextInfo.ToTitleCase($m.Groups[1].Value.ToLowerInvariant())
        $id = [int]$m.Groups[2].Value
        $name = if ($m.Groups['q'].Success) { $m.Groups['q'].Value } else { $m.Groups['b'].Value }
        $map["$type`:$id"] = [PSCustomObject]@{ Type = $type; Id = $id; Name = $name; File = $file.FullName }
    }
    return $map
}