Modules/businessdev.ALbuild.Apps/Private/Get-BcAlExecutableLines.ps1
|
function Get-BcAlExecutableLines { <# .SYNOPSIS Identifies the executable statement lines of an AL source file -- the honest denominator for code coverage. .DESCRIPTION Business Central's coverage export only ever lists Covered/PartiallyCovered lines (verified against the platform: "AL Code Coverage Mgt.".SaveCoverageResults filters to those statuses), so the raw CSV alone always reads ~100%. To compute an honest line-coverage percentage ALbuild derives the *total* executable lines from the AL source instead. BC counts a source line as a "Code" line only when it carries an executable statement. This parser mirrors that: it walks each procedure/trigger body (the `begin`..matching `end` after a `procedure`/`trigger` header) and returns the 1-based source line numbers of statement lines, excluding object/property declarations, procedure & trigger signatures, `var` sections and their variable declarations, lone `begin`/`end`/`else`/`do`/`then` block keywords, comments and blank lines. Calibrated against a controlled BC28.2 container (a codeunit whose only statements sit on specific lines reported exactly by BC). A line the parser counts but BC can never report is pure loss: Convert-BcCodeCoverage unions the source lines with the covered ones, so a phantom line only ever raises the denominator. Four rules exist for exactly that reason: * String literals are MASKED before anything else is examined. AL XPath expressions routinely contain '/*' and never '*/', so scanning the raw line made a single `SelectSingleNode('//*[...]')` open a block comment that never closed - silently hiding the rest of the file from the measurement. * Preprocessor directives (`#region`, `#pragma`, `#if`, ...) are not statements and are not counted. * A statement spanning several lines counts ONCE, on its first line: BC attributes the statement to where it starts, so the argument lines can never be reported as covered. * With -Symbols, lines inside a non-compiled `#if` branch are skipped - the compiler never emitted them, so they are dead code in this build. * `repeat` and case labels are branch targets, not statements. BC emits no instrumentation point for either, so counting them can only ever depress the percentage. Both were established against real coverage data rather than assumed - see the comments at the exclusion itself for the measurements and for why each rule is deliberately narrow. * The body of a method annotated `[NonDebuggable]` is skipped: the attribute removes the method from the debugger, and BC's coverage rides on that instrumentation, so those lines run without ever being reported. Whether the attribute applies depends on -Symbols, because it is usually wrapped in `#if not DEBUG`. Note that `until`, `case ... of`, `if ... then`, `while`, `for` and `foreach` ARE reported by BC and are therefore counted: the loop condition is evaluated code, the loop header is not. One AL object per file is assumed (BC/AL convention), matching Get-BcAlObjectMap. .PARAMETER Path The .al file to analyse. .PARAMETER Symbols The preprocessor symbols the file was compiled with -- the same set that reaches alc.exe as `/define:`, normally read from the app's app.json (see Resolve-BcAlAppContext). Understood forms: `#if SYMBOL`, `#if not SYMBOL`, `#else`, `#endif`. An expression this does not understand is treated as ACTIVE, so an unusual condition can only over-count (today's behaviour) and never hide real code. Omitted (the default) means no branch is skipped at all -- behaviour identical to before this parameter existed, so existing callers are unaffected. .OUTPUTS int[] -- sorted, distinct 1-based source line numbers that carry an executable statement. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Returns a set of executable lines; the plural noun is intentional and clearer.')] [CmdletBinding()] [OutputType([int[]])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [string[]] $Symbols = @() ) $lines = @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue) if ($lines.Count -eq 0) { return @() } # Blank out the CONTENT of every string literal, keeping the quotes. AL escapes a single quote by # doubling it. Everything downstream - comment detection, keyword tests, bracket depth - runs on the # masked text, so punctuation inside a literal can never be mistaken for code. $maskLiterals = { param([string] $text) [regex]::Replace($text, "'(?:[^']|'')*'", "''") } $useSymbols = @($Symbols).Count -gt 0 # One frame per open #if. Active = lines here are compiled; Taken = some branch of this #if already # matched, so a following #else must not activate. $branches = [System.Collections.Generic.List[hashtable]]::new() $branchActive = { if ($branches.Count -eq 0) { $true } else { [bool] $branches[$branches.Count - 1].Active } } $evalCondition = { param([string] $expression) $e = "$expression".Trim() if ($e -match '^not\s+([A-Za-z_][A-Za-z0-9_]*)$') { return -not ($Symbols -contains $Matches[1]) } if ($e -match '^([A-Za-z_][A-Za-z0-9_]*)$') { return ($Symbols -contains $Matches[1]) } return $null # not understood -> caller keeps the branch active } $exec = [System.Collections.Generic.List[int]]::new() $inBody = $false # inside a procedure/trigger body (after its opening begin) $depth = 0 # begin/end nesting within the current body (0 = not in a body) $pendingHeader = $false # saw a procedure/trigger header, waiting for its opening begin $inBlockComment = $false $parenDepth = 0 # open '(' carried across lines -> continuation lines of one statement $pendingNonDebuggable = $false # saw an ACTIVE [NonDebuggable], waiting for the header it belongs to $skipBody = $false # inside the body of a [NonDebuggable] method: runs, but is never reported for ($i = 0; $i -lt $lines.Count; $i++) { # Mask literals FIRST - before comment detection, which is what defect D1 got wrong. $scan = (& $maskLiterals $lines[$i]).Trim() # Strip block comments /* ... */ (whole-line handling is enough for AL statement detection). if ($inBlockComment) { if ($scan -match '\*/') { $inBlockComment = $false; $scan = ($scan -replace '^.*?\*/', '').Trim() } else { continue } } if ($scan -match '/\*' -and $scan -notmatch '/\*.*\*/') { $inBlockComment = $true; $scan = ($scan -replace '/\*.*$', '').Trim() } if ($scan -eq '') { continue } if ($scan.StartsWith('//')) { continue } # Strip trailing line comment for keyword tests. $code = ($scan -replace '//.*$', '').Trim() if ($code -eq '') { continue } $lower = $code.ToLowerInvariant() # Preprocessor directives: never statements (they emit no code), but they steer branch tracking. if ($lower.StartsWith('#')) { if ($lower -match '^#\s*if\b') { $parentActive = & $branchActive $condition = if ($code -match '^#\s*if\s+(.+)$') { $Matches[1] } else { '' } $value = & $evalCondition $condition $isActive = if ($null -eq $value) { $true } else { [bool] $value } # unknown condition -> keep counting $branches.Add(@{ Active = ($parentActive -and $isActive); Taken = ($parentActive -and $isActive); ParentActive = $parentActive }) } elseif ($lower -match '^#\s*else\b') { if ($branches.Count -gt 0) { $frame = $branches[$branches.Count - 1] $frame.Active = ($frame.ParentActive -and -not $frame.Taken) $frame.Taken = $true } } elseif ($lower -match '^#\s*endif\b') { if ($branches.Count -gt 0) { $branches.RemoveAt($branches.Count - 1) } } continue } # Only when symbols are known: skip what this build does not compile. if ($useSymbols -and -not (& $branchActive)) { continue } # [NonDebuggable] takes the annotated METHOD away from the debugger, and BC's coverage rides on that # same instrumentation - so such a body executes but is never reported. Counting it makes a share of # the app permanently unreachable no matter how many tests exist. In businessdev.Api.Banking the # attribute appears 131 times. # # Deliberately AFTER the branch check above: the attribute is normally wrapped in '#if not DEBUG', # so whether it applies depends on the symbols this build compiled with. Built WITH DEBUG defined, # the branch is inactive, the attribute never seen, and the method counts again - which is right, # because then it really is debuggable. # # Only recognised OUTSIDE a header and a body. The same attribute may annotate a VARIABLE inside a # 'var' section, and that says nothing about the enclosing method's instrumentation. if (-not $inBody -and -not $pendingHeader -and $lower -match '^\[nondebuggable\]') { $pendingNonDebuggable = $true continue } # A procedure/trigger header opens a (possibly var-prefixed) body. if ($lower -match '^(local\s+|internal\s+|protected\s+)*(procedure|trigger)\b') { $pendingHeader = $true $skipBody = $pendingNonDebuggable $pendingNonDebuggable = $false $parenDepth = 0 # a signature's parentheses never continue into the body continue } # The opening `begin` of a procedure/trigger body. if (-not $inBody -and $pendingHeader -and $lower -match '^begin\b') { $inBody = $true; $depth = 1; $pendingHeader = $false continue } if (-not $inBody) { # Outside any body: var sections, declarations, object/property lines -- never executable. $parenDepth = 0 continue } # Inside a procedure/trigger body. Track begin/end nesting and skip pure block keywords. # Count begins/ends on the line to maintain depth (a line may open and close blocks). # # `case` opens a block too, and it is closed by `end` just like `begin` is. Counting only `begin` # as an opener made the `end;` of a case block cancel the procedure's own `begin`: the parser # decided the body had finished and dropped EVERY remaining statement of that procedure from the # denominator. That silently inflated coverage, because the lines BC did report were still unioned # in while the unreported ones next to them had vanished from the total. $opens = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])begin(?![A-Za-z0-9_])')).Count # The lookbehind also rejects a member access ('.Case') or a quoted identifier, which are names, # not the keyword. AL has no `case` without a closing `end`, so this cannot over-count even when # the `of` sits on a following line. $opens += ([regex]::Matches($lower, '(?<![A-Za-z0-9_."])case(?![A-Za-z0-9_])')).Count $closes = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])end(?![A-Za-z0-9_])')).Count # A statement that started on an earlier line continues here; BC reports it at its first line. $startsStatement = ($parenDepth -eq 0) # `repeat` joins the lone block keywords: BC emits no instrumentation point for it. Measured against # a real run (businessdev.EInvoice, 19 coverage files): of 35 `repeat` lines NOT ONE was ever # reported, while 27 of them had their matching `until` reported - so those loops demonstrably ran # and the `repeat` still never appeared. No counterexample anywhere in the corpus. # Deliberately only a line that is EXACTLY `repeat`: AL allows `repeat <statement>; until ...` on one # line, and that line does carry a statement BC reports. $isPureKeyword = $lower -match '^(begin|end|end;|else|do|then|var|repeat)$' -or $lower -match '^(end\s+else\b.*)$' # Case labels (`Step::DocumentLine:`, `Database::"Sales Header":`, `1, 2:`) are branch targets, not # statements, and BC never reports them either: of 55 labels, 41 had the statement they guard # reported while the label itself stayed absent - again no counterexample. Syntactically a label is # the only thing in an AL body that ends in a single colon; `::` is a qualifier, and `:=` is an # assignment, so both are excluded. A label written on the same line as its statement # (`1: Total := 2;`) ends in `;`, keeps its statement and is therefore still counted. $isCaseLabel = $code -match ':$' -and $code -notmatch '::$' -and $code -notmatch ':=' # A statement line: inside the body, not a lone block keyword. `end else begin`-style lines are control flow. # $skipBody: a [NonDebuggable] body. Depth is still tracked below so the body's end is found; only # the counting is suppressed. if ($startsStatement -and -not $isPureKeyword -and -not $isCaseLabel -and -not $skipBody) { # Lines that are only opening/closing braces or block punctuation aren't statements. if ($lower -notmatch '^[{}();]+$') { $exec.Add($i + 1) | Out-Null } } $parenDepth += ([regex]::Matches($code, '\(')).Count - ([regex]::Matches($code, '\)')).Count if ($parenDepth -lt 0) { $parenDepth = 0 } $depth += $opens - $closes if ($depth -le 0) { $inBody = $false; $depth = 0; $parenDepth = 0; $skipBody = $false } } return @($exec | Sort-Object -Unique) } |