extensions/specrew-speckit/scripts/work-kind-validator.ps1

#!/usr/bin/env pwsh
# Work-Kind CI Validator (Feature 182, Iteration 2).
#
# Provider-NEUTRAL: the core imports no forge tool. It reads the work-kind declaration + the catalog
# + the changed-file set (via a ProviderAdapter's read_pr_context, or the git-diff fallback) + the
# closeout evidence, and emits an advisory|blocking verdict that NAMES THE EXACT GAP (SC-005).
#
# Defaults to ADVISORY (warns, never blocks). Fail-open everywhere: malformed/missing input degrades
# to a WARN, never a crash or a spurious block.
#
# Checks (FR-007):
# 1. exactly one work_kind is declared (.specrew/work-kind.yml; else infer from branch prefix)
# 2. the declared kind exists in the catalog
# 3. changed files are within the kind's allowed_scope (global-allowlist files exempt) [ChangedFileClassifier]
# 4. required closeout evidence is present; software-feature/bug-bash have no open lifecycle boundary [CloseoutEvidenceChecker]

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

function Resolve-SpecrewWorkKindCatalogPath {
    # Resolve work-kinds.yml across the deployed + source shapes (first existing wins; $null if none).
    # The validator and its catalog ship together in the SAME extension package, so the catalog BESIDE
    # THE SCRIPT is the most authoritative (it always matches this validator's own version). Fall through
    # to the deployed project layout (.specify/extensions/...), then the in-repo source-tree layout
    # (extensions/...). In a real deployed project the validator lives at
    # <proj>/.specify/extensions/specrew-speckit/scripts/, so the beside-script and .specify candidates
    # coincide; when the validator is loaded from a global module against a project, beside-script (the
    # module's own catalog) is preferred — version-consistent with this validator's logic.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][string]$ProjectPath)
    $extRoot = Split-Path -Parent $script:WkValidatorRoot   # <ext>/scripts -> <ext>
    $candidates = @(
        (Join-Path $extRoot 'knowledge/work-kinds.yml'),                                          # beside the script (this validator's extension package)
        (Join-Path $ProjectPath '.specify/extensions/specrew-speckit/knowledge/work-kinds.yml'),  # deployed project shape
        (Join-Path $ProjectPath 'extensions/specrew-speckit/knowledge/work-kinds.yml')            # in-repo source-tree shape
    )
    return @($candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1)
}

function Get-SpecrewWorkKindFromBranchPrefix {
    # Infer a default work_kind from the branch prefix (docs/, devops/, fix/, feature/) using each
    # kind's branch_prefix_hint. Returns $null when no prefix matches.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Catalog,
        [Parameter(Mandatory = $true)][AllowNull()][string]$Branch
    )
    if ([string]::IsNullOrWhiteSpace($Branch)) { return $null }
    $b = $Branch.Trim()
    foreach ($wk in @($Catalog.work_kinds)) {
        $hint = [string]$wk.branch_prefix_hint
        if (-not [string]::IsNullOrWhiteSpace($hint) -and $b.StartsWith($hint, [System.StringComparison]::OrdinalIgnoreCase)) {
            return [string]$wk.id
        }
    }
    return $null
}

function Test-SpecrewWorkKindClosed {
    # CloseoutEvidenceChecker (best-effort, fail-open). For software-feature/bug-bash, a closed work
    # item has a feature-closeout marker (closeout-dashboard.md) in its feature dir; otherwise the
    # lifecycle boundary is still open. Returns @{ closed=<bool>; reason=<string> }.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][string]$ProjectPath, [Parameter(Mandatory = $true)][string]$Branch)
    # Resolve the feature dir from the branch (e.g. 182-foo -> specs/182-foo).
    $featureDir = $null
    if ($Branch -match '^(?<slug>[0-9]{3,}-[A-Za-z0-9-]+)$') {
        $candidate = Join-Path $ProjectPath (Join-Path 'specs' $Matches['slug'])
        if (Test-Path -LiteralPath $candidate -PathType Container) { $featureDir = $candidate }
    }
    if ($null -eq $featureDir) {
        return @{ closed = $true; reason = 'feature dir not resolvable from branch; closeout check skipped (fail-open)' }
    }
    $closeoutMarker = Join-Path $featureDir 'closeout-dashboard.md'
    if (Test-Path -LiteralPath $closeoutMarker) {
        return @{ closed = $true; reason = 'feature-closeout marker present' }
    }
    return @{ closed = $false; reason = "no feature-closeout marker (closeout-dashboard.md) in $featureDir — the work item's lifecycle boundary is still open" }
}

