Private/Get-DFRoleDb.ps1

#Requires -Version 7.0

$script:DFRoleDb = $null

function Get-DFRoleDb {
    <#
    .SYNOPSIS
        Loads the role definitions (data/roles.json) into a cached hashtable keyed by role name.
    .DESCRIPTION
        Each entry is normalized by ConvertTo-DFRoleRecord; invalid entries warn and
        are skipped. A missing or unreadable file warns once and yields an empty
        table, so every role feature degrades to a no-op.
    .PARAMETER Path
        Read this file instead of the shipped data/roles.json. Always uncached and
        never populates the cache.
    .PARAMETER Force
        Reload the shipped file, replacing the cache.
    .OUTPUTS
        System.Collections.Hashtable.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string]$Path,
        [switch]$Force
    )

    if (-not $Path -and -not $Force -and $null -ne $script:DFRoleDb) { return $script:DFRoleDb }

    $file = if ($Path) { $Path } else { Join-Path $PSScriptRoot '../data/roles.json' }
    $db = @{}
    try {
        $raw = Get-Content -LiteralPath $file -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
        foreach ($p in $raw.PSObject.Properties) {
            $record = ConvertTo-DFRoleRecord -Name $p.Name -Role $p.Value
            if ($record) { $db[$p.Name] = $record }
        }
    } catch {
        Write-Warning "DotForge: role definitions unavailable ($($_.Exception.Message)); roles are ignored."
    }

    if (-not $Path) { $script:DFRoleDb = $db }
    $db
}

function ConvertTo-DFRoleRecord {
    <#
    .SYNOPSIS
        Validates one role definition and returns it with every field present.
    .DESCRIPTION
        kind must be 'single' or 'category'. A single role needs a hook named
        Initialize-DFRole plus its name in PascalCase (project-env ->
        Initialize-DFRoleProjectEnv). A category role may not declare a hook, exclusive,
        reserved names or emptyMembership. Invalid entries warn and return nothing.
    .PARAMETER Name
        The role name (the key in roles.json).
    .PARAMETER Role
        The parsed definition.
    .OUTPUTS
        PSCustomObject, or nothing when invalid.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][pscustomobject]$Role
    )

    # Direct property reads (no helper scriptblock): this runs at every startup.
    $props = $Role.PSObject.Properties
    $reserved = $props['reserved']?.Value
    # Index the collection itself: assigning it from an if-expression would
    # enumerate it into a plain array, and string indexing would then find nothing.
    $resProps = ${reserved}?.PSObject.Properties
    $record = [pscustomobject]@{
        name            = $Name
        kind            = $props['kind']?.Value
        exclusive       = [bool]$props['exclusive']?.Value
        hook            = $props['hook']?.Value
        emptyMembership = [bool]$props['emptyMembership']?.Value
        reserved        = [pscustomobject]@{
            env     = [string[]]@(if ($null -ne $resProps) { $resProps['env']?.Value })
            aliases = [string[]]@(if ($null -ne $resProps) { $resProps['aliases']?.Value })
            code    = [string[]]@(if ($null -ne $resProps) { $resProps['code']?.Value })
        }
        requires        = [string]$props['requires']?.Value
        description     = [string]$props['description']?.Value
    }

    $problem = switch ($record.kind) {
        'single' {
            # The hook name is derived from the role name (project-env -> ProjectEnv),
            # so no two roles can share one and a sidecar's function serves one role.
            $pascal = -join ($Name -split '-' | Where-Object { $_ } | ForEach-Object { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) })
            $expected = "Initialize-DFRole$pascal"
            if ($record.hook -cne $expected) { "hook '$($record.hook)' must be $expected" }
        }
        'category' {
            if ($record.hook -or $record.emptyMembership -or $record.exclusive -or
                $record.reserved.env.Count -or $record.reserved.aliases.Count -or $record.reserved.code.Count) {
                'a category role cannot declare a hook, exclusive, emptyMembership or reserved names'
            }
        }
        default { "unknown kind '$($record.kind)' (expected single or category)" }
    }
    if ($problem) {
        Write-Warning "DotForge: role '$Name' is invalid and ignored: $problem."
        return
    }
    $record
}