Modules/businessdev.ALbuild.Core/Public/Test-BcChangeSet.ps1

function Test-BcChangeSet {
    <#
    .SYNOPSIS
        Gates a pull request on what its diff contains - tests for changed feature code, no disabled tests.
 
    .DESCRIPTION
        Static gate that runs on the diff alone, before any container is built, so a pull request that
        cannot pass fails in seconds instead of after a 40-minute build. It compares HEAD against the
        merge base with the target branch and applies the rules given in -Rule:
 
          TestsForChanges Changed feature AL code requires changed test AL code. A change to logic
                           that no test touches is either untested or the test was not updated.
          DisabledTests A pull request must not add or change a disabledTests.json: disabling a test
                           is a decision for the team, not a way to make a red build green.
 
        A file counts as test code when its content declares AL tests ('Subtype = Test' or a [Test]
        attribute) and, failing that, when its path matches -TestPathPattern. Content first, because the
        path heuristic alone both over- and under-reports: a 'Tests' folder may hold library helpers with
        no [Test] at all, and a substring match would take 'LatestEntry.Codeunit.al' for test code and
        wave the change through. The default pattern therefore matches 'test'/'tests' only as a whole
        word in the path.
 
        Returns a verdict object; with -ThrowOnFailure it throws so a pipeline step fails. When the diff
        is empty (nothing to compare against, e.g. a manual run on the target branch itself) the gate
        passes and says so - it never fails for lack of information.
 
    .PARAMETER RepositoryRoot
        The git working tree to inspect. Default: the current location.
 
    .PARAMETER TargetBranch
        Branch the pull request targets, used to compute the merge base. Default: 'dev'.
 
    .PARAMETER BaseRef
        Explicit comparison base (commit, tag or ref). Skips the merge-base resolution against
        -TargetBranch.
 
    .PARAMETER Fetch
        Fetch -TargetBranch from 'origin' first. Needed on a build agent with a shallow or single-branch
        clone, where the target branch is not present locally.
 
    .PARAMETER Rule
        Rules to apply: TestsForChanges, DisabledTests. Default: both.
 
    .PARAMETER TestPathPattern
        Regex marking a path as test code when its content is inconclusive. Default: 'test' or 'tests' as
        a whole word anywhere in the path (so 'test/', 'Test Library/' and 'Poster.Test.al' count,
        'LatestEntry.Codeunit.al' does not).
 
    .PARAMETER ThrowOnFailure
        Throw a terminating error when a rule is violated (for use as a pipeline gate).
 
    .PARAMETER Quiet
        Suppress the gate's own verdict and violation lines (the changed-file list still logs, it is the
        evidence). For callers that report the verdict themselves -- the Azure DevOps task renders it as a
        pipeline issue, as a warning or an error depending on failOnIssue -- so nothing is logged twice.
 
    .EXAMPLE
        Test-BcChangeSet -TargetBranch dev -Fetch -ThrowOnFailure
        The PR gate: fail unless the diff carries test changes and disables no tests.
 
    .EXAMPLE
        Test-BcChangeSet -BaseRef HEAD~1 -Rule TestsForChanges
        Check the last commit only, for the "tests present" rule.
 
    .OUTPUTS
        PSCustomObject verdict: passed, baseRef, changedFiles[], featureFiles[], testFiles[], issues[].
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string] $RepositoryRoot = (Get-Location).Path,
        [string] $TargetBranch = 'dev',
        [string] $BaseRef,
        [switch] $Fetch,
        [ValidateSet('TestsForChanges', 'DisabledTests')]
        [string[]] $Rule = @('TestsForChanges', 'DisabledTests'),
        [string] $TestPathPattern = '(?i)(^|[^A-Za-z0-9])tests?([^A-Za-z0-9]|$)',
        [switch] $ThrowOnFailure,
        [switch] $Quiet
    )

    if (-not (Test-Path -LiteralPath $RepositoryRoot)) { throw "Repository root '$RepositoryRoot' does not exist." }
    $root = (Resolve-Path -LiteralPath $RepositoryRoot).Path

    # ----- comparison base ---------------------------------------------------------------------------
    $base = $BaseRef
    if (-not $base) {
        if ($Fetch) {
            $refSpec = '+refs/heads/{0}:refs/remotes/origin/{0}' -f $TargetBranch
            $fetched = Invoke-BcGit -RepositoryRoot $root -Arguments @('fetch', '--no-tags', 'origin', $refSpec) -AllowFailure
            if (-not $fetched.Success) {
                Write-ALbuildLog -Level Verbose "Could not fetch '$TargetBranch' from origin: $("$($fetched.StdErr)".Trim())"
            }
        }
        foreach ($candidate in @("origin/$TargetBranch", $TargetBranch)) {
            $mb = Invoke-BcGit -RepositoryRoot $root -Arguments @('merge-base', 'HEAD', $candidate) -AllowFailure
            if ($mb.Success -and -not [string]::IsNullOrWhiteSpace($mb.StdOut)) { $base = $mb.StdOut.Trim(); break }
        }
        if (-not $base) {
            Write-ALbuildLog -Level Warning "No merge base with '$TargetBranch' found (shallow clone or unknown branch?) - comparing against 'origin/$TargetBranch' directly."
            $base = "origin/$TargetBranch"
        }
    }
    Write-ALbuildLog "Change-set gate: comparing HEAD against '$base' (target branch '$TargetBranch')."

    # ----- changed files (added/copied/modified/renamed; deletions cannot violate a rule) -------------
    $diff = Invoke-BcGit -RepositoryRoot $root -Arguments @('diff', '--name-only', '--diff-filter=ACMR', $base, 'HEAD') -AllowFailure
    if (-not $diff.Success) {
        $detail = @("$($diff.StdErr)", "$($diff.StdOut)" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1)
        throw "Could not compute the diff against '$base': $("$detail".Trim())"
    }
    $changed = @("$($diff.StdOut)" -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ })

    $issues = [System.Collections.Generic.List[object]]::new()
    $featureFiles = [System.Collections.Generic.List[string]]::new()
    $testFiles = [System.Collections.Generic.List[string]]::new()

    if ($changed.Count -eq 0) {
        if (-not $Quiet) { Write-ALbuildLog -Level Success "Change-set gate PASSED: the diff against '$base' is empty - nothing to gate." }
        return [PSCustomObject]@{
            passed           = $true
            baseRef          = $base
            targetBranch     = $TargetBranch
            rules            = @($Rule)
            changedFileCount = 0
            changedFiles     = @()
            featureFiles     = @()
            testFiles        = @()
            issues           = @()
        }
    }

    Write-ALbuildLog "Changed file(s): $($changed.Count)"
    foreach ($f in $changed) { Write-ALbuildLog " $f" }

    # ----- (1) no disabled tests ---------------------------------------------------------------------
    if ($Rule -contains 'DisabledTests') {
        $disabled = @($changed | Where-Object { [System.IO.Path]::GetFileName($_) -ieq 'disabledTests.json' })
        if ($disabled.Count -gt 0) {
            $issues.Add([PSCustomObject]@{
                    rule    = 'DisabledTests'
                    message = "The change set adds or changes disabledTests.json ($($disabled -join ', ')) - tests must not be disabled to make a build green."
                    files   = @($disabled)
                })
        }
    }

    # ----- (2) feature code changed -> test code changed ---------------------------------------------
    if ($Rule -contains 'TestsForChanges') {
        foreach ($f in @($changed | Where-Object { $_ -like '*.al' })) {
            $full = Join-Path $root $f
            $isTest = $false
            if (Test-Path -LiteralPath $full) {
                $content = Get-Content -LiteralPath $full -Raw -ErrorAction SilentlyContinue
                if ($content -and ($content -match '(?i)Subtype\s*=\s*Test' -or $content -match '(?i)\[Test\b')) { $isTest = $true }
            }
            if (-not $isTest -and $f -match $TestPathPattern) { $isTest = $true }
            if ($isTest) { [void]$testFiles.Add($f) } else { [void]$featureFiles.Add($f) }
        }
        Write-ALbuildLog "Changed AL code: $($featureFiles.Count) feature file(s), $($testFiles.Count) test file(s)."
        if ($featureFiles.Count -gt 0 -and $testFiles.Count -eq 0) {
            $issues.Add([PSCustomObject]@{
                    rule    = 'TestsForChanges'
                    message = "$($featureFiles.Count) feature file(s) changed without any test change - new or changed logic needs tests."
                    files   = @($featureFiles)
                })
        }
    }

    $verdict = [PSCustomObject]@{
        passed           = ($issues.Count -eq 0)
        baseRef          = $base
        targetBranch     = $TargetBranch
        rules            = @($Rule)
        changedFileCount = $changed.Count
        changedFiles     = @($changed)
        featureFiles     = @($featureFiles)
        testFiles        = @($testFiles)
        issues           = @($issues)
    }

    if ($verdict.passed) {
        if (-not $Quiet) { Write-ALbuildLog -Level Success "Change-set gate PASSED: $($changed.Count) changed file(s), no rule violated." }
    }
    else {
        if (-not $Quiet) {
            foreach ($i in $issues) {
                Write-ALbuildLog -Level Warning "($($i.rule)) $($i.message)"
                foreach ($f in $i.files) { Write-ALbuildLog " $f" }
            }
        }
        if ($ThrowOnFailure) { throw "Change-set gate failed: $(@($issues | ForEach-Object { $_.rule }) -join ', ')." }
    }

    return $verdict
}