function Invoke-SpecrewWorkKindValidation {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [string]$BaseRef,
        [string]$HeadRef = 'HEAD',
        [ValidateSet('advisory', 'blocking')][string]$Mode = 'advisory',
        [string]$Provider = 'generic',
        [AllowNull()][string[]]$ChangedFiles,
        [AllowNull()][string]$Branch
    )

    $findings = [System.Collections.Generic.List[object]]::new()
    function Add-Finding { param([string]$Check, [string]$Severity, [string]$Message) $findings.Add([ordered]@{ check = $Check; severity = $Severity; message = $Message }) | Out-Null }

    # --- load catalog (fail-open) — resolve across deployed + source shapes (beside the validator's own
    # extension package wins; then ProjectPath/.specify, then ProjectPath source tree) ---
    $catalogPath = Resolve-SpecrewWorkKindCatalogPath -ProjectPath $ProjectPath
    $catalog = $null
    if ($catalogPath) {
        $catalog = ConvertFrom-SpecrewWorkKindCatalog -Text (Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8)
    }
    if ($null -eq $catalog) {
        # Name the deployed (.specify) candidate — the location a real `specrew init` project installs to —
        # not the in-repo source-tree path that only exists in Specrew's own repo.
        $reportedPath = if ($catalogPath) { $catalogPath } else { Join-Path $ProjectPath '.specify/extensions/specrew-speckit/knowledge/work-kinds.yml' }
        Add-Finding 'catalog' 'warn' "work-kinds.yml catalog not found/parseable (looked beside the validator, then $reportedPath); skipping work-kind checks (fail-open)"
        return [ordered]@{ verdict = 'advisory-warn'; kind = $null; mode = $Mode; findings = @($findings.ToArray()) }
    }
    $catalogIds = @($catalog.work_kinds | ForEach-Object { [string]$_.id })

    # --- PR context (changed files + branch): explicit overrides (tests/callers) win; else the
    # adapter's read_pr_context / git-diff fallback ---
    if ($null -ne $ChangedFiles -or -not [string]::IsNullOrWhiteSpace($Branch)) {
        $changed = @($ChangedFiles)
        $branch = [string]$Branch
    }
    elseif (-not [string]::IsNullOrWhiteSpace($BaseRef)) {
        $ctx = Get-SpecrewPrContext -ProjectPath $ProjectPath -BaseRef $BaseRef -HeadRef $HeadRef
        $changed = @($ctx.changed_files)
        $branch = [string]$ctx.source_branch
    }
    else {
        Add-Finding 'pr-context' 'warn' 'no -BaseRef and no -ChangedFiles/-Branch override; nothing to validate (fail-open)'
        return [ordered]@{ verdict = 'advisory-warn'; kind = $null; mode = $Mode; findings = @($findings.ToArray()) }
    }

    # --- check 1: exactly one declared kind (else infer from branch prefix) ---
    $declPath = Join-Path $ProjectPath '.specrew/work-kind.yml'
    $kind = $null
    if (Test-Path -LiteralPath $declPath) {
        $decl = ConvertFrom-SpecrewWorkKindDeclaration -Text (Get-Content -LiteralPath $declPath -Raw -Encoding UTF8)
        if ($null -ne $decl -and -not [string]::IsNullOrWhiteSpace([string]$decl.work_kind)) {
            $kind = [string]$decl.work_kind
        }
    }
    if ($null -eq $kind) {
        $inferred = Get-SpecrewWorkKindFromBranchPrefix -Catalog $catalog -Branch $branch
        if ($null -ne $inferred) {
            $kind = $inferred
            Add-Finding 'declaration' 'warn' "no .specrew/work-kind.yml; inferred work_kind '$kind' from the branch prefix. Add .specrew/work-kind.yml to make it authoritative."
        }
        else {
            Add-Finding 'declaration' 'warn' "no work_kind declared. Add .specrew/work-kind.yml with 'work_kind: <software-feature|bug-bash|docs-only|devops>' (or use a branch prefix docs/ devops/ fix/ feature/)."
            return [ordered]@{ verdict = 'advisory-warn'; kind = $null; mode = $Mode; findings = @($findings.ToArray()) }
        }
    }

    # --- check 2: declared kind in catalog ---
    if ($kind -notin $catalogIds) {
        Add-Finding 'in-catalog' 'warn' "declared work_kind '$kind' is not in the catalog (known: $($catalogIds -join ', ')). Reclassify (fail-open: skipping scope/evidence checks)."
        return [ordered]@{ verdict = 'advisory-warn'; kind = $kind; mode = $Mode; findings = @($findings.ToArray()) }
    }
    $wk = $catalog.work_kinds | Where-Object { $_.id -eq $kind } | Select-Object -First 1

    # --- check 3: changed-file scope (ChangedFileClassifier) ---
    $allow = @($catalog.global_allowlist)
    $scope = @($wk.allowed_scope)
    foreach ($file in $changed) {
        if ([string]::IsNullOrWhiteSpace($file)) { continue }
        if (Test-SpecrewWorkKindAllowlisted -Path $file -Allowlist $allow) { continue }
        $inScope = $false
        foreach ($g in $scope) { if (Test-SpecrewWorkKindGlob -Path $file -Pattern $g) { $inScope = $true; break } }
        if (-not $inScope) {
            Add-Finding 'changed-file-scope' 'fail' "'$file' is outside the '$kind' allowed scope. $kind allows: $($scope -join ', '). Reclassify the PR, or move this change to a separate work item."
        }
    }

    # --- check 4: closeout evidence (CloseoutEvidenceChecker) ---
    if ($kind -in @('software-feature', 'bug-bash')) {
        $closeout = Test-SpecrewWorkKindClosed -ProjectPath $ProjectPath -Branch $branch
        if (-not [bool]$closeout.closed) {
            Add-Finding 'closeout-evidence' 'fail' "'$kind' PR has an open lifecycle boundary: $($closeout.reason). Required evidence for $kind`: $($wk.required_evidence -join ', '). Close out the work item before merge."
        }
    }

    # --- verdict ---
    $hasFail = @($findings | Where-Object { $_.severity -eq 'fail' }).Count -gt 0
    $verdict = if (-not $hasFail) { if (@($findings).Count -gt 0) { "$Mode-warn" } else { "$Mode-pass" } }
    elseif ($Mode -eq 'blocking') { 'blocking-fail' } else { 'advisory-fail' }

    # FR-023 / SC-016: the intake/CI work-kind surface points the crew to the SELECTED kind's lifecycle
    # contract (resolved through the catalog at runtime), so the lifecycle is governed, not improvised.
    $lifecycle = Get-SpecrewWorkKindLifecycleSurface -ProjectRoot $ProjectPath
    return [ordered]@{ verdict = $verdict; kind = $kind; mode = $Mode; findings = @($findings.ToArray()); lifecycle = $lifecycle }
}

