extensions/specrew-speckit/scripts/run-hardening-gate.ps1
|
[CmdletBinding()] param( [string]$ProjectPath = (Get-Location).Path, [string]$FeaturePath, [string]$IterationPath, [string]$SpecPath, [string]$ReviewedBy, [string]$ReviewedAt, [ValidateSet('Object', 'Json', 'Markdown')] [string]$OutputFormat = 'Object' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sharedGovernancePath = Join-Path $PSScriptRoot 'shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Shared governance helper not found at '$sharedGovernancePath'." } . $sharedGovernancePath function Convert-ToRepoRelativePath { param( [Parameter(Mandatory = $true)] [string]$BasePath, [Parameter(Mandatory = $true)] [string]$TargetPath ) # Cross-platform safe replacement for the legacy [System.Uri] MakeRelativeUri pattern, # which fails on Linux for bare absolute paths. $baseFull = [System.IO.Path]::GetFullPath($BasePath) $targetFull = [System.IO.Path]::GetFullPath($TargetPath) return ([System.IO.Path]::GetRelativePath($baseFull, $targetFull)) -replace '\\', '/' } function Resolve-HardeningContext { param( [Parameter(Mandatory = $true)] [string]$ProjectRoot, [string]$FeaturePath, [string]$IterationPath, [string]$SpecPath ) $resolvedFeaturePath = $null if (-not [string]::IsNullOrWhiteSpace($FeaturePath)) { $resolvedFeaturePath = Resolve-ProjectPath -Path $FeaturePath } elseif (-not [string]::IsNullOrWhiteSpace($SpecPath)) { $resolvedFeaturePath = Split-Path -Parent (Resolve-ProjectPath -Path $SpecPath) } $resolvedIterationPath = $null if (-not [string]::IsNullOrWhiteSpace($IterationPath)) { $resolvedIterationPath = Resolve-ProjectPath -Path $IterationPath if ([string]::IsNullOrWhiteSpace($resolvedFeaturePath)) { $resolvedFeaturePath = Split-Path -Parent (Split-Path -Parent $resolvedIterationPath) } } if ([string]::IsNullOrWhiteSpace($resolvedFeaturePath)) { $specsRoot = Join-Path $ProjectRoot 'specs' if (Test-Path -LiteralPath $specsRoot -PathType Container) { $featureCandidates = @( Get-ChildItem -LiteralPath $specsRoot -Directory | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'spec.md') -PathType Leaf } | Sort-Object Name ) if ($featureCandidates.Count -eq 1) { $resolvedFeaturePath = $featureCandidates[0].FullName } elseif ($featureCandidates.Count -gt 1) { $iterationCandidates = foreach ($featureCandidate in $featureCandidates) { $iterationsRoot = Join-Path $featureCandidate.FullName 'iterations' if (-not (Test-Path -LiteralPath $iterationsRoot -PathType Container)) { continue } foreach ($directory in Get-ChildItem -LiteralPath $iterationsRoot -Directory | Sort-Object Name) { $numericValue = 0 if ([int]::TryParse($directory.Name, [ref]$numericValue)) { [pscustomobject]@{ FeaturePath = $featureCandidate.FullName IterationPath = $directory.FullName IterationNumber = $numericValue } } } } $selectedCandidate = @($iterationCandidates | Sort-Object IterationNumber -Descending | Select-Object -First 1)[0] if ($null -ne $selectedCandidate) { $resolvedFeaturePath = $selectedCandidate.FeaturePath if ([string]::IsNullOrWhiteSpace($resolvedIterationPath)) { $resolvedIterationPath = $selectedCandidate.IterationPath } } } } } if ([string]::IsNullOrWhiteSpace($resolvedFeaturePath)) { throw 'Unable to resolve a feature path for the hardening gate. Provide -FeaturePath, -SpecPath, or -IterationPath.' } if ([string]::IsNullOrWhiteSpace($resolvedIterationPath)) { $iterationsRoot = Join-Path $resolvedFeaturePath 'iterations' if (Test-Path -LiteralPath $iterationsRoot -PathType Container) { $iterationDirectories = foreach ($directory in Get-ChildItem -LiteralPath $iterationsRoot -Directory | Sort-Object Name) { $numericValue = 0 if ([int]::TryParse($directory.Name, [ref]$numericValue)) { [pscustomobject]@{ FullName = $directory.FullName IterationNumber = $numericValue } } } $resolvedIterationPath = @( $iterationDirectories | Sort-Object IterationNumber -Descending | Select-Object -First 1 | ForEach-Object { $_.FullName } )[0] } } if ([string]::IsNullOrWhiteSpace($resolvedIterationPath)) { throw "Unable to resolve an iteration path under '$resolvedFeaturePath'." } $resolvedSpecPath = if (-not [string]::IsNullOrWhiteSpace($SpecPath)) { Resolve-ProjectPath -Path $SpecPath } else { Join-Path $resolvedFeaturePath 'spec.md' } if (-not (Test-Path -LiteralPath $resolvedSpecPath -PathType Leaf)) { throw "Spec path '$resolvedSpecPath' does not exist." } return [pscustomobject]@{ ProjectRoot = $ProjectRoot FeaturePath = $resolvedFeaturePath IterationPath = $resolvedIterationPath SpecPath = $resolvedSpecPath FeatureRef = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $resolvedSpecPath IterationRef = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $resolvedIterationPath } } function Get-HardeningConcernDefinitions { return @( [pscustomobject]@{ Concern = 'security-surface' Category = 'security' Rationale = 'Scaffolded placeholder. Review trust boundaries, privilege changes, and sensitive flows before implementation proceeds.' ExpectedControls = 'Document trust boundaries, authorization expectations, and the runtime verification signals required before closure.' } [pscustomobject]@{ Concern = 'error-handling-expectations' Category = 'error-handling' Rationale = 'Scaffolded placeholder. Record expected failure semantics and incomplete-state handling before implementation proceeds.' ExpectedControls = 'Describe fail-closed behavior, surfaced errors, and the validation evidence that will confirm the failure contract once implementation exists.' } [pscustomobject]@{ Concern = 'retry-idempotency-requirements' Category = 'retry-idempotency' Rationale = 'Scaffolded placeholder. Confirm whether retries/idempotency are required or explicitly not applicable.' ExpectedControls = 'Record whether retries are forbidden, idempotent, or not applicable for this slice before implementation begins.' } [pscustomobject]@{ Concern = 'test-integrity-targets' Category = 'test-integrity' Rationale = 'Scaffolded placeholder. Tie negative-path expectations to observable test evidence before implementation proceeds.' ExpectedControls = 'Name the deterministic regression assertions and observable validation commands required before the slice can close.' } [pscustomobject]@{ Concern = 'operational-resilience-concerns' Category = 'operational' Rationale = 'Scaffolded placeholder. Review runtime resilience, fallback, and operator-facing failure signals before implementation proceeds.' ExpectedControls = 'Record runtime resilience checks, rollback expectations, and the operational proof required before closure.' } ) } function Get-MarkdownSectionLines { param( [AllowEmptyCollection()] [AllowEmptyString()] [Parameter(Mandatory = $true)] [string[]]$Lines, [Parameter(Mandatory = $true)] [string]$Heading ) $headingPattern = '^#{2,3}\s+' + [regex]::Escape($Heading) + '\b' $startIndex = -1 for ($index = 0; $index -lt $Lines.Count; $index++) { if ($Lines[$index] -match $headingPattern) { $startIndex = $index break } } if ($startIndex -lt 0) { return @() } $sectionLines = New-Object System.Collections.Generic.List[string] for ($index = $startIndex + 1; $index -lt $Lines.Count; $index++) { $currentLine = $Lines[$index] if ($currentLine -match '^#{2,3}\s+') { break } $sectionLines.Add($currentLine) | Out-Null } return $sectionLines.ToArray() } function Escape-MarkdownTableCell { param([AllowNull()][string]$Value) if ($null -eq $Value) { return '' } return ($Value -replace '\|', '\|').Trim() } function Get-CanonicalMarkdownToken { param([AllowNull()][string]$Value) $normalized = Normalize-MarkdownCell $Value if (Test-IsNullish $normalized) { return '—' } return $normalized } function Merge-HardeningConcernRows { param( [AllowNull()][object]$ExistingState ) $existingByConcern = @{} if ($null -ne $ExistingState) { foreach ($row in @($ExistingState.ConcernRows)) { $concernId = Normalize-MarkdownCell ([string]$row.Concern) if (-not [string]::IsNullOrWhiteSpace($concernId)) { $existingByConcern[$concernId] = $row } } } $rows = New-Object System.Collections.Generic.List[object] foreach ($definition in @(Get-HardeningConcernDefinitions)) { $existingRow = if ($existingByConcern.ContainsKey($definition.Concern)) { $existingByConcern[$definition.Concern] } else { $null } $existingStatus = if ($null -ne $existingRow) { [string]$existingRow.Status } else { $null } $status = Normalize-MarkdownCell $existingStatus if ([string]::IsNullOrWhiteSpace($status)) { $status = 'tbd' } $allowedStatuses = @('addressed', 'not-applicable', 'tbd', 'deferred-with-approval') if ($status.ToLowerInvariant() -notin $allowedStatuses) { $status = 'tbd' } $existingEvidenceBasis = if ($null -ne $existingRow) { [string]$existingRow.EvidenceBasis } else { $null } $evidenceBasis = Normalize-MarkdownCell $existingEvidenceBasis if (Test-IsNullish $evidenceBasis) { switch ($status) { 'addressed' { $evidenceBasis = 'planning-time-analysis' } 'deferred-with-approval' { $evidenceBasis = 'planning-time-analysis' } 'not-applicable' { $evidenceBasis = 'not-applicable' } default { $evidenceBasis = '—' } } } $existingRuntimeEvidenceStatus = if ($null -ne $existingRow) { [string]$existingRow.RuntimeEvidenceStatus } else { $null } $runtimeEvidenceStatus = Normalize-MarkdownCell $existingRuntimeEvidenceStatus if (Test-IsNullish $runtimeEvidenceStatus) { switch ($status) { 'addressed' { $runtimeEvidenceStatus = 'pending-post-implementation' } 'deferred-with-approval' { $runtimeEvidenceStatus = 'pending-post-implementation' } 'not-applicable' { $runtimeEvidenceStatus = 'not-needed' } default { $runtimeEvidenceStatus = '—' } } } $existingExpectedControls = if ($null -ne $existingRow) { [string]$existingRow.ExpectedControls } else { $null } $expectedControls = Normalize-MarkdownCell $existingExpectedControls if (Test-IsNullish $expectedControls) { if ($status -eq 'not-applicable') { $expectedControls = '—' } elseif ($status -eq 'tbd') { $expectedControls = '—' } else { $expectedControls = $definition.ExpectedControls } } $existingRationale = if ($null -ne $existingRow) { [string]$existingRow.Rationale } else { $null } $rationale = Normalize-MarkdownCell $existingRationale if (Test-IsNullish $rationale) { $rationale = $definition.Rationale } $existingApproval = if ($null -ne $existingRow) { [string]$existingRow.Approval } else { $null } $approval = Normalize-MarkdownCell $existingApproval if ($status -ne 'deferred-with-approval') { $approval = '—' } elseif (Test-IsNullish $approval) { $approval = '—' } $rows.Add([pscustomobject]@{ Concern = $definition.Concern Category = $definition.Category Status = $status EvidenceBasis = $evidenceBasis RuntimeEvidenceStatus = $runtimeEvidenceStatus ExpectedControls = $expectedControls Blocking = 'true' Rationale = $rationale Approval = $approval }) | Out-Null } return $rows.ToArray() } function Get-HardeningVerdict { param( [Parameter(Mandatory = $true)] [object[]]$ConcernRows, [Parameter(Mandatory = $true)] [string]$ProjectRoot ) $blockingConcerns = @( $ConcernRows | Where-Object { Test-HardeningConcernBlocksImplementation -Concern $_ -ProjectRoot $ProjectRoot } ) if ($blockingConcerns.Count -gt 0) { return [pscustomobject]@{ OverallVerdict = 'blocked' BlockingConcerns = $blockingConcerns BlocksImplementation = $true } } $hasApprovedDeferral = [bool]@( $ConcernRows | Where-Object { (Normalize-MarkdownCell ([string]$_.Status)).ToLowerInvariant() -eq 'deferred-with-approval' } | Select-Object -First 1 ) return [pscustomobject]@{ OverallVerdict = if ($hasApprovedDeferral) { 'deferred-with-approval' } else { 'ready' } BlockingConcerns = @() BlocksImplementation = $false } } function Get-GateApprovalReference { param( [AllowNull()][object]$ExistingState, [Parameter(Mandatory = $true)] [object[]]$ConcernRows, [Parameter(Mandatory = $true)] [string]$ProjectRoot, [Parameter(Mandatory = $true)] [string]$OverallVerdict ) if ($OverallVerdict -ne 'deferred-with-approval') { return '—' } $candidateRefs = New-Object System.Collections.Generic.List[string] foreach ($row in @($ConcernRows | Where-Object { (Normalize-MarkdownCell ([string]$_.Status)).ToLowerInvariant() -eq 'deferred-with-approval' })) { $approvalRef = Normalize-MarkdownCell ([string]$row.Approval) if (-not (Test-IsNullish $approvalRef) -and -not $candidateRefs.Contains($approvalRef)) { $candidateRefs.Add($approvalRef) | Out-Null } } $existingMetadataApproval = if ($null -ne $ExistingState) { Normalize-MarkdownCell ([string]$ExistingState.Metadata.ApprovalRef) } else { $null } if (-not (Test-IsNullish $existingMetadataApproval)) { $approvalRecord = Get-ApprovalReferenceRecord -ProjectRoot $ProjectRoot -ApprovalRef $existingMetadataApproval -AllowedTypes @('decision', 'defer') if ($null -ne $approvalRecord -and $approvalRecord.HasHumanApproval) { return $existingMetadataApproval } } if ($candidateRefs.Count -eq 0) { return '—' } return ($candidateRefs.ToArray() -join ', ') } function Get-HardeningNotes { param( [AllowNull()][object]$ExistingState, [AllowEmptyCollection()] [AllowEmptyString()] [Parameter(Mandatory = $true)] [string[]]$ExistingLines, [Parameter(Mandatory = $true)] [string]$OverallVerdict ) $existingNotes = @( Get-MarkdownSectionLines -Lines $ExistingLines -Heading 'Notes' | ForEach-Object { $_.TrimEnd() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } ) if ($existingNotes.Count -gt 0) { return $existingNotes } if ($null -ne $ExistingState) { return @() } switch ($OverallVerdict) { 'ready' { return @( '- All bounded pre-implementation hardening concerns currently resolve without blocking implementation.' '- Re-run this orchestration after any material scope change so readiness remains truthful.' ) } 'deferred-with-approval' { return @( '- At least one bounded hardening concern is deferred with explicit human approval and visible rationale.' '- Re-run this orchestration after the approved follow-up is completed or the deferral changes.' ) } default { return @( '- This artifact blocks implementation until every critical concern is addressed, explicitly not applicable with rationale, or deferred with human approval.' '- Re-run this orchestration after the concern rows are reviewed so the verdict stays truthful.' ) } } } function Get-HardeningGateContent { param( [Parameter(Mandatory = $true)] [pscustomobject]$Context, [AllowNull()][object]$ExistingState, [Parameter(Mandatory = $true)] [object[]]$ConcernRows, [Parameter(Mandatory = $true)] [string]$ReviewedBy, [Parameter(Mandatory = $true)] [string]$ReviewedAt, [Parameter(Mandatory = $true)] [string]$OverallVerdict, [Parameter(Mandatory = $true)] [string]$ApprovalRef, [AllowEmptyCollection()] [AllowEmptyString()] [Parameter(Mandatory = $true)] [string[]]$ExistingLines ) $requestedReviewClass = if ($null -ne $ExistingState) { Normalize-MarkdownCell ([string]$ExistingState.Metadata.RequestedReviewClass) } else { $null } if (Test-IsNullish $requestedReviewClass) { $requestedReviewClass = 'strongest-available' } $effectiveReviewClass = if ($null -ne $ExistingState) { Normalize-MarkdownCell ([string]$ExistingState.Metadata.EffectiveReviewClass) } else { $null } if (Test-IsNullish $effectiveReviewClass) { $effectiveReviewClass = '(pending hardening review)' } $notes = @(Get-HardeningNotes -ExistingState $ExistingState -ExistingLines $ExistingLines -OverallVerdict $OverallVerdict) $iterationNumber = Split-Path -Leaf $Context.IterationPath $lines = [System.Collections.Generic.List[string]]::new() $null = $lines.Add("# Hardening Gate: Iteration $iterationNumber") $null = $lines.Add('') $null = $lines.Add('**Schema**: v1') $null = $lines.Add('**Gate ID**: `pre-implementation-hardening`') $null = $lines.Add(('**Feature Ref**: `' + $Context.FeatureRef + '`')) $null = $lines.Add(('**Iteration Ref**: `' + $Context.IterationRef + '`')) $null = $lines.Add(('**Requested Review Class**: `' + $requestedReviewClass + '`')) $null = $lines.Add(('**Effective Review Class**: `' + $effectiveReviewClass + '`')) $null = $lines.Add(('**Overall Verdict**: `' + $OverallVerdict + '`')) $null = $lines.Add(('**Approval Ref**: `' + (Get-CanonicalMarkdownToken -Value $ApprovalRef) + '`')) $null = $lines.Add(('**Reviewed By**: ' + $ReviewedBy)) $null = $lines.Add(('**Reviewed At**: ' + $ReviewedAt)) $null = $lines.Add('') $null = $lines.Add('## Concern Review') $null = $lines.Add('') $null = $lines.Add('| Concern | Category | Status | Evidence Basis | Runtime Evidence Status | Expected Controls | Blocking | Rationale | Approval |') $null = $lines.Add('| --- | --- | --- | --- | --- | --- | --- | --- | --- |') foreach ($row in $ConcernRows) { $null = $lines.Add(( '| `{0}` | `{1}` | `{2}` | `{3}` | `{4}` | {5} | `{6}` | {7} | `{8}` |' -f (Escape-MarkdownTableCell -Value ([string]$row.Concern)), (Escape-MarkdownTableCell -Value ([string]$row.Category)), (Escape-MarkdownTableCell -Value ([string]$row.Status)), (Escape-MarkdownTableCell -Value ([string]$row.EvidenceBasis)), (Escape-MarkdownTableCell -Value ([string]$row.RuntimeEvidenceStatus)), (Escape-MarkdownTableCell -Value (Get-CanonicalMarkdownToken -Value ([string]$row.ExpectedControls))), (Escape-MarkdownTableCell -Value ([string]$row.Blocking)), (Escape-MarkdownTableCell -Value ([string]$row.Rationale)), (Escape-MarkdownTableCell -Value (Get-CanonicalMarkdownToken -Value ([string]$row.Approval))) )) } if ($notes.Count -gt 0) { $null = $lines.Add('') $null = $lines.Add('## Notes') $null = $lines.Add('') foreach ($note in $notes) { $null = $lines.Add($note) } } return ($lines -join [Environment]::NewLine) + [Environment]::NewLine } $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath if (-not (Test-Path -LiteralPath $resolvedProjectPath -PathType Container)) { throw "Project path '$resolvedProjectPath' does not exist." } $context = Resolve-HardeningContext -ProjectRoot $resolvedProjectPath -FeaturePath $FeaturePath -IterationPath $IterationPath -SpecPath $SpecPath $qualityDirectory = Join-Path $context.IterationPath 'quality' $hardeningGatePath = Join-Path $qualityDirectory 'hardening-gate.md' if (-not (Test-Path -LiteralPath $qualityDirectory -PathType Container)) { $null = New-Item -ItemType Directory -Path $qualityDirectory -Force } $existingLines = if (Test-Path -LiteralPath $hardeningGatePath -PathType Leaf) { @(Get-MarkdownContent -Path $hardeningGatePath) } else { @() } $existingState = if (Test-Path -LiteralPath $hardeningGatePath -PathType Leaf) { Get-HardeningGateState -Path $hardeningGatePath -ProjectRoot $resolvedProjectPath } else { $null } $effectiveReviewedBy = if (-not [string]::IsNullOrWhiteSpace($ReviewedBy)) { $ReviewedBy.Trim() } elseif ($null -ne $existingState -and -not (Test-IsNullish ([string]$existingState.Metadata.ReviewedBy))) { Normalize-MarkdownCell ([string]$existingState.Metadata.ReviewedBy) } else { 'Reviewer (pending)' } $effectiveReviewedAt = if (-not [string]::IsNullOrWhiteSpace($ReviewedAt)) { $ReviewedAt.Trim() } elseif ($null -ne $existingState -and -not (Test-IsNullish ([string]$existingState.Metadata.ReviewedAt))) { Normalize-MarkdownCell ([string]$existingState.Metadata.ReviewedAt) } else { (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } $concernRows = @(Merge-HardeningConcernRows -ExistingState $existingState) $verdict = Get-HardeningVerdict -ConcernRows $concernRows -ProjectRoot $resolvedProjectPath $approvalRef = Get-GateApprovalReference -ExistingState $existingState -ConcernRows $concernRows -ProjectRoot $resolvedProjectPath -OverallVerdict $verdict.OverallVerdict $content = Get-HardeningGateContent ` -Context $context ` -ExistingState $existingState ` -ConcernRows $concernRows ` -ReviewedBy $effectiveReviewedBy ` -ReviewedAt $effectiveReviewedAt ` -OverallVerdict $verdict.OverallVerdict ` -ApprovalRef $approvalRef ` -ExistingLines $existingLines Write-Utf8FileAtomic -Path $hardeningGatePath -Content $content $finalState = Get-HardeningGateState -Path $hardeningGatePath -ProjectRoot $resolvedProjectPath $result = [pscustomobject]@{ Path = $hardeningGatePath FeatureRef = $context.FeatureRef IterationRef = $context.IterationRef OverallVerdict = [string]$finalState.Metadata.OverallVerdict ApprovalRef = [string]$finalState.Metadata.ApprovalRef ReviewedBy = [string]$finalState.Metadata.ReviewedBy ReviewedAt = [string]$finalState.Metadata.ReviewedAt BlocksImplementation = [bool]$finalState.BlocksImplementation BlockingConcernIds = @($finalState.BlockingConcerns | ForEach-Object { [string]$_.Concern }) ConcernRows = @($finalState.ConcernRows) } switch ($OutputFormat) { 'Json' { $result | ConvertTo-Json -Depth 8 } 'Markdown' { $content } default { $result } } |