Modules/businessdev.ALbuild.Apps/Public/Test-BcObjectIdConsistency.ps1

function Test-BcObjectIdConsistency {
    <#
    .SYNOPSIS
        Checks the object IDs a workspace uses: duplicates across its apps, and - with -Ninja - whether
        AL Object ID Ninja would hand out an ID the repository already uses.
 
    .DESCRIPTION
        Ninja reserves IDs in its own backend. When the repository and Ninja drift apart - an object created
        without Ninja, a branch merged with IDs Ninja never saw - Ninja hands out an ID that is already in use,
        and the clash surfaces as a compile error on someone else's branch (Banking #67152).
 
        * Two apps of the workspace that declare the same object type and ID collide in one tenant; the
          compiler only sees one app at a time.
        * With -Ninja, the backend is asked for the next ID per object type WITHOUT committing it (the preview
          the Ninja extension shows) - the same request the Ninja client sends, for each app's ranges
          (.objidconfig 'objectRanges' / 'idRanges', else app.json 'idRanges') and pool (.objidconfig
          'appPoolId', else SHA-256 of the app id). An answer that is already used in the repository means
          Ninja's reservations lag behind it.
 
        Everything here only warns: an ID check must never fail a build.
 
    .PARAMETER WorkspaceRoot
        The repository root; every AL project below it is checked (albuild.json 'excludeProjects' honoured).
 
    .PARAMETER Ninja
        Also ask the Ninja backend. This sends the app's id hash, name, publisher and version to it, as the
        Ninja extension does.
 
    .PARAMETER NinjaBackendUrl
        The Ninja backend. Default https://backend.alid.ninja.
 
    .OUTPUTS
        PSCustomObject: Passed, ObjectCount, NinjaChecked, Findings[] { Kind, ObjectType, Id, App, Files[], Message }.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot,
        [switch] $Ninja,
        [string] $NinjaBackendUrl = 'https://backend.alid.ninja'
    )

    $root = (Resolve-Path -LiteralPath $WorkspaceRoot).Path
    $exclude = @()
    try {
        $config = Get-ALbuildProjectConfig -AppFolder $root -WorkspaceRoot $root
        if ($config.Has('ExcludeProjects')) { $exclude = @($config.ExcludeProjects) }
    }
    catch { Write-ALbuildLog -Level Verbose "No albuild.json at '$root': $($_.Exception.Message)" }
    $projects = @(Get-BcProjectBuildOrder -Path $root -ExcludeProjects $exclude)

    $declRx = [regex]::new('(?im)^\s*(codeunit|table|page|report|xmlport|query|enum|pageextension|tableextension|reportextension|enumextension|permissionset|permissionsetextension)\s+(\d+)\s')
    $byKey = @{}         # 'type:id' -> list of { App, File }
    $byApp = @{}         # app folder -> @{ type -> HashSet[int] }
    $objectCount = 0
    foreach ($p in $projects) {
        $used = @{}
        foreach ($file in Get-ChildItem -LiteralPath $p.Folder -Filter '*.al' -File -Recurse -ErrorAction SilentlyContinue |
                Where-Object { $_.FullName.Substring($p.Folder.Length) -notmatch '[\\/](\.alpackages|\.output|output|\.snapshots)[\\/]' }) {
            $text = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
            if (-not $text) { continue }
            $m = $declRx.Match($text)
            if (-not $m.Success) { continue }
            $type = $m.Groups[1].Value.ToLowerInvariant()
            $id = [int]$m.Groups[2].Value
            $objectCount++
            if (-not $used.ContainsKey($type)) { $used[$type] = [System.Collections.Generic.HashSet[int]]::new() }
            [void]$used[$type].Add($id)
            $key = "$($type):$id"
            if (-not $byKey.ContainsKey($key)) { $byKey[$key] = [System.Collections.Generic.List[object]]::new() }
            $rel = if ($file.FullName.StartsWith($p.Folder, [System.StringComparison]::OrdinalIgnoreCase)) {
                (Split-Path -Path $p.Folder -Leaf) + '/' + $file.FullName.Substring($p.Folder.Length).TrimStart('\', '/').Replace('\', '/')
            }
            else { $file.FullName }
            $byKey[$key].Add([PSCustomObject]@{ App = $p.Name; File = $rel })
        }
        $byApp[$p.Folder] = $used
    }

    $findings = [System.Collections.Generic.List[object]]::new()
    foreach ($key in ($byKey.Keys | Sort-Object)) {
        $apps = @($byKey[$key] | ForEach-Object App | Select-Object -Unique)
        if ($apps.Count -lt 2) { continue }
        $type, $id = $key -split ':'
        $findings.Add([PSCustomObject]@{
                Kind = 'Duplicate'; ObjectType = $type; Id = [int]$id; App = ($apps -join ', ')
                Files = @($byKey[$key] | ForEach-Object File)
                Message = "$type $id is declared by $($apps.Count) apps ($($apps -join ', ')); they cannot be installed side by side."
            })
    }

    $ninjaGaps = 0
    if ($Ninja) {
        # The backend refuses a request without the git user's e-mail (GIT_EMAIL_REQUIRED), as the Ninja
        # client sends it; read the same values it does.
        $gitName = "$(& git -C $root config user.name 2>$null)".Trim()
        $gitEmail = "$(& git -C $root config user.email 2>$null)".Trim().ToLowerInvariant()
        if (-not $gitEmail) {
            Write-ALbuildLog -Level Warning "Object ID Ninja needs the git user's e-mail ('git config user.email'); none is set in '$root', so Ninja was not asked."
            $ninjaGaps++
        }
    }
    if ($Ninja -and $gitEmail) {
        foreach ($p in $projects) {
            $manifest = Get-Content -LiteralPath (Join-Path $p.Folder 'app.json') -Raw | ConvertFrom-Json
            $objIdConfig = $null
            $cfgPath = Join-Path $p.Folder '.objidconfig'
            if (Test-Path -LiteralPath $cfgPath) {
                # .objidconfig allows comments; strip line comments before parsing.
                $objIdConfig = ((Get-Content -LiteralPath $cfgPath) -replace '^\s*//.*$', '') -join "`n" | ConvertFrom-Json
            }
            $cfgValue = { param($n) if ($objIdConfig -and ($objIdConfig.PSObject.Properties.Name -contains $n)) { $objIdConfig.$n } else { $null } }
            $poolId = & $cfgValue 'appPoolId'
            if (-not $poolId) {
                $sha = [System.Security.Cryptography.SHA256]::Create()
                try { $poolId = -join ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("$($manifest.id)")) | ForEach-Object { $_.ToString('x2') }) }
                finally { $sha.Dispose() }
            }
            $headers = @{
                'Ninja-App-Id'         = "$($manifest.id)"
                'Ninja-Header-Payload' = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((@{
                                gitUserName = $gitName; gitUserEmail = $gitEmail
                                appPublisher = "$($manifest.publisher)"; appName = "$($manifest.name)"; appVersion = "$($manifest.version)"
                                ninjaVersion = '3.1.0'; source = 'albuild'
                            } | ConvertTo-Json -Compress)))
            }
            $authKey = & $cfgValue 'authKey'
            if ($authKey) { $headers['Ninja-Auth-Key'] = "$authKey" }

            foreach ($type in ($byApp[$p.Folder].Keys | Sort-Object)) {
                $objectRanges = & $cfgValue 'objectRanges'
                $ranges = if ($objectRanges -and ($objectRanges.PSObject.Properties.Name -contains $type)) { @($objectRanges.$type) }
                elseif (& $cfgValue 'idRanges') { @(& $cfgValue 'idRanges') }
                else { @($manifest.idRanges) }
                $ranges = @($ranges | Where-Object { $_ } | ForEach-Object { [PSCustomObject]@{ from = [int]$_.from; to = [int]$_.to } })
                if ($ranges.Count -eq 0) { continue }
                $body = @{ appId = $poolId; type = $type; ranges = $ranges; commit = $false; perRange = $true } | ConvertTo-Json -Depth 5 -Compress
                try {
                    $answer = Invoke-RestMethod -Method Post -Uri "$($NinjaBackendUrl.TrimEnd('/'))/api/v3/getNext/$poolId" -Headers $headers `
                        -ContentType 'application/json' -Body $body -TimeoutSec 20 -ErrorAction Stop
                }
                catch {
                    Write-ALbuildLog -Level Warning "Object ID Ninja could not be asked about $($p.Name) ($type): $($_.Exception.Message). The ID check against Ninja is incomplete."
                    $ninjaGaps++
                    continue
                }
                if (-not $answer -or -not ($answer.PSObject.Properties.Name -contains 'id')) { continue }
                foreach ($next in @($answer.id)) {
                    if ($null -eq $next -or -not $byApp[$p.Folder][$type].Contains([int]$next)) { continue }
                    $owner = @($byKey["$($type):$next"] | Where-Object App -EQ $p.Name | ForEach-Object File)
                    $findings.Add([PSCustomObject]@{
                            Kind = 'NinjaStale'; ObjectType = $type; Id = [int]$next; App = $p.Name; Files = $owner
                            Message = "Object ID Ninja would hand out $type $next for $($p.Name), but $($owner -join ', ') already uses it. Ninja's reservations lag behind the repository; synchronise them ('Ninja: Synchronize Object IDs with the Azure back end')."
                        })
                }
            }
        }
    }

    foreach ($f in $findings) { Write-ALbuildLog -Level Warning $f.Message }
    if ($findings.Count -eq 0) {
        $ninjaText = if (-not $Ninja) { '' } elseif ($ninjaGaps -gt 0) { "; Ninja could not be asked $ninjaGaps time(s)" } else { ', Ninja in step with the repository' }
        Write-ALbuildLog -Level Success ("Object IDs: {0} object(s) in {1} app(s), no conflict{2}." -f $objectCount, $projects.Count, $ninjaText)
    }
    return [PSCustomObject]@{ Passed = ($findings.Count -eq 0); ObjectCount = $objectCount; NinjaChecked = [bool]($Ninja -and $ninjaGaps -eq 0); Findings = @($findings) }
}