function Write-SpecrewWorkKindBypassAudit {
    # Emergency/bypass audit (FR-011, SC-009): a bypass leaves a durable artifact, never a silent skip.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [Parameter(Mandatory = $true)][string]$Who,
        [Parameter(Mandatory = $true)][string]$Why,
        [string]$What = 'work-kind validation bypass',
        [string]$When
    )
    if ([string]::IsNullOrWhiteSpace($When)) { $When = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
    $auditDir = Join-Path $ProjectPath '.specrew/bypass-audit'
    if (-not (Test-Path -LiteralPath $auditDir)) { $null = New-Item -ItemType Directory -Path $auditDir -Force }
    $line = "- who: $Who | why: $Why | what: $What | when: $When"
    $auditFile = Join-Path $auditDir 'bypass-log.md'
    if (-not (Test-Path -LiteralPath $auditFile)) {
        $utf8 = [System.Text.UTF8Encoding]::new($false)
        [System.IO.File]::WriteAllText($auditFile, "# Work-Kind Governance Bypass Audit`n`nEach bypass is an authorized escape hatch with a durable record (never a silent skip).`n`n", $utf8)
    }
    Add-Content -LiteralPath $auditFile -Value $line -Encoding UTF8
    return @{ recorded = $true; path = $auditFile; entry = $line }
}

function Format-SpecrewWorkKindVerdict {
    # Human-readable validator output (the ui-ux surface): names the exact gap + allowed scope + fix.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)]$Result)
    $lines = [System.Collections.Generic.List[string]]::new()
    $kindTxt = if ($null -ne $Result.kind) { "declares work_kind: $($Result.kind)" } else { 'no work_kind' }
    $lines.Add("[work-kind] $kindTxt") | Out-Null
    foreach ($f in @($Result.findings)) {
        $mark = switch ($f.severity) { 'fail' { 'x' } 'warn' { '!' } default { '+' } }
        $lines.Add(" $mark $($f.check): $($f.message)") | Out-Null
    }
    if (@($Result.findings).Count -eq 0) { $lines.Add(' + all checks passed') | Out-Null }
    $blocking = $Result.verdict -like 'blocking-*'
    $tail = if ($blocking) { 'blocking mode' } else { 'not blocking (phased: advisory mode)' }
    $lines.Add(" verdict: $($Result.verdict.ToUpperInvariant()) — $tail") | Out-Null
    return ($lines -join [Environment]::NewLine)
}