scripts/internal/gate-preflight.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function New-SpecrewGatePreflightCheck { param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][ValidateSet('pass', 'fail', 'not-applicable')][string]$Status, [Parameter(Mandatory)][string]$Message, [AllowNull()][object]$Evidence ) return [pscustomobject][ordered]@{ name = $Name status = $Status message = $Message evidence = $Evidence } } function Get-SpecrewGatePreflightMarkdownValue { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Label) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } $pattern = '^\*\*' + [regex]::Escape($Label) + '\*\*:\s*(.+?)\s*$' foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { if ($line -match $pattern) { return $Matches[1].Trim() } } return $null } function Get-SpecrewGatePreflightStatus { param([Parameter(Mandatory)][string]$ProjectRoot) $records = New-Object System.Collections.Generic.List[object] $lines = @(& git -C $ProjectRoot status --porcelain=v1 --untracked-files=all 2>$null) if ($LASTEXITCODE -ne 0) { throw "git status failed for '$ProjectRoot'." } foreach ($line in $lines) { $text = [string]$line if ($text.Length -lt 4) { continue } $path = $text.Substring(3).Trim() if ($path -match ' -> (?<destination>.+)$') { $path = $Matches['destination'].Trim() } $normalized = $path.Replace('\', '/') $writer = if ($normalized -match '^(?:\.specrew|\.specify|\.squad)(?:/|$)' -or $normalized -match '^specs/[^/]+/iterations/[0-9]+/(?:state\.md|tasks-progress\.yml|drift-log\.md|review\.md|retro\.md|dashboard\.md|quality(?:/|$))') { 'governance-record' } else { 'product-or-methodology' } $records.Add([pscustomobject][ordered]@{ status = $text.Substring(0, 2); path = $normalized; writer = $writer }) | Out-Null } return $records.ToArray() } function Get-SpecrewGatePreflightTaskState { param([Parameter(Mandatory)][string]$Path) $tasks = [ordered]@{} $current = $null foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { if ($line -match '^ (?<id>T\d+):\s*$') { $current = $Matches['id'] $tasks[$current] = $null continue } if ($null -ne $current -and $line -match '^ status:\s*["'']?(?<status>[^"''#\s]+)') { $tasks[$current] = $Matches['status'].Trim().ToLowerInvariant() } } return $tasks } function ConvertFrom-SpecrewGatePreflightIdList { param([AllowNull()][string]$Value) if ([string]::IsNullOrWhiteSpace($Value) -or $Value -match '^\(?none\)?$') { return @() } return @($Value -split '\s*,\s*' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^T\d+$' }) } function Get-SpecrewGatePreflightEnforcementMode { # FR-025 (T016): repository_governance.enforcement_mode is a schema enum # (branch-protection | rulesets | ci-only | manual) that carries exactly the # intended-vs-active distinction the delivery check needs. It sat in the same file the check # already reads and was never consulted. Same line-oriented read as release_truth_branch. [CmdletBinding()] param([Parameter(Mandatory = $true)][string]$ProjectRoot) $governancePath = Join-Path $ProjectRoot '.specrew/repository-governance.yml' if (-not (Test-Path -LiteralPath $governancePath -PathType Leaf)) { return '' } foreach ($line in Get-Content -LiteralPath $governancePath -Encoding UTF8) { if ($line -match '^\s*enforcement_mode:\s*["'']?(?<value>[^"''#\s]+)') { return $Matches['value'].Trim().ToLowerInvariant() } } return '' } function Invoke-SpecrewGatePreflight { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProjectRoot, [Parameter(Mandatory)][ValidateSet('specify', 'clarify', 'plan', 'tasks', 'before-implement', 'review-signoff', 'retro', 'iteration-closeout', 'feature-closeout')][string]$BoundaryType, [AllowNull()][string]$FeatureRef, [AllowNull()][string]$IterationNumber, [switch]$FailOnError ) $root = (Resolve-Path -LiteralPath $ProjectRoot).Path $checks = New-Object System.Collections.Generic.List[object] $head = [string](@(& git -C $root rev-parse --verify HEAD 2>$null) | Select-Object -First 1) if ($LASTEXITCODE -ne 0 -or $head -notmatch '^[0-9a-f]{40,64}$') { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status fail -Message 'HEAD could not be resolved; boundary evidence cannot be bound to a commit.' -Evidence $null)) | Out-Null } # FR-025 (iteration 002, T016): ONE CHECK, ONE JOB. `pushed-head` used to carry two: release-model # DELIVERY (what the schema says it governs - "release_model controls only applicable closeout delivery # steps") and, silently, the DURABILITY of the commit a verdict binds to. Aimed at delivery, it fired at # `specify` and demanded a published repository to clear a SPECIFICATION (the HelloWinUIReactive walk, # blocked there). Split into two named checks, each with its own message and its own job. $release = Resolve-SpecrewReleaseModel -ProjectRoot $root $branch = [string](@(& git -C $root branch --show-current 2>$null) | Select-Object -First 1) $originUrl = @(& git -C $root remote get-url origin 2>$null) $hasOrigin = ($LASTEXITCODE -eq 0 -and $originUrl.Count -gt 0) $remoteHeadForBranch = '' if ($hasOrigin -and -not [string]::IsNullOrWhiteSpace($branch)) { $remoteLine = [string](@(& git -C $root ls-remote --heads origin ("refs/heads/{0}" -f $branch) 2>$null) | Select-Object -First 1) if ($remoteLine -match '^(?<hash>[0-9a-f]{40,64})\s+') { $remoteHeadForBranch = $Matches['hash'] } } $enforcementMode = Get-SpecrewGatePreflightEnforcementMode -ProjectRoot $root # --- DELIVERY: pushed-head, at the boundaries that actually deliver ----------------------------------- $deliveryBoundaries = @('iteration-closeout', 'feature-closeout') if ($BoundaryType -notin $deliveryBoundaries) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status not-applicable -Message ("Release model '{0}' governs closeout delivery only; nothing is owed to origin at the '{1}' boundary." -f $release.Model, $BoundaryType) -Evidence @{ release_model = $release.Model; boundary = $BoundaryType })) | Out-Null } elseif ([string]$release.Model -eq 'local-only') { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status not-applicable -Message 'The recorded release model is local-only; no remote push is owed.' -Evidence @{ release_model = $release.Model })) | Out-Null } elseif (-not $hasOrigin) { # The declared-but-not-yet-created remote: the DevOps lens records the agreed future posture and says # honestly, in the same file, that it is not active yet (enforcement_mode: manual). Reading only # release_model made that honesty the thing that blocked the walk - the inert-fact shape, where the # fact needed to decide is present and unread. if ([string]::IsNullOrWhiteSpace($enforcementMode) -or $enforcementMode -eq 'manual') { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status not-applicable -Message ("Release model '{0}' is recorded with enforcement_mode '{1}' and no origin exists yet: the remote posture is a declared intention, not an active obligation. Nothing is owed now; delivery to origin becomes owed at the first closeout after the remote exists. To activate it, create the repository and add it as 'origin' - the recorded decision itself needs no change." -f $release.Model, $(if ([string]::IsNullOrWhiteSpace($enforcementMode)) { 'manual (no repository_governance block)' } else { $enforcementMode })) -Evidence @{ release_model = $release.Model; enforcement_mode = $enforcementMode })) | Out-Null } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status fail -Message ("Release model '{0}' with enforcement_mode '{1}' requires origin, but origin is not configured: the record claims active remote enforcement, and that cannot be true without a remote. Add the remote as 'origin', or record enforcement_mode: manual until it exists." -f $release.Model, $enforcementMode) -Evidence @{ release_model = $release.Model; enforcement_mode = $enforcementMode })) | Out-Null } } elseif ([string]::IsNullOrWhiteSpace($branch)) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status fail -Message 'A remote-delivered boundary cannot use a detached HEAD.' -Evidence @{ release_model = $release.Model })) | Out-Null } elseif ([string]::IsNullOrWhiteSpace($remoteHeadForBranch) -or $remoteHeadForBranch -cne $head) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status fail -Message "Branch '$branch' is not pushed to origin at the current HEAD." -Evidence @{ local_head = $head; remote_head = $remoteHeadForBranch; branch = $branch })) | Out-Null } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'pushed-head' -Status pass -Message "origin/$branch matches the current HEAD." -Evidence @{ head = $head; branch = $branch })) | Out-Null } # --- DURABILITY: verdict-commit-durable, at EVERY boundary -------------------------------------------- # The second job, now named. Every boundary records an auth_commit_hash and THREE readers resolve it back # against git: Get-SpecrewGitArtifactStateId derives the crossing's tree from it, Get-SpecrewBoundaryStage- # Evidence reads that tree, and the constraint-change path diffs it against the previous boundary commit. # A verdict bound to a commit that exists only locally is one rebase away from naming nothing. With an # origin this is exactly the requirement pushed-head used to impose everywhere - unchanged in strength, # only renamed to its job; without one it is an honest note, not a demand to publish. if (-not $hasOrigin) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'verdict-commit-durable' -Status not-applicable -Message ("Boundary verdicts bind to commit '{0}' and later gates resolve it back. No origin is configured, so this local history is the only copy: do not rewrite or drop this commit. When a remote exists, pushing the branch makes the record durable." -f $(if ($head.Length -ge 8) { $head.Substring(0, 8) } else { $head })) -Evidence @{ head = $head })) | Out-Null } elseif ([string]::IsNullOrWhiteSpace($branch)) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'verdict-commit-durable' -Status fail -Message 'Boundary verdicts bind to the current commit and later gates resolve it back, but HEAD is detached: there is no branch whose remote copy could preserve it. Check out the feature branch before recording a boundary.' -Evidence @{ head = $head })) | Out-Null } elseif ([string]::IsNullOrWhiteSpace($remoteHeadForBranch) -or $remoteHeadForBranch -cne $head) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'verdict-commit-durable' -Status fail -Message ("Boundary verdicts bind to commit '{0}' and later gates resolve it back (stage evidence, constraint diffs, review coverage). It exists only in this local history: origin/{1} is at '{2}'. Push the branch so the commit the verdict names survives a rebase or a lost checkout: git push origin {1}" -f $(if ($head.Length -ge 8) { $head.Substring(0, 8) } else { $head }), $branch, $(if ([string]::IsNullOrWhiteSpace($remoteHeadForBranch)) { 'no such branch' } elseif ($remoteHeadForBranch.Length -ge 8) { $remoteHeadForBranch.Substring(0, 8) } else { $remoteHeadForBranch })) -Evidence @{ local_head = $head; remote_head = $remoteHeadForBranch; branch = $branch })) | Out-Null } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'verdict-commit-durable' -Status pass -Message ("origin/{0} carries the commit this boundary binds to." -f $branch) -Evidence @{ head = $head; branch = $branch })) | Out-Null } $governancePath = Join-Path $root '.specrew/repository-governance.yml' $trunkName = $null if (Test-Path -LiteralPath $governancePath -PathType Leaf) { foreach ($line in Get-Content -LiteralPath $governancePath -Encoding UTF8) { if ($line -match '^\s*release_truth_branch:\s*["'']?(?<value>[^"''#\s]+)') { $trunkName = $Matches['value']; break } } } $trunkCandidates = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace($trunkName)) { $trunkCandidates.Add("origin/$trunkName") | Out-Null $trunkCandidates.Add($trunkName) | Out-Null } foreach ($fallback in @('origin/main', 'origin/master', 'main', 'master')) { $trunkCandidates.Add($fallback) | Out-Null } $trunkRef = $null foreach ($candidate in $trunkCandidates) { if ([string]::IsNullOrWhiteSpace([string]$candidate)) { continue } & git -C $root rev-parse --verify --quiet ([string]$candidate) 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { $trunkRef = [string]$candidate; break } } if ($null -eq $trunkRef) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'ahead-count' -Status not-applicable -Message 'No trunk ref is present yet; ahead count is not applicable to this greenfield history.' -Evidence $null)) | Out-Null } else { $aheadRaw = [string](@(& git -C $root rev-list --count ("{0}..HEAD" -f $trunkRef) 2>$null) | Select-Object -First 1) if ($LASTEXITCODE -ne 0 -or $aheadRaw -notmatch '^\d+$') { $checks.Add((New-SpecrewGatePreflightCheck -Name 'ahead-count' -Status fail -Message "Ahead count could not be computed from '$trunkRef'." -Evidence $null)) | Out-Null } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'ahead-count' -Status pass -Message "HEAD is $aheadRaw commit(s) ahead of $trunkRef." -Evidence @{ ahead_count = [int]$aheadRaw; trunk_ref = $trunkRef })) | Out-Null } } $statusRecords = @(Get-SpecrewGatePreflightStatus -ProjectRoot $root) $nonRecordStatus = @($statusRecords | Where-Object { $_.writer -ne 'governance-record' }) if ($nonRecordStatus.Count -gt 0) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'working-tree' -Status fail -Message ("The boundary tree has {0} uncommitted product/methodology path(s); commit or dispose them before rendering a packet." -f $nonRecordStatus.Count) -Evidence $statusRecords)) | Out-Null } elseif ($statusRecords.Count -gt 0) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'working-tree' -Status pass -Message ("Only {0} writer-owned governance record path(s) are uncommitted; they are classified explicitly and do not disguise product work." -f $statusRecords.Count) -Evidence $statusRecords)) | Out-Null } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'working-tree' -Status pass -Message 'The working tree is clean.' -Evidence @())) | Out-Null } $featurePath = if ([string]::IsNullOrWhiteSpace($FeatureRef)) { $null } else { Join-Path $root ('specs/' + (Split-Path -Leaf $FeatureRef)) } $iterationPath = if ($null -eq $featurePath -or [string]::IsNullOrWhiteSpace($IterationNumber)) { $null } else { Join-Path $featurePath ('iterations/' + $IterationNumber) } $taskPath = if ($null -eq $iterationPath) { $null } else { Join-Path $iterationPath 'tasks-progress.yml' } $statePath = if ($null -eq $iterationPath) { $null } else { Join-Path $iterationPath 'state.md' } if ($null -ne $taskPath -and (Test-Path -LiteralPath $taskPath -PathType Leaf) -and (Test-Path -LiteralPath $statePath -PathType Leaf)) { $tasks = Get-SpecrewGatePreflightTaskState -Path $taskPath $allowed = @('pending', 'in-progress', 'done', 'blocked', 'needs-rework', 'deferred') $bad = @($tasks.GetEnumerator() | Where-Object { [string]::IsNullOrWhiteSpace([string]$_.Value) -or [string]$_.Value -notin $allowed }) $expectedRemaining = @($tasks.GetEnumerator() | Where-Object { [string]$_.Value -ne 'done' } | ForEach-Object { [string]$_.Key } | Sort-Object) $expectedProgress = @($tasks.GetEnumerator() | Where-Object { [string]$_.Value -eq 'in-progress' } | ForEach-Object { [string]$_.Key } | Sort-Object) $remainingValue = Get-SpecrewGatePreflightMarkdownValue -Path $statePath -Label 'Tasks Remaining' $progressValue = Get-SpecrewGatePreflightMarkdownValue -Path $statePath -Label 'In Progress' $actualRemaining = @(ConvertFrom-SpecrewGatePreflightIdList -Value $remainingValue | Sort-Object) $actualProgress = @(ConvertFrom-SpecrewGatePreflightIdList -Value $progressValue | Sort-Object) $consistent = $bad.Count -eq 0 -and ($expectedRemaining -join ',') -ceq ($actualRemaining -join ',') -and ($expectedProgress -join ',') -ceq ($actualProgress -join ',') if ($consistent) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'task-state' -Status pass -Message 'Task status values and state.md summaries agree.' -Evidence @{ task_count = $tasks.Count })) | Out-Null } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'task-state' -Status fail -Message 'tasks-progress.yml contains an invalid status or disagrees with state.md Tasks Remaining/In Progress.' -Evidence @{ invalid = @($bad | ForEach-Object { "{0}:{1}" -f $_.Key, $_.Value }); expected_remaining = $expectedRemaining; actual_remaining = $actualRemaining; expected_in_progress = $expectedProgress; actual_in_progress = $actualProgress })) | Out-Null } } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'task-state' -Status not-applicable -Message 'No paired tasks-progress.yml/state.md exists at this stage.' -Evidence $null)) | Out-Null } $contract = @(Get-SpecrewBoundaryStageEvidenceContract | Where-Object { $_.Boundary -ceq $BoundaryType }) $missing = New-Object System.Collections.Generic.List[string] $owedUnverifiable = $false if ($contract.Count -eq 1 -and [string]$contract[0].Kind -ne 'none') { $base = if ([string]$contract[0].Kind -eq 'iteration-file') { $iterationPath } else { $featurePath } # ABSENT AND UNVERIFIABLE ARE DIFFERENT ANSWERS, and this check used to give the first for both. # # Found in the HelloWinUIReactive walk, 2026-08-30: with no resolvable feature - the ordinary state # of a project whose start-context carries no feature_ref - $base is null, every owed path was added # to $missing, and the gate reported `Boundary 'specify' is missing owed evidence: spec.md` for a # spec.md THAT EXISTS ON DISK. Naming the same project with -Feature makes the identical check pass. # A consumer is told a file they are looking at is missing, which is the worst kind of refusal: it # is specific, confident and false. # # The sibling implementation of this exact question already draws the distinction. FR-024's # Test-SpecrewBoundaryOwedArtifactsOnDisk returns Absent=$false with no feature identity, because a # positive ABSENT reading refuses loudly while UNVERIFIABLE keeps today's behaviour - method rule 12, # fail open on the diagnosis and say so. Two readers of one contract disagreed, and the one a human # meets at a boundary was the wrong one. if ($null -eq $base) { $owedUnverifiable = $true } else { foreach ($relative in @($contract[0].Paths)) { $path = Join-Path $base ([string]$relative) if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { $missing.Add([string]$relative) | Out-Null } } } if ([string]$contract[0].Kind -eq 'content' -and $missing.Count -eq 0) { $content = Get-Content -LiteralPath (Join-Path $featurePath ([string]$contract[0].Paths[0])) -Raw -Encoding UTF8 $markerMatches = @($contract[0].Markers | Where-Object { $content -match [string]$_ }) if ($markerMatches.Count -eq 0) { $missing.Add(([string]$contract[0].Paths[0] + ' required content')) | Out-Null } } } if ($owedUnverifiable) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'owed-artifact' -Status not-applicable -Message ("Boundary '$BoundaryType' owes evidence, but this run could not resolve which feature it belongs to, so nothing was checked. Name the feature to check it: pass -Feature <feature-ref>, or record the active feature in .specrew/start-context.json.") -Evidence $null)) | Out-Null } elseif ($missing.Count -gt 0) { $checks.Add((New-SpecrewGatePreflightCheck -Name 'owed-artifact' -Status fail -Message ("Boundary '$BoundaryType' is missing owed evidence: {0}." -f ($missing -join ', ')) -Evidence $missing.ToArray())) | Out-Null } else { $checks.Add((New-SpecrewGatePreflightCheck -Name 'owed-artifact' -Status pass -Message "Boundary '$BoundaryType' has its owed artifact floor." -Evidence $null)) | Out-Null } $result = [pscustomobject][ordered]@{ schema_version = '1.0' ok = @($checks | Where-Object { $_.status -eq 'fail' }).Count -eq 0 project_root = $root boundary = $BoundaryType feature_ref = $FeatureRef iteration = $IterationNumber checks = $checks.ToArray() } if ($FailOnError.IsPresent -and -not $result.ok) { $failures = @($result.checks | Where-Object { $_.status -eq 'fail' } | ForEach-Object { "[{0}] {1}" -f $_.name, $_.message }) throw ("Boundary gate preflight failed before packet/state mutation:`n - " + ($failures -join "`n - ")) } return $result } |