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 } |