extensions/specrew-speckit/scripts/provider-adapter.ps1
|
#!/usr/bin/env pwsh # ProviderAdapter contract + dispatch (Feature 182). # # The ONLY forge-specific seam. The methodology, the work-kind declaration, and the CI validator # core import NO forge assumption; they go through this contract. v1 ships: # - generic / unknown : always-present fallback (ci-only/manual; git-diff read_pr_context) # - github : reference adapter. This forge-NEUTRAL core keeps only a placeholder for # github (FR-014: the core imports no forge adapter); the real github # capability detection + guarded apply live in the github adapter # (provider-github.ps1), reached via the capability-detector orchestrator — # never through this core dispatch (which is why the core never imports it). # - synthesized : generated on the fly for another forge; READ-ONLY until a human verifies it # # Contract operations: # Invoke-SpecrewDetectCapability -> { provider, mechanism, constraints } (read-only, always safe) # Invoke-SpecrewDescribeProtection -> human-readable plan (read-only, always safe) # Invoke-SpecrewApplyProtection -> result (GUARDED: human-approved; # refused for read-only/unverified adapters) # Get-SpecrewPrContext -> { changed_files, target_branch, source_branch, merge_state } # (forge-NEUTRAL git-diff fallback; works with no adapter) $script:WorkKindCommonPath = Join-Path $PSScriptRoot 'work-kind-common.ps1' if (Test-Path -LiteralPath $script:WorkKindCommonPath) { . $script:WorkKindCommonPath } function Resolve-SpecrewProviderAdapter { # Resolve a provider adapter descriptor (a pure in-memory constructor — no state change). # `read_only` is true for the generic fallback and for a synthesized adapter that a human has not # yet verified (DP-S3 safety guardrail). [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Provider, [switch]$Synthesized, [switch]$Verified ) $p = $Provider.Trim().ToLowerInvariant() $readOnly = $true switch ($p) { 'github' { $readOnly = $false } # reference adapter: not read-only (the github adapter carries the guarded apply) { $_ -in @('generic', 'unknown', '') } { $p = 'generic'; $readOnly = $true } default { # any other forge id is treated as a synthesized adapter $readOnly = -not ($Synthesized -and $Verified) } } if ($Synthesized) { $readOnly = -not $Verified } return [ordered]@{ provider = $p synthesized = [bool]$Synthesized verified = [bool]$Verified read_only = [bool]$readOnly } } function Get-SpecrewPrContext { # Forge-NEUTRAL read_pr_context fallback: the changed-file set via `git diff`, plus branch # info. Works with NO adapter. Fail-open: returns an empty changed-file set on any git error. [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$ProjectPath, [Parameter(Mandatory = $true)][string]$BaseRef, [string]$HeadRef = 'HEAD' ) $changed = @() $targetBranch = $null $sourceBranch = $null try { $diff = & git -C $ProjectPath diff --name-only "$BaseRef...$HeadRef" 2>$null if ($LASTEXITCODE -eq 0 -and $diff) { $changed = @($diff | Where-Object { $_ -and $_.Trim().Length -gt 0 } | ForEach-Object { ($_ -replace '\\', '/').Trim() }) } } catch { $changed = @() } try { $sourceBranch = (& git -C $ProjectPath rev-parse --abbrev-ref $HeadRef 2>$null | Select-Object -First 1) $targetBranch = ($BaseRef -replace '^origin/', '') } catch { # Fail-open: branch info is best-effort; the changed-file set is what the validator needs. $sourceBranch = $null } return [ordered]@{ changed_files = @($changed) target_branch = $targetBranch source_branch = $sourceBranch merge_state = 'unknown' # forge-specific; enriched by a real adapter in iteration 2 } } function Invoke-SpecrewDetectCapability { # Read-only, always safe. The generic adapter reports ci-only/manual. This is the FORGE-NEUTRAL # contract surface: for github it returns a neutral placeholder because the core imports no forge # adapter (FR-014). Real github capability detection lives in the github adapter and is reached # through the capability-detector orchestrator, never through this core dispatch. [CmdletBinding()] param( [Parameter(Mandatory = $true)]$Adapter, [string]$ProjectPath = '.' ) $provider = [string]$Adapter['provider'] switch ($provider) { 'github' { return [ordered]@{ provider = 'github' mechanism = 'ci-only' constraints = @('forge-neutral core: ci-only is the honest answer the core gives without importing a forge adapter (FR-014); for real GitHub capability (branch-protection/rulesets) use the github adapter via the capability detector.') } } default { # generic / unknown / synthesized-read-only $genericPath = Join-Path $PSScriptRoot 'provider-generic.ps1' if (Test-Path -LiteralPath $genericPath) { . $genericPath return (Get-SpecrewGenericCapability -ProjectPath $ProjectPath -Provider $provider) } return [ordered]@{ provider = $provider; mechanism = 'manual'; constraints = @('no adapter available; manual enforcement') } } } } function Invoke-SpecrewDescribeProtection { # Read-only, always safe: a human-readable plan describing what protection the captured # governance asks for. Never mutates anything. [CmdletBinding()] param( [Parameter(Mandatory = $true)]$Adapter, [Parameter(Mandatory = $true)]$Governance ) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add("[describe-protection] provider=$($Adapter['provider']) (read-only plan)") | Out-Null $bm = $Governance['branch_model'] if ($null -ne $bm) { $lines.Add(" branch model: $($bm['style']); release-truth branch: $($bm['release_truth_branch'])") | Out-Null foreach ($b in @($bm['branches'])) { if ($null -eq $b) { continue } $checks = @($b['required_checks']) -join ', ' $lines.Add(" protect '$($b['name'])' (role=$($b['role'])): PR-required=$($b['require_pull_request']); checks=[$checks]; force-push=$($b['allow_force_pushes']); deletions=$($b['allow_deletions'])") | Out-Null } } $lines.Add(" apply? describe-only by default — apply_protection requires explicit human approval") | Out-Null return ($lines -join [Environment]::NewLine) } function Invoke-SpecrewApplyProtection { # GUARDED privileged action. Refuses unless: the human explicitly approved (-Approved) AND the # adapter is not read-only (a generic fallback or an unverified synthesized adapter is always # refused). Specrew holds no secret; a real apply uses the caller's own forge token (iteration 2). [CmdletBinding()] param( [Parameter(Mandatory = $true)]$Adapter, [Parameter(Mandatory = $true)]$Governance, [switch]$Approved ) if ([bool]$Adapter['read_only']) { return [ordered]@{ applied = $false; reason = "adapter '$($Adapter['provider'])' is read-only (generic fallback or unverified synthesized adapter); apply_protection refused (DP-S2/S3)" } } if (-not $Approved) { return [ordered]@{ applied = $false; reason = 'apply_protection requires explicit human approval (-Approved); refused (DP-S2)' } } # Approved + a non-read-only (github/verified) adapter: this forge-neutral core performs NO # mutation by design (it imports no forge adapter, FR-014). The real guarded apply lives in the # github adapter (Invoke-SpecrewGitHubApplyProtection), human-approved + -Execute-gated. return [ordered]@{ applied = $false; reason = "apply_protection is not performed by the forge-neutral core; route through the forge adapter's guarded apply (human-approved + -Execute-gated). No mutation performed here (honest, not over-claimed)." } } |