tests/Roles.Contract.Tests.ps1
|
# The role contract, checked at author time over every shipped tool. See # docs/superpowers/specs/2026-10-05-roles-v2-design.md §6. BeforeDiscovery { $root = Split-Path $PSScriptRoot -Parent $script:ToolCases = @(Get-ChildItem (Join-Path $root 'Tools') -Filter '*.json' | ForEach-Object { @{ Name = $_.BaseName; JsonPath = $_.FullName; SidecarPath = [IO.Path]::ChangeExtension($_.FullName, '.ps1') } }) } BeforeAll { $root = Split-Path $PSScriptRoot -Parent $script:Roles = Get-Content (Join-Path $root 'data/roles.json') -Raw | ConvertFrom-Json -AsHashtable $script:AllReservedEnv = @($script:Roles.Values | ForEach-Object { if ($_['reserved']) { $_['reserved']['env'] } } | Where-Object { $_ }) $script:AllReservedAliases = @($script:Roles.Values | ForEach-Object { if ($_['reserved']) { $_['reserved']['aliases'] } } | Where-Object { $_ }) function Get-RoleMemberships($JsonPath) { $j = Get-Content $JsonPath -Raw | ConvertFrom-Json -AsHashtable if ($j['roles']) { $j['roles'] } else { @{} } } # Every AST node in $Ast that is not inside a function named Initialize-DFRole*. function Get-NodesOutsideHooks($Ast, [scriptblock]$Predicate) { $Ast.FindAll({ param($n) & $Predicate $n }, $true) | Where-Object { $p = $_.Parent while ($p) { if ($p -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $p.Name -like 'Initialize-DFRole*') { return $false } $p = $p.Parent } $true } } # A role block has content when its env or aliases hold at least one entry; # an empty {} is still a truthy hashtable. function Test-RoleBlockContent([hashtable]$Block) { ($Block['env'] -and $Block['env'].Count -gt 0) -or ($Block['aliases'] -and $Block['aliases'].Count -gt 0) } function Get-SidecarAst($Path) { [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$null) } function Get-HookNames($Ast) { # ForEach-Object, not .Name: member enumeration on no matches yields a lone $null. @($Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -like '*Initialize-DFRole*' }, $true) | ForEach-Object Name) } } Describe 'Role contract: <Name>' -ForEach $script:ToolCases { It 'declares only roles that exist in data/roles.json' { foreach ($role in (Get-RoleMemberships $JsonPath).Keys) { $script:Roles.ContainsKey($role) | Should -BeTrue -Because "$Name declares unknown role '$role'" } } It 'gives every single-kind membership content or a hook, and category memberships neither' { $hooks = if (Test-Path $SidecarPath) { Get-HookNames (Get-SidecarAst $SidecarPath) } else { @() } $memberships = Get-RoleMemberships $JsonPath foreach ($role in $memberships.Keys) { $def = $script:Roles[$role] $block = $memberships[$role] $hasContent = Test-RoleBlockContent $block if ($def['kind'] -eq 'single') { ($hasContent -or $def['hook'] -in $hooks -or $def['emptyMembership']) | Should -BeTrue -Because "$Name joins '$role' but supplies no aliases, env or $($def['hook'])" } else { $hasContent | Should -BeFalse -Because "category role '$role' takes no aliases or env" } } } It 'defines hooks only for roles it declares, and never as global functions' { if (-not (Test-Path $SidecarPath)) { return } $declared = @((Get-RoleMemberships $JsonPath).Keys | ForEach-Object { $script:Roles[$_]['hook'] }) foreach ($h in Get-HookNames (Get-SidecarAst $SidecarPath)) { $h | Should -Not -BeLike 'global:*' -Because 'a hook must stay local to the companion call' $h | Should -BeIn $declared -Because "$Name defines $h for a role it does not declare" } } It 'sets no role-reserved env var or alias at the top level' { $j = Get-Content $JsonPath -Raw | ConvertFrom-Json -AsHashtable foreach ($k in @(if ($j['env']) { $j['env'].Keys })) { $k | Should -Not -BeIn $script:AllReservedEnv -Because "$k is reserved for a role; put it in that role's block" } foreach ($k in @(if ($j['aliases']) { $j['aliases'].Keys })) { $k | Should -Not -BeIn $script:AllReservedAliases -Because "$k is reserved for a role; put it in that role's block" } } It 'never assigns a role-reserved env var in its companion, hooks included' { if (-not (Test-Path $SidecarPath)) { return } $ast = Get-SidecarAst $SidecarPath $hits = $ast.FindAll({ param($n) ($n -is [System.Management.Automation.Language.AssignmentStatementAst] -and $n.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and $n.Left.VariablePath.IsDriveQualified -and $n.Left.VariablePath.DriveName -eq 'Env' -and ($n.Left.VariablePath.UserPath -replace '^Env:') -in $script:AllReservedEnv) -or ($n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $n.Member.Value -eq 'SetEnvironmentVariable' -and $n.Arguments.Count -ge 1 -and $n.Arguments[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $n.Arguments[0].Value -in $script:AllReservedEnv) }, $true) @($hits).Count | Should -Be 0 -Because "reserved variables come only from role blocks: $($hits.Extent.Text -join '; ')" } It 'uses its roles'' reserved code only inside role hooks' { if (-not (Test-Path $SidecarPath)) { return } $ast = Get-SidecarAst $SidecarPath foreach ($role in (Get-RoleMemberships $JsonPath).Keys) { $reserved = $script:Roles[$role]['reserved'] foreach ($token in @(if ($reserved) { $reserved['code'] })) { if (-not $token) { continue } $hits = Get-NodesOutsideHooks $ast { param($n) ($n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq $token) -or ($n -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $n.Parent -isnot [System.Management.Automation.Language.CommandAst] -and $n.Value -like "*$token*") -or ($token -like 'function:*' -and $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and ($n.Name -replace '^global:') -eq ($token -replace '^function:')) } @($hits).Count | Should -Be 0 -Because "'$token' belongs to the $role role and may appear only in its hook: $(@($hits).Extent.Text -join '; ')" } } } } Describe 'Role contract: a broken fixture is caught' { It 'does not count an empty env or aliases block as role content' { Test-RoleBlockContent @{ env = @{}; aliases = @{} } | Should -BeFalse Test-RoleBlockContent @{ env = @{ PAGER = 'less' } } | Should -BeTrue Test-RoleBlockContent @{ aliases = @{ ls = @{ command = 'x' } } } | Should -BeTrue } It 'flags a member that runs Invoke-Expression outside its prompt hook' { $path = Join-Path $TestDrive 'bad.ps1' 'Invoke-Expression (starship init powershell)' | Set-Content $path $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null) $hits = Get-NodesOutsideHooks $ast { param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Invoke-Expression' } @($hits).Count | Should -Be 1 } It 'accepts the same call inside the hook' { $path = Join-Path $TestDrive 'good.ps1' 'function Initialize-DFRolePrompt { Invoke-Expression (starship init powershell) }' | Set-Content $path $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null) $hits = Get-NodesOutsideHooks $ast { param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Invoke-Expression' } @($hits).Count | Should -Be 0 } } |