extensions/specrew-speckit/scripts/capability-detector.ps1

#!/usr/bin/env pwsh
# Capability Detector + Brownfield Detection (Feature 182, Iteration 2).
#
# Orchestrates the ProviderAdapter's read-only `detect_capability` into an HONEST report (FR-012):
# the achievable mechanism (branch-protection | rulesets | ci-only | manual) with constraints,
# describe-only by default. Plus brownfield detection (FR-021): the repo's EXISTING CI/CD +
# protection posture, with an adapt-or-change recommendation — never a silent overwrite.

$script:CapDetectorRoot = $PSScriptRoot
. (Join-Path $script:CapDetectorRoot 'work-kind-common.ps1')
. (Join-Path $script:CapDetectorRoot 'provider-adapter.ps1')
. (Join-Path $script:CapDetectorRoot 'provider-generic.ps1')

function Resolve-SpecrewGovernanceProvider {
    # FR-026: read the canonical FORGE provider from .specrew/repository-governance.yml. The forge is
    # `provider.name` (the richer shape) OR a scalar `provider:` (top-level, or under repository_governance —
    # the simpler/older shape). It is NEVER `ci.provider` (that names the CI system, e.g. gitlab-ci — the
    # DF-004 mis-read). Block-aware so a nested ci.provider is ignored. Falls back to 'generic'.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][string]$ProjectPath)
    $govPath = Join-Path $ProjectPath '.specrew/repository-governance.yml'
    if (-not (Test-Path -LiteralPath $govPath)) { return 'generic' }
    $block = ''
    $rgProvider = $null
    foreach ($line in (Get-Content -LiteralPath $govPath -Encoding UTF8)) {
        if ($line -match '^(?<k>[a-z_]+):\s*(?<v>.*)$') {
            $block = $Matches['k']
            # simplest shape: a top-level `provider: <value>` scalar
            if ($block -eq 'provider' -and -not [string]::IsNullOrWhiteSpace($Matches['v'])) {
                return (ConvertFrom-SpecrewWorkKindScalar -Raw $Matches['v'])
            }
            continue
        }
        # canonical rich shape: provider.name (nested under the top-level `provider:` block)
        if ($block -eq 'provider' -and $line -match '^\s+name:\s*(?<v>\S+)') {
            return (ConvertFrom-SpecrewWorkKindScalar -Raw $Matches['v'])
        }
        # canonical template shape: repository_governance.provider (a scalar under repository_governance)
        if ($block -eq 'repository_governance' -and $line -match '^\s{2}provider:\s*(?<v>\S+)' -and $null -eq $rgProvider) {
            $rgProvider = (ConvertFrom-SpecrewWorkKindScalar -Raw $Matches['v'])
        }
        # NOTE: `ci.provider` lives under block 'ci' and is intentionally NOT read here (DF-004 fix).
    }
    if ($null -ne $rgProvider) { return $rgProvider }
    return 'generic'
}

function Invoke-SpecrewCapabilityDetection {
    # FR-012: honest capability report. github -> the GitHub adapter (gh, fail-open); anything else ->
    # the generic fallback (ci-only/manual) or an offer to synthesize a read-only adapter.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][string]$ProjectPath, [string]$Provider)
    if ([string]::IsNullOrWhiteSpace($Provider)) { $Provider = Resolve-SpecrewGovernanceProvider -ProjectPath $ProjectPath }
    $p = $Provider.Trim().ToLowerInvariant()

    if ($p -eq 'github') {
        $ghPath = Join-Path $script:CapDetectorRoot 'provider-github.ps1'
        if (Test-Path -LiteralPath $ghPath) {
            . $ghPath
            $cap = Get-SpecrewGitHubCapability -ProjectPath $ProjectPath
            $cap['describe_only_default'] = $true
            return $cap
        }
    }
    if ($p -in @('generic', 'unknown', '')) {
        $cap = Get-SpecrewGenericCapability -ProjectPath $ProjectPath -Provider 'generic'
        $cap['describe_only_default'] = $true
        return $cap
    }
    # An unrecognized forge: no shipped adapter — offer synthesis (read-only), report manual until then.
    return [ordered]@{
        provider              = $p
        mechanism             = 'manual'
        constraints           = @("no shipped adapter for forge '$p'; synthesize a READ-ONLY adapter (apply stays human-approved) — until then enforcement is manual + the provider-neutral CI check.")
        describe_only_default = $true
    }
}

function Invoke-SpecrewBrownfieldDetection {
    # FR-021: detect the EXISTING CI/CD + branch protection + review posture, and recommend
    # ADAPT (slot the work-kind check into the existing CI) vs CHANGE (recommended posture).
    # Read-only; never overwrites.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][string]$ProjectPath, [string]$ReleaseTruthBranch = 'main', [string]$Provider)
    if ([string]::IsNullOrWhiteSpace($Provider)) { $Provider = Resolve-SpecrewGovernanceProvider -ProjectPath $ProjectPath }

    # existing CI?
    $ciSignals = @('.github/workflows', '.gitlab-ci.yml', 'azure-pipelines.yml', '.azuredevops', '.circleci', 'Jenkinsfile', '.drone.yml', 'bitbucket-pipelines.yml')
    $ciFound = @()
    foreach ($s in $ciSignals) { if (Test-Path -LiteralPath (Join-Path $ProjectPath $s)) { $ciFound += $s } }

    # existing protection (GitHub only, read-only)?
    $protection = [ordered]@{ readable = $false; protected = $null; reason = 'not checked (non-github or gh unavailable)' }
    if ($Provider -eq 'github') {
        $ghPath = Join-Path $script:CapDetectorRoot 'provider-github.ps1'
        if (Test-Path -LiteralPath $ghPath) { . $ghPath; $protection = Get-SpecrewGitHubExistingProtection -Branch $ReleaseTruthBranch -ProjectPath $ProjectPath }
    }

    $hasCi = $ciFound.Count -gt 0
    $recommendation = if ($hasCi) {
        'ADAPT: slot the work-kind validator into your existing CI lane(s); record the existing posture in .specrew/repository-governance.yml.'
    }
    else {
        'CHANGE: no CI detected — add the provider-neutral work-kind check (and, where the forge supports it, protect the release-truth branch).'
    }
    return [ordered]@{
        ci_detected          = $hasCi
        ci_signals           = @($ciFound)
        protection           = $protection
        recommendation       = $recommendation
        never_overwrite_note = 'Specrew reports the detected posture and recommends; it never overwrites an existing setup.'
    }
}

function Format-SpecrewCapabilityReport {
    # The ui-ux surface: honest mechanism + constraints; describe-only by default.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)]$Capability)
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add("[capability] provider=$($Capability.provider) mechanism=$($Capability.mechanism)") | Out-Null
    foreach ($c in @($Capability.constraints)) { $lines.Add(" - $c") | Out-Null }
    if ([bool]$Capability['describe_only_default']) { $lines.Add(' apply? describe-only by default — apply_protection requires explicit human approval.') | Out-Null }
    return ($lines -join [Environment]::NewLine)
}