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

#!/usr/bin/env pwsh
# Work-kind common helpers (Feature 182).
#
# Focused, dependency-free readers for the work-kind YAML files + a glob matcher. Specrew
# deliberately avoids the powershell-yaml dependency, so these are line-based readers matched to
# the parser-friendly subset the catalog/declaration are authored in (see work-kinds.yml header).
# All readers are fail-open: a structurally unreadable document returns $null and the caller
# decides (the validator degrades to advisory WARN, never a crash).
#
# Library file: it does NOT set a script-wide Set-StrictMode, so dot-sourcing does not change the
# caller's strict-mode posture.

function ConvertFrom-SpecrewWorkKindScalar {
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][AllowEmptyString()][AllowNull()][string]$Raw)
    if ($null -eq $Raw) { return $null }
    $v = $Raw.Trim()
    if ($v.Length -eq 0) { return $null }
    # strip a trailing inline comment that is not inside quotes
    if ($v -notmatch '^["'']' -and $v -match '^(?<val>[^#]*?)\s+#.*$') { $v = $Matches['val'].Trim() }
    if ($v.Length -ge 2 -and (($v[0] -eq '"' -and $v[-1] -eq '"') -or ($v[0] -eq "'" -and $v[-1] -eq "'"))) {
        return $v.Substring(1, $v.Length - 2)
    }
    switch -Regex ($v) {
        '^(?i:true)$'  { return $true }
        '^(?i:false)$' { return $false }
        '^(?i:null|~)$' { return $null }
        default { return $v }
    }
}

function ConvertFrom-SpecrewWorkKindCatalog {
    # Reads work-kinds.yml into an ordered hashtable: { schema_version, work_kinds=[..], global_allowlist=[..] }.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }

    $lines = $Text -split '\r?\n'
    $rec = [ordered]@{ schema_version = $null; work_kinds = @(); global_allowlist = @() }
    $kinds = [System.Collections.Generic.List[object]]::new()
    $allow = [System.Collections.Generic.List[string]]::new()
    $section = 'top'
    $cur = $null
    $curList = $null

    foreach ($line in $lines) {
        if ($line -match '^\s*#' -or $line -match '^\s*$') { continue }

        # Top-level key (no indentation)
        if ($line -match '^(?<k>[a-z_]+):\s*(?<v>.*)$') {
            $k = $Matches['k']; $v = $Matches['v']
            switch ($k) {
                'work_kinds' { $section = 'work_kinds'; $cur = $null; $curList = $null; continue }
                'global_allowlist' { $section = 'global_allowlist'; $cur = $null; $curList = $null; continue }
                default { $rec[$k] = ConvertFrom-SpecrewWorkKindScalar -Raw $v; $section = 'top'; continue }
            }
        }

        if ($section -eq 'work_kinds') {
            # new work-kind entry: " - id: <value>"
            if ($line -match '^\s{2}-\s+id:\s*(?<v>.*)$') {
                $cur = [ordered]@{ id = ConvertFrom-SpecrewWorkKindScalar -Raw $Matches['v']; required_evidence = @(); allowed_scope = @() }
                $kinds.Add($cur) | Out-Null
                $curList = $null
                continue
            }
            if ($null -eq $cur) { continue }
            # nested list item: " - <value>"
            if ($line -match '^\s{6}-\s+(?<v>.*)$') {
                if ($null -ne $curList) {
                    $existing = [System.Collections.Generic.List[object]]::new()
                    foreach ($e in @($cur[$curList])) { $existing.Add($e) | Out-Null }
                    $existing.Add((ConvertFrom-SpecrewWorkKindScalar -Raw $Matches['v'])) | Out-Null
                    $cur[$curList] = $existing.ToArray()
                }
                continue
            }
            # property: " key:" (starts nested list) or " key: value" (scalar)
            if ($line -match '^\s{4}(?<k>[a-z_]+):\s*(?<v>.*)$') {
                $pk = $Matches['k']; $pv = $Matches['v']
                if ([string]::IsNullOrWhiteSpace($pv)) {
                    $curList = $pk
                    if (-not $cur.Contains($pk)) { $cur[$pk] = @() }
                }
                else {
                    $cur[$pk] = ConvertFrom-SpecrewWorkKindScalar -Raw $pv
                    $curList = $null
                }
                continue
            }
        }

        if ($section -eq 'global_allowlist') {
            if ($line -match '^\s{2}-\s+(?<v>.*)$') {
                $allow.Add([string](ConvertFrom-SpecrewWorkKindScalar -Raw $Matches['v'])) | Out-Null
                continue
            }
        }
    }

    $rec['work_kinds'] = $kinds.ToArray()
    $rec['global_allowlist'] = $allow.ToArray()
    return $rec
}

function ConvertFrom-SpecrewWorkKindDeclaration {
    # Reads a .specrew/work-kind.yml declaration: { work_kind, schema_version, notes }.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    $rec = [ordered]@{}
    foreach ($line in ($Text -split '\r?\n')) {
        if ($line -match '^\s*#' -or $line -match '^\s*$') { continue }
        if ($line -match '^(?<k>[a-z_]+):\s*(?<v>.*)$') {
            $rec[$Matches['k']] = ConvertFrom-SpecrewWorkKindScalar -Raw $Matches['v']
        }
    }
    if (-not $rec.Contains('work_kind')) { return $null }
    return $rec
}

function Get-SpecrewWorkKindLifecycle {
    # FR-023 / SC-016: resolve the declared work_kind (.specrew/work-kind.yml) THROUGH the catalog to its
    # lifecycle template, and confirm that template is actually resolvable (deployed / in the module).
    # This is RUNTIME resolution, not file-presence: it proves the crew is pointed to the lifecycle the
    # declaration selected. Returns @{ Declared, Kind, LifecycleTemplate, ResolvedPath, Exists, Reason }.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $result = [ordered]@{ Declared = $false; Kind = $null; LifecycleTemplate = $null; ResolvedPath = $null; Exists = $false; Reason = $null }
    $resolved = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction SilentlyContinue)
    if ($null -eq $resolved) { $result.Reason = 'project-root-not-found'; return [pscustomobject]$result }
    $root = $resolved.Path

    $declPath = Join-Path $root '.specrew/work-kind.yml'
    if (-not (Test-Path -LiteralPath $declPath -PathType Leaf)) { $result.Reason = 'no-work-kind-declared'; return [pscustomobject]$result }
    $decl = ConvertFrom-SpecrewWorkKindDeclaration -Text (Get-Content -LiteralPath $declPath -Raw -Encoding UTF8)
    if ($null -eq $decl -or -not $decl.Contains('work_kind')) { $result.Reason = 'declaration-unparseable'; return [pscustomobject]$result }
    $result.Declared = $true
    $result.Kind = [string]$decl['work_kind']

    $catalogPath = @(
        (Join-Path $root 'extensions/specrew-speckit/knowledge/work-kinds.yml'),
        (Join-Path $root '.specify/extensions/specrew-speckit/knowledge/work-kinds.yml')
    ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
    if (-not $catalogPath) { $result.Reason = 'catalog-not-found'; return [pscustomobject]$result }
    $catalog = ConvertFrom-SpecrewWorkKindCatalog -Text (Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8)
    $entry = @($catalog.work_kinds) | Where-Object { [string]$_.id -eq $result.Kind } | Select-Object -First 1
    if ($null -eq $entry) { $result.Reason = ('kind-not-in-catalog: {0}' -f $result.Kind); return [pscustomobject]$result }
    $tmpl = if ($entry.Contains('lifecycle_template')) { [string]$entry['lifecycle_template'] } else { $null }
    if ([string]::IsNullOrWhiteSpace($tmpl)) { $result.Reason = ('no-lifecycle_template-for: {0}' -f $result.Kind); return [pscustomobject]$result }
    $result.LifecycleTemplate = $tmpl

    # lifecycle_template ("templates/lifecycle/<kind>-lifecycle.md") is relative to the EXTENSION ROOT
    # (the catalog's grandparent): the templates ship WITH the extension, so the SAME relative path
    # resolves in the dev tree (extensions/specrew-speckit/...) AND a deployed project
    # (.specify/extensions/specrew-speckit/...). This is the deployed-shape fix (the prior repo-root
    # resolution failed in a real deployment).
    $extRoot = Split-Path -Parent (Split-Path -Parent $catalogPath)   # <ext>/knowledge/work-kinds.yml -> <ext>
    $cand = Join-Path $extRoot $tmpl
    if (Test-Path -LiteralPath $cand -PathType Leaf) {
        $result.ResolvedPath = (Resolve-Path -LiteralPath $cand).Path
        $result.Exists = $true
    }
    else {
        $result.Reason = ('lifecycle-template-not-resolvable: {0} (looked under {1})' -f $tmpl, $extRoot)
    }
    return [pscustomobject]$result
}

function Get-SpecrewWorkKindLifecycleSurface {
    # FR-023 / SC-016: the human-visible line the intake/start/refocus surfaces render so the crew is
    # pointed to the SELECTED work_kind's lifecycle CONTRACT. Returns $null when nothing is declared.
    [CmdletBinding()]
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)
    $r = Get-SpecrewWorkKindLifecycle -ProjectRoot $ProjectRoot
    if (-not $r.Declared) { return $null }
    if ($r.Exists) {
        return ("Work kind: {0} -> lifecycle contract: {1} (resolved). Follow this {0} lifecycle, not improvised ceremony." -f $r.Kind, $r.LifecycleTemplate)
    }
    return ("Work kind: {0} -> lifecycle template '{1}' is declared in the catalog but NOT resolvable ({2}) — deploy the lifecycle templates." -f $r.Kind, $r.LifecycleTemplate, $r.Reason)
}

function Test-SpecrewWorkKindGlob {
    # Minimal gitignore-style glob match: `**` matches any path segments, `*` matches within a
    # segment, `?` one char. Forge-neutral (operates on a normalized forward-slash path).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][string]$Pattern
    )
    $p = ($Path -replace '\\', '/').TrimStart('./')
    $g = ($Pattern -replace '\\', '/').TrimStart('./')
    # Build a regex from the glob. Order matters: handle `**/` (zero-or-more leading/intermediate
    # path segments, gitignore semantics) before the bare `**` and `*`.
    $rx = [System.Text.RegularExpressions.Regex]::Escape($g)
    $rx = $rx -replace '\\\*\\\*/', '(?:.*/)?'       # **/ -> optional path segments (incl. none)
    $rx = $rx -replace '\\\*\\\*', '.*'               # ** -> anything (e.g. trailing /**)
    $rx = $rx -replace '\\\*', '[^/]*'                # * -> within a segment
    $rx = $rx -replace '\\\?', '[^/]'                 # ? -> one char
    return [bool]([System.Text.RegularExpressions.Regex]::IsMatch($p, ('^' + $rx + '$')))
}

function Test-SpecrewWorkKindAllowlisted {
    # True if a changed file matches any global_allowlist glob (exempt from scope checks).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Allowlist
    )
    foreach ($g in $Allowlist) {
        if ([string]::IsNullOrWhiteSpace($g)) { continue }
        if (Test-SpecrewWorkKindGlob -Path $Path -Pattern $g) { return $true }
    }
    return $false
}