extensions/specrew-speckit/scripts/drift-diff.ps1
|
[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$SpecPath, [Parameter(Mandatory = $true)] [string]$TaskId, [Parameter(Mandatory = $true)] [string]$ImplementationPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sharedGovernancePath = Join-Path $PSScriptRoot 'shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Missing shared governance helper '$sharedGovernancePath'." } . $sharedGovernancePath function Get-MarkdownContent { param([string]$Path) return @(Get-Content -LiteralPath $Path -Encoding UTF8) } function Get-RequirementSummaryMap { param([string[]]$Lines) $requirements = [ordered]@{} foreach ($line in $Lines) { if ($line -match '^\s*-\s+\*\*(FR-\d+)\*\*:\s+(.+?)\s*$') { $requirements[$Matches[1]] = $Matches[2].Trim() } } return $requirements } function Get-ImplementationEvidence { param([string]$Path) $resolvedPath = Resolve-ProjectPath -Path $Path if (-not (Test-Path -LiteralPath $resolvedPath)) { throw "Implementation path '$resolvedPath' does not exist." } if (Test-Path -LiteralPath $resolvedPath -PathType Leaf) { return [pscustomobject]@{ ResolvedPath = $resolvedPath EvidenceText = [System.IO.File]::ReadAllText($resolvedPath, [System.Text.UTF8Encoding]::new($false)) } } $files = @(Get-ChildItem -LiteralPath $resolvedPath -Recurse -File | Sort-Object FullName) if ($files.Count -eq 0) { throw "Implementation path '$resolvedPath' does not contain any files." } $sections = foreach ($file in $files) { $relativePath = $file.FullName.Substring($resolvedPath.TrimEnd('\').Length).TrimStart('\') @( ('# File: {0}' -f $relativePath), [System.IO.File]::ReadAllText($file.FullName, [System.Text.UTF8Encoding]::new($false)) ) -join [Environment]::NewLine } return [pscustomobject]@{ ResolvedPath = $resolvedPath EvidenceText = $sections -join ([Environment]::NewLine + [Environment]::NewLine) } } function Get-RequirementRefFromEvidence { param([string]$EvidenceText) $explicitMatch = [regex]::Match($EvidenceText, '(?im)^\s*RequirementRef\s*:\s*(FR-\d+)\s*$') if ($explicitMatch.Success) { return $explicitMatch.Groups[1].Value } $inlineMatch = [regex]::Match($EvidenceText, '\b(FR-\d+)\b') if ($inlineMatch.Success) { return $inlineMatch.Groups[1].Value } return $null } function Get-RequirementConstraints { param([string]$RequirementText) $requiredTokens = [regex]::Matches($RequirementText, '(?i)must\s+(?:include|contain)\s+`([^`]+)`') | ForEach-Object { $_.Groups[1].Value.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $forbiddenTokens = [regex]::Matches($RequirementText, '(?i)must\s+not\s+(?:include|contain)\s+`([^`]+)`') | ForEach-Object { $_.Groups[1].Value.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } return [pscustomobject]@{ RequiredTokens = @($requiredTokens) ForbiddenTokens = @($forbiddenTokens) } } function Test-EvidenceContainsToken { param( [string]$EvidenceText, [string]$Token ) $escapedToken = [regex]::Escape($Token) $pattern = if ($Token -match '^[A-Za-z0-9_-]+$') { '(?<![A-Za-z0-9_-]){0}(?![A-Za-z0-9_-])' -f $escapedToken } else { $escapedToken } $regex = [regex]::new( $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase ) return $regex.IsMatch($EvidenceText) } $resolvedSpecPath = Resolve-ProjectPath -Path $SpecPath if (-not (Test-Path -LiteralPath $resolvedSpecPath -PathType Leaf)) { throw "Spec file '$resolvedSpecPath' does not exist." } $specLines = @(Get-MarkdownContent -Path $resolvedSpecPath) $requirements = Get-RequirementSummaryMap -Lines $specLines if ($requirements.Count -eq 0) { throw "Spec file '$resolvedSpecPath' does not contain any FR requirement summaries." } $implementationEvidence = Get-ImplementationEvidence -Path $ImplementationPath $requirementRef = Get-RequirementRefFromEvidence -EvidenceText $implementationEvidence.EvidenceText if ([string]::IsNullOrWhiteSpace($requirementRef)) { throw "Implementation evidence '$($implementationEvidence.ResolvedPath)' does not declare a RequirementRef." } if (-not $requirements.Contains($requirementRef)) { throw "Requirement '$requirementRef' was not found in '$resolvedSpecPath'." } $requirementText = [string]$requirements[$requirementRef] $constraints = Get-RequirementConstraints -RequirementText $requirementText $timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $driftEvents = New-Object System.Collections.Generic.List[object] $descriptions = New-Object System.Collections.Generic.List[string] $evidenceSummaryParts = New-Object System.Collections.Generic.List[string] $eventCounter = 1 foreach ($token in $constraints.RequiredTokens) { if (Test-EvidenceContainsToken -EvidenceText $implementationEvidence.EvidenceText -Token $token) { $null = $evidenceSummaryParts.Add(('contains required token `{0}`' -f $token)) continue } $description = ('Delivered output is missing required token `{0}`.' -f $token) $null = $descriptions.Add($description) $driftEvents.Add([pscustomobject]@{ type = 'incomplete' drift_id = ('DR-{0:000}' -f $eventCounter) detected_at = $timestamp task_ref = $TaskId requirement_ref = $requirementRef severity = 'moderate' description = $description requirement_citation = $requirementText resolution = 'implementation-reverted' resolution_detail = ('Update the delivered output so it includes required token `{0}`.' -f $token) log_snippet = @( ('- **DR-{0:000}**: Detected {1} during {2}' -f $eventCounter, $timestamp.Substring(0, 10), $TaskId), (' - **Requirement**: {0}' -f $requirementRef), (' - **Deviation**: {0}' -f $description), ' - **Resolution**: implementation-reverted', (' - **Detail**: Update the delivered output so it includes required token `{0}`.' -f $token) ) -join [Environment]::NewLine }) $eventCounter++ } foreach ($token in $constraints.ForbiddenTokens) { if (-not (Test-EvidenceContainsToken -EvidenceText $implementationEvidence.EvidenceText -Token $token)) { $null = $evidenceSummaryParts.Add(('does not contain forbidden token `{0}`' -f $token)) continue } $description = ('Delivered output contains forbidden token `{0}`.' -f $token) $null = $descriptions.Add($description) $driftEvents.Add([pscustomobject]@{ type = 'violation' drift_id = ('DR-{0:000}' -f $eventCounter) detected_at = $timestamp task_ref = $TaskId requirement_ref = $requirementRef severity = 'critical' description = $description requirement_citation = $requirementText resolution = 'implementation-reverted' resolution_detail = ('Remove forbidden token `{0}` from the delivered output.' -f $token) log_snippet = @( ('- **DR-{0:000}**: Detected {1} during {2}' -f $eventCounter, $timestamp.Substring(0, 10), $TaskId), (' - **Requirement**: {0}' -f $requirementRef), (' - **Deviation**: {0}' -f $description), ' - **Resolution**: implementation-reverted', (' - **Detail**: Remove forbidden token `{0}` from the delivered output.' -f $token) ) -join [Environment]::NewLine }) $eventCounter++ } if ($constraints.RequiredTokens.Count -eq 0 -and $constraints.ForbiddenTokens.Count -eq 0) { $description = 'Requirement text does not contain explicit `MUST include/contain` or `MUST NOT include/contain` constraints that drift-diff.ps1 can evaluate.' $driftEvents.Add([pscustomobject]@{ type = 'human-decision' drift_id = 'DR-001' detected_at = $timestamp task_ref = $TaskId requirement_ref = $requirementRef severity = 'moderate' description = $description requirement_citation = $requirementText resolution = 'human-decision' resolution_detail = 'Refine the requirement or evaluate this task manually with the Spec Steward.' log_snippet = @( ('- **DR-001**: Detected {0} during {1}' -f $timestamp.Substring(0, 10), $TaskId), (' - **Requirement**: {0}' -f $requirementRef), (' - **Deviation**: {0}' -f $description), ' - **Resolution**: human-decision', ' - **Detail**: Refine the requirement or evaluate this task manually with the Spec Steward.' ) -join [Environment]::NewLine }) } $result = if ($driftEvents.Count -gt 0) { [pscustomobject]@{ verdict = 'DRIFT' task_ref = $TaskId requirement_ref = $requirementRef requirement_text = $requirementText evidence_summary = if ($descriptions.Count -gt 0) { $descriptions -join ' ' } else { 'Drift detected.' } drift_events = $driftEvents drift_log_update_note = 'Replace the zero-drift summary in drift-log.md with the real event count and resolution status before review accepts the iteration.' } } else { [pscustomobject]@{ verdict = 'PASS' task_ref = $TaskId requirement_ref = $requirementRef requirement_text = $requirementText evidence_summary = if ($evidenceSummaryParts.Count -gt 0) { $evidenceSummaryParts -join '; ' } else { 'Evidence matches the requirement.' } drift_events = @() drift_log_update_note = 'No drift-log update is required.' } } $result | ConvertTo-Json -Depth 6 exit 0 |