Public/Get-DFCommandConflict.ps1

#Requires -Version 7.0

function Get-DFCommandConflict {
    <#
    .SYNOPSIS
        Reports DotForge commands that another tool shadows before PowerShell can
        resolve them.

    .DESCRIPTION
        Coreutils for Windows installs a PSConsoleHostReadLine hook that rewrites
        command names to '<name>.cmd' before PowerShell resolves them. Any DotForge
        alias sharing a name with an enabled coreutils utility is therefore
        unreachable at the prompt — and because the rewrite happens above command
        resolution, Get-Command still reports DotForge's version, so the conflict is
        invisible to normal probing.

        This function compares the command names DotForge creates (its own helper
        aliases plus every alias, role alias and picker alias declared in the tool database)
        against the set the coreutils hook will rewrite, and returns one object per
        conflict along with the command that resolves it.

        The check is host-accurate: the coreutils hook is injected only into the
        ConsoleHost profile, so hosts that never load it (such as the VS Code
        integrated terminal) correctly report no conflicts. Nothing is spawned and
        no registry is read. When coreutils is not installed, or its hook is not
        loaded in this host, this returns nothing.

        Resolving a conflict requires elevation and is a policy choice, so DotForge
        never applies it: keep the coreutils utility, or disable it and let
        DotForge's version through. The Fix property carries the exact command.

    .PARAMETER ToolsPath
        Directory of tool JSON records to read alias names from. Defaults to the
        module's own Tools directory.

    .PARAMETER Tools
        Tool records to check instead of every known tool. Start-DFSession passes
        the tools it activated.
    .PARAMETER IncludeIgnored
        Also return conflicts listed in the session config's IgnoreConflicts, which are
        suppressed by default.

    .EXAMPLE
        Get-DFCommandConflict

        Lists every DotForge command currently shadowed by coreutils.

    .EXAMPLE
        (Get-DFCommandConflict).DisableWith | Sort-Object -Unique

        Produces the argument list for 'coreutils-manager disable'. Use DisableWith
        rather than Command: 'la' is not a coreutils utility and the manager rejects
        it, so it maps to 'ls'.

    .EXAMPLE
        Start-DFSession -Config @{ Tools = @('bat'); IgnoreConflicts = @('cat') }
        Get-DFCommandConflict

        Reports conflicts while accepting coreutils' cat over DotForge's bat alias.

    .OUTPUTS
        [PSCustomObject] with Command, ShadowedBy, WouldResolveTo, Ignored,
        DisableWith, and Fix properties. Returns nothing when no conflict exists.

    .LINK
        https://github.com/simsrw73/DotForge/blob/main/docs/guide/coreutils-conflicts.md
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string]$ToolsPath,
        [object[]]$Tools,
        [switch]$IncludeIgnored
    )

    $shadowed = Get-DFCoreutilsShadowSet
    if (-not $shadowed) { return }

    # Names DotForge creates. Two sources, because they are created two different ways:
    # 1. Helper aliases (touch, env, paste, ...) — created at import by Public/*.ps1
    # and exported by the module manifest (AliasesToExport), so the module owns
    # them and (Get-Module DotForge).ExportedAliases contains them.
    # 2. Tool aliases and picker aliases (cat, ls, ff, ...) — created by
    # Register-DFTool from the tool database.
    $owned = [System.Collections.Generic.List[string]]::new()

    # Cached: this runs from Register-DFTool at profile load, and re-parsing the
    # manifest each call is the bulk of this function's cost.
    if ($null -eq $script:DFManifestAliases) {
        $script:DFManifestAliases = @()
        $manifest = Join-Path $PSScriptRoot '../DotForge.psd1'
        if (Test-Path $manifest) {
            $data = Import-PowerShellDataFile -Path $manifest -ErrorAction Ignore
            if ($data -and $data.AliasesToExport) {
                $script:DFManifestAliases = [string[]]@($data.AliasesToExport)
            }
        }
    }
    if ($script:DFManifestAliases) { $owned.AddRange([string[]]$script:DFManifestAliases) }

    # A session passes its active tools; run on its own, every known tool is checked.
    $checked = if ($PSBoundParameters.ContainsKey('Tools')) { @($Tools) } else {
        $dbParams = @{}
        if ($ToolsPath) { $dbParams['ToolsPath'] = $ToolsPath }
        @((Import-DFToolDb @dbParams -ErrorAction Ignore).Values)
    }
    foreach ($tool in $checked) {
        if (-not $tool) { continue }
        if ($tool.aliases) {
            $owned.AddRange([string[]]@($tool.aliases.PSObject.Properties.Name))
        }
        # A role's aliases (ls, ll, ...) are defined only by the role's winner,
        # but any member may win, so all of them can be shadowed.
        foreach ($role in $tool.roles.PSObject.Properties) {
            if ($role.Value.aliases) {
                $owned.AddRange([string[]]@($role.Value.aliases.PSObject.Properties.Name))
            }
        }
        if ($tool.picker -is [PSCustomObject] -and $tool.picker.alias) {
            $owned.Add([string]$tool.picker.alias)
        }
    }

    $ignored = [string[]]@(Get-DFConfig IgnoreConflicts)

    foreach ($name in ($owned | Sort-Object -Unique)) {
        if ($shadowed -notcontains $name) { continue }

        $isIgnored = $ignored -contains $name
        if ($isIgnored -and -not $IncludeIgnored) { continue }

        # The alias/function still resolves — the hook intercepts above resolution —
        # so this reports what the user loses to the rewrite.
        $target = Get-Command -Name $name -ErrorAction Ignore |
            Where-Object { $_.CommandType -ne 'Application' } |
            Select-Object -First 1

        # coreutils-manager has no 'la' utility and rejects it: there is no la.cmd, and
        # the installer synthesizes 'la' into the hook only while 'ls' is enabled. So
        # 'ls' is what you disable, and doing so removes 'la' with it.
        $disableWith = if ($name -eq 'la') { 'ls' } else { $name }

        [PSCustomObject]@{
            Command        = $name
            ShadowedBy     = 'coreutils'
            WouldResolveTo = if ($target) { $target.Definition ?? $target.Name } else { $null }
            Ignored        = $isIgnored
            DisableWith    = $disableWith
            Fix            = "coreutils-manager disable $disableWith (run elevated)"
        }
    }
}