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

function Get-BcAlToolAssemblyFolder {
    <#
    .SYNOPSIS
        Returns the folder of the assemblies an AL Tool executable actually runs.
 
    .DESCRIPTION
        Internal helper. The AL Tool package ships one build per target framework - AL Tool 18 carries
        tools/net8.0/any AND tools/net10.0/any, each with its own copy of CodeCop, UICop, AppSourceCop
        and PerTenantExtensionCop. 'dotnet tool install' picks ONE of them for the 'al' launcher, and
        which one depends on the SDK doing the install (a net8 SDK takes net8.0, a net10 SDK net10.0).
 
        Analyzers have to come from that same folder. A net10 analyzer loaded into a net8 compiler fails
        on 'System.Collections.Immutable, Version=10.0.0.0' for every single rule, the compiler reports
        that as a warning (AL1003) and compiles on without any analyzer - which is what every BC 29
        build did while a recursive search of the package store handed out tools/net10.0 simply
        because it sorts before tools/net8.0.
 
        The launcher is a .NET apphost: it carries the relative path of the assembly it starts
        ('.store/<package>/<version>/.../tools/<tfm>/any/altool.dll') as a UTF-8 string, on Windows and
        on Linux alike. That path is read here rather than inferred from the installed SDKs or
        runtimes, because it is the one statement that cannot disagree with what runs.
 
        A compiler that is not a launcher (a plain alc.exe next to its own analyzers, as in the AL
        Language extension) is answered with its own folder when the analyzers are there.
 
    .PARAMETER CompilerPath
        Path of the AL Tool executable ('al.exe', 'al', 'alc.exe', ...).
 
    .OUTPUTS
        System.String: the folder, or $null when it cannot be determined.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string] $CompilerPath
    )

    if (-not (Test-Path -LiteralPath $CompilerPath -PathType Leaf)) { return $null }
    $compilerFolder = Split-Path -Parent $CompilerPath

    # The embedded path is printable ASCII ('.store/<package id>/<version>/...'), so Latin-1 maps every
    # byte 1:1 and the match is confined to printable ASCII - the binary bytes around it cannot bleed
    # into the path. Launchers are a few hundred KB; reading one whole is cheaper than being clever.
    $bytes = [System.IO.File]::ReadAllBytes($CompilerPath)
    $text = [System.Text.Encoding]::GetEncoding(28591).GetString($bytes)
    $match = [regex]::Match($text, '(?<p>[\x20-\x7E-["<>|]]*?tools[\\/]net[0-9.]+[\\/]any[\\/][\x20-\x7E-["<>|\\/]]+\.dll)')
    if ($match.Success) {
        $relative = $match.Groups['p'].Value -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
        $target = if ([System.IO.Path]::IsPathRooted($relative)) { $relative } else { Join-Path $compilerFolder $relative }
        if (Test-Path -LiteralPath $target -PathType Leaf) { return (Split-Path -Parent $target) }
    }

    if (Test-Path -LiteralPath (Join-Path $compilerFolder 'Microsoft.Dynamics.Nav.CodeCop.dll')) { return $compilerFolder }
    return $null
}