scripts/internal/continuous-co-review/review-run-index-writer.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function ConvertTo-ContinuousCoReviewRunIndexIsoTimestamp { param( [datetime] $Timestamp = [datetime]::UtcNow ) return $Timestamp.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) } function Get-ContinuousCoReviewRunIndexProperty { param( [Parameter(Mandatory)] [AllowNull()] $Object, [Parameter(Mandatory)] [string] $Name ) if ($null -eq $Object) { return $null } if (Test-ReviewerContractPropertyExists -Object $Object -Name $Name) { return Get-ReviewerContractPropertyValue -Object $Object -Name $Name } return $null } function Write-ContinuousCoReviewRunIndexJson { param( [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [AllowNull()] $InputObject ) $json = $InputObject | ConvertTo-Json -Depth 100 if (Test-Path -LiteralPath $Path -PathType Leaf) { $existingJson = Get-Content -LiteralPath $Path -Raw $existingCanonical = ConvertTo-ReviewerContractCanonicalJson -InputObject (ConvertFrom-ReviewerContractJson -Json $existingJson) $incomingCanonical = ConvertTo-ReviewerContractCanonicalJson -InputObject $InputObject if ($existingCanonical -ne $incomingCanonical) { throw "Durable review run index artifact already exists with different content: $Path" } return } $parent = Split-Path -Parent $Path New-Item -ItemType Directory -Path $parent -Force | Out-Null Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 -NoNewline } function New-ContinuousCoReviewRunIndexRecord { param( [Parameter(Mandatory)] [string] $RunId, [Parameter(Mandatory)] [string] $CheckpointId, [Parameter(Mandatory)] [string] $BaselineRef, [AllowNull()] [string] $DiffHash, [AllowNull()] [string] $ReviewedRef, [AllowNull()] $ReviewRequest, [AllowNull()] $RequestBundle, [AllowNull()] $SpawnInvocation, [AllowNull()] $FindingsResult, [AllowNull()] $InfrastructureFailure, [AllowNull()] $ReviewThread, [AllowNull()] $GateVerdict, [AllowNull()] $CleanupResult, [AllowNull()] [string] $Scope, [AllowNull()] [string] $ReviewedTreeId, # T094/FR-036 (iter-009 D4): the 3-dimension evidence labels # { completeness: full|partial; independence: independent|same-host|unverified; # budget: normal|time-extended } - the tiered signoff gate's assurance input. [AllowNull()] $EvidenceLabels, [datetime] $CreatedAt = [datetime]::UtcNow ) $createdAtText = ConvertTo-ContinuousCoReviewRunIndexIsoTimestamp -Timestamp $CreatedAt $requestHash = Get-ContinuousCoReviewRunIndexProperty -Object $ReviewRequest -Name 'request_hash' if ($null -eq $requestHash) { $requestHash = Get-ContinuousCoReviewRunIndexProperty -Object $RequestBundle -Name 'request_hash' } # T058 / FR-027: the gate floor (FR-025) recomputes freshness from baseline_ref to # the working tree and matches diff_hash, and the incremental baseline advances to # reviewed_ref only on a pass. Surface both directly on the durable run record so # neither the gate nor the next review has to re-parse review-request.json. $resolvedDiffHash = if (-not [string]::IsNullOrWhiteSpace($DiffHash)) { $DiffHash } else { $changeSet = Get-ContinuousCoReviewRunIndexProperty -Object $ReviewRequest -Name 'change_set' Get-ContinuousCoReviewRunIndexProperty -Object $changeSet -Name 'diff_hash' } $status = 'recorded' if ($null -ne $GateVerdict) { $status = Get-ContinuousCoReviewRunIndexProperty -Object $GateVerdict -Name 'state' } elseif ($null -ne $InfrastructureFailure) { $status = 'infrastructure_failure' } $requestedHost = Get-ContinuousCoReviewRunIndexProperty -Object $SpawnInvocation -Name 'requested_host' $requestedModel = Get-ContinuousCoReviewRunIndexProperty -Object $SpawnInvocation -Name 'requested_model' if ($null -eq $requestedHost -and $null -ne $ReviewRequest) { $providerRequest = Get-ContinuousCoReviewRunIndexProperty -Object $ReviewRequest -Name 'provider_request' $requestedHost = Get-ContinuousCoReviewRunIndexProperty -Object $providerRequest -Name 'requested_host' $requestedModel = Get-ContinuousCoReviewRunIndexProperty -Object $providerRequest -Name 'requested_model' } $actualHost = Get-ContinuousCoReviewRunIndexProperty -Object $SpawnInvocation -Name 'actual_host' $actualModel = Get-ContinuousCoReviewRunIndexProperty -Object $SpawnInvocation -Name 'actual_model' if (($null -eq $actualHost -or $null -eq $actualModel) -and $null -ne $FindingsResult) { $reviewer = Get-ContinuousCoReviewRunIndexProperty -Object $FindingsResult -Name 'reviewer' if ($null -eq $actualHost) { $actualHost = Get-ContinuousCoReviewRunIndexProperty -Object $reviewer -Name 'host' } if ($null -eq $actualModel) { $actualModel = Get-ContinuousCoReviewRunIndexProperty -Object $reviewer -Name 'model' } } $adapterId = Get-ContinuousCoReviewRunIndexProperty -Object $SpawnInvocation -Name 'adapter_id' if ($null -eq $adapterId -and $null -ne $FindingsResult) { $reviewer = Get-ContinuousCoReviewRunIndexProperty -Object $FindingsResult -Name 'reviewer' $adapterId = Get-ContinuousCoReviewRunIndexProperty -Object $reviewer -Name 'adapter_id' } return [pscustomobject][ordered]@{ schema_version = '1.0' run_id = $RunId checkpoint_id = $CheckpointId baseline_ref = $BaselineRef diff_hash = $resolvedDiffHash reviewed_ref = $ReviewedRef reviewed_tree_id = $ReviewedTreeId scope = $Scope evidence_labels = $EvidenceLabels status = $status request_ref = 'review-request.json' request_hash = $requestHash invocation_ref = if ($null -ne $SpawnInvocation) { 'spawn-invocation.json' } else { $null } invocation_id = Get-ContinuousCoReviewRunIndexProperty -Object $SpawnInvocation -Name 'invocation_id' findings_result_ref = if ($null -ne $FindingsResult) { 'findings-result.json' } else { $null } infrastructure_failure_ref = if ($null -ne $InfrastructureFailure) { 'infrastructure-failure.json' } else { $null } review_thread_ref = if ($null -ne $ReviewThread) { 'review-thread.json' } else { $null } gate_verdict_ref = if ($null -ne $GateVerdict) { 'gate-verdict.json' } else { $null } requested_host = $requestedHost requested_model = $requestedModel actual_host = $actualHost actual_model = $actualModel adapter_id = $adapterId cleanup_status = Get-ContinuousCoReviewRunIndexProperty -Object $CleanupResult -Name 'cleanup_status' cleanup_failure_ref = if ($null -ne (Get-ContinuousCoReviewRunIndexProperty -Object $CleanupResult -Name 'cleanup_failure')) { 'cleanup-failure.json' } else { $null } created_at = $createdAtText updated_at = $createdAtText } } function New-ContinuousCoReviewRunSkippedIndexRecord { param( [Parameter(Mandatory)] [string] $RunId, [Parameter(Mandatory)] [string] $CheckpointId, [Parameter(Mandatory)] [string] $BaselineRef, [Parameter(Mandatory)] [string] $Reason, [AllowNull()] [string] $DiffHash, [AllowNull()] $GateVerdict, [datetime] $CreatedAt = [datetime]::UtcNow ) $createdAtText = ConvertTo-ContinuousCoReviewRunIndexIsoTimestamp -Timestamp $CreatedAt return [pscustomobject][ordered]@{ schema_version = '1.0' run_id = $RunId checkpoint_id = $CheckpointId baseline_ref = $BaselineRef status = 'skipped' reason = $Reason diff_hash = $DiffHash gate_verdict_ref = if ($null -ne $GateVerdict) { 'gate-verdict.json' } else { $null } created_at = $createdAtText updated_at = $createdAtText } } function Write-ContinuousCoReviewRunIndex { param( [Parameter(Mandatory)] [string] $RepoRoot, [Parameter(Mandatory)] [string] $RunId, [Parameter(Mandatory)] [string] $CheckpointId, [Parameter(Mandatory)] [string] $BaselineRef, [AllowNull()] [string] $DiffHash, [AllowNull()] [string] $ReviewedRef, [AllowNull()] [string] $Scope, [AllowNull()] [string] $ReviewedTreeId, [AllowNull()] $ReviewRequest, [AllowNull()] $RequestBundle, [AllowNull()] $SpawnInvocation, [AllowNull()] $FindingsResult, [AllowNull()] $InfrastructureFailure, [AllowNull()] $ReviewThread, [AllowNull()] $GateVerdict, [AllowNull()] $CleanupResult, [AllowNull()] $SkippedRun, [AllowNull()] $EvidenceLabels, [datetime] $CreatedAt = [datetime]::UtcNow ) $resolvedRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path $runRoot = Join-Path $resolvedRepoRoot ".specrew/review/inline/$RunId" New-Item -ItemType Directory -Path $runRoot -Force | Out-Null if ($null -ne $SkippedRun) { $skipReason = Get-ContinuousCoReviewRunIndexProperty -Object $SkippedRun -Name 'reason' if ([string]::IsNullOrWhiteSpace([string] $skipReason)) { $skipReason = 'no-reviewable-diff' } $skipDiffHash = Get-ContinuousCoReviewRunIndexProperty -Object $SkippedRun -Name 'diff_hash' $skippedRecord = New-ContinuousCoReviewRunSkippedIndexRecord ` -RunId $RunId ` -CheckpointId $CheckpointId ` -BaselineRef $BaselineRef ` -Reason $skipReason ` -DiffHash $skipDiffHash ` -GateVerdict $GateVerdict ` -CreatedAt $CreatedAt $skippedPath = Join-Path $runRoot 'review-run-skipped.json' Write-ContinuousCoReviewRunIndexJson -Path $skippedPath -InputObject $skippedRecord if ($null -ne $GateVerdict) { Write-ContinuousCoReviewRunIndexJson -Path (Join-Path $runRoot 'gate-verdict.json') -InputObject $GateVerdict } return [pscustomobject][ordered]@{ schema_version = '1.0' run_id = $RunId review_root = $runRoot review_run_skipped_path = $skippedPath review_run_skipped = $skippedRecord } } $runRecord = New-ContinuousCoReviewRunIndexRecord ` -RunId $RunId ` -CheckpointId $CheckpointId ` -BaselineRef $BaselineRef ` -DiffHash $DiffHash ` -ReviewedRef $ReviewedRef ` -Scope $Scope ` -ReviewedTreeId $ReviewedTreeId ` -ReviewRequest $ReviewRequest ` -RequestBundle $RequestBundle ` -SpawnInvocation $SpawnInvocation ` -FindingsResult $FindingsResult ` -InfrastructureFailure $InfrastructureFailure ` -ReviewThread $ReviewThread ` -GateVerdict $GateVerdict ` -CleanupResult $CleanupResult ` -EvidenceLabels $EvidenceLabels ` -CreatedAt $CreatedAt if ($null -ne $ReviewRequest) { Write-ContinuousCoReviewRunIndexJson -Path (Join-Path $runRoot 'review-request.json') -InputObject $ReviewRequest } if ($null -ne $SpawnInvocation) { Write-ContinuousCoReviewRunIndexJson -Path (Join-Path $runRoot 'spawn-invocation.json') -InputObject $SpawnInvocation } if ($null -ne $InfrastructureFailure) { Write-ContinuousCoReviewRunIndexJson -Path (Join-Path $runRoot 'infrastructure-failure.json') -InputObject $InfrastructureFailure } if ($null -ne $GateVerdict) { Write-ContinuousCoReviewRunIndexJson -Path (Join-Path $runRoot 'gate-verdict.json') -InputObject $GateVerdict } $cleanupFailure = Get-ContinuousCoReviewRunIndexProperty -Object $CleanupResult -Name 'cleanup_failure' if ($null -ne $cleanupFailure) { Write-ContinuousCoReviewRunIndexJson -Path (Join-Path $runRoot 'cleanup-failure.json') -InputObject $cleanupFailure } $runPath = Join-Path $runRoot 'review-run.json' Write-ContinuousCoReviewRunIndexJson -Path $runPath -InputObject $runRecord return [pscustomobject][ordered]@{ schema_version = '1.0' run_id = $RunId review_root = $runRoot review_run_path = $runPath review_run = $runRecord } } function Write-ContinuousCoReviewReviewRunIndex { param( [Parameter(Mandatory)] [string] $RepoRoot, [Parameter(Mandatory)] [string] $RunId, [Parameter(Mandatory)] [string] $CheckpointId, [Parameter(Mandatory)] [string] $BaselineRef, [AllowNull()] $ReviewRequest, [AllowNull()] $RequestBundle, [AllowNull()] $SpawnInvocation, [AllowNull()] $FindingsResult, [AllowNull()] $InfrastructureFailure, [AllowNull()] $ReviewThread, [AllowNull()] $GateVerdict, [AllowNull()] $CleanupResult, [AllowNull()] $SkippedRun, [datetime] $CreatedAt = [datetime]::UtcNow ) return Write-ContinuousCoReviewRunIndex @PSBoundParameters } function Write-ContinuousCoReviewSkippedRunIndex { param( [Parameter(Mandatory)] [string] $RepoRoot, [Parameter(Mandatory)] [string] $RunId, [Parameter(Mandatory)] [string] $CheckpointId, [Parameter(Mandatory)] [string] $BaselineRef, [Parameter(Mandatory)] $SkippedRun, [AllowNull()] $GateVerdict, [datetime] $CreatedAt = [datetime]::UtcNow ) return Write-ContinuousCoReviewRunIndex ` -RepoRoot $RepoRoot ` -RunId $RunId ` -CheckpointId $CheckpointId ` -BaselineRef $BaselineRef ` -SkippedRun $SkippedRun ` -GateVerdict $GateVerdict ` -CreatedAt $CreatedAt } function Invoke-ContinuousCoReviewResolverGit { param( [Parameter(Mandatory)] [string] $RepoRoot, [Parameter(Mandatory)] [string[]] $Arguments ) Push-Location -LiteralPath $RepoRoot try { $output = @(& git @Arguments 2>$null) $exit = $LASTEXITCODE } finally { Pop-Location } return [pscustomobject]@{ ExitCode = $exit; Output = @($output) } } function Get-ContinuousCoReviewGitIsAncestor { # T066 / FR-025: lineage identity. Returns $true when <Ancestor> is an ancestor of # (or equal to) <Descendant>. A non-resolvable ref or a git failure returns $false # (fail-closed: a run we cannot prove is on this lineage is not a candidate). param( [Parameter(Mandatory)] [string] $RepoRoot, [AllowNull()] [string] $Ancestor, [string] $Descendant = 'HEAD' ) if ([string]::IsNullOrWhiteSpace($Ancestor)) { return $false } $result = Invoke-ContinuousCoReviewResolverGit -RepoRoot $RepoRoot -Arguments @('merge-base', '--is-ancestor', $Ancestor, $Descendant) return ($result.ExitCode -eq 0) } function Get-ContinuousCoReviewMergeBaseAnchor { # T066 / FR-025 (HOLE B): the trusted anchor co-review must cover back to is the # merge-base of HEAD with the trunk - "co-review must cover everything the feature # added on top of shipped trunk". Returns the 40-hex anchor commit, or $null. param( [Parameter(Mandatory)] [string] $RepoRoot, [string] $TrunkName = 'main', [string] $HeadRef = 'HEAD' ) # F5 (145 review): the trunk may exist only as a remote ref on a fresh checkout, so try # the local name first then `origin/<trunk>` before giving up (a $null anchor fails the # gate closed). The caller may pass an already-qualified ref (e.g. 'origin/main'); we # never double-prefix. $trunkCandidates = New-Object System.Collections.Generic.List[string] [void] $trunkCandidates.Add($TrunkName) if ($TrunkName -notmatch '/') { [void] $trunkCandidates.Add("origin/$TrunkName") } # iter-007 real-host dogfood fix: the trunk is NOT always 'main'. A real repo's default branch was 'dev' # (origin/HEAD -> origin/dev), so 'main' + 'origin/main' BOTH failed to resolve -> $null anchor -> # 'no-reviewable-checkpoint' -> the navigator silently no-op'd at EVERY implement checkpoint (no review # ever fired). Detect the repo's CONFIGURED default branch and try it before giving up. $detectedHead = Invoke-ContinuousCoReviewResolverGit -RepoRoot $RepoRoot -Arguments @('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') if ($detectedHead.ExitCode -eq 0 -and @($detectedHead.Output).Count -gt 0) { $detectedTrunk = ([string] $detectedHead.Output[0]).Trim() if (-not [string]::IsNullOrWhiteSpace($detectedTrunk)) { if (-not $trunkCandidates.Contains($detectedTrunk)) { [void] $trunkCandidates.Add($detectedTrunk) } $bareTrunk = $detectedTrunk -replace '^origin/', '' if ($bareTrunk -ne $detectedTrunk -and -not $trunkCandidates.Contains($bareTrunk)) { [void] $trunkCandidates.Add($bareTrunk) } } } foreach ($trunk in $trunkCandidates) { $result = Invoke-ContinuousCoReviewResolverGit -RepoRoot $RepoRoot -Arguments @('merge-base', $trunk, $HeadRef) if ($result.ExitCode -eq 0 -and @($result.Output).Count -gt 0) { $anchor = ([string] $result.Output[0]).Trim() if ($anchor -match '^[0-9a-f]{40}$') { return $anchor } } } return $null } function ConvertTo-ContinuousCoReviewReviewState { param([AllowNull()] $Record) if ($null -eq $Record) { return $null } return [pscustomobject][ordered]@{ run_id = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'run_id' checkpoint_id = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'checkpoint_id' baseline_ref = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'baseline_ref' diff_hash = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'diff_hash' reviewed_ref = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'reviewed_ref' reviewed_tree_id = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'reviewed_tree_id' scope = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'scope' status = Get-ContinuousCoReviewRunIndexProperty -Object $Record -Name 'status' } } function Get-ContinuousCoReviewPassingReviewRuns { # T066 / FR-025: read all durable .specrew/review/inline pass|escalated runs, newest # first. When -AncestorOfRef is supplied (production), filter to runs whose reviewed_ref # is an ancestor of that ref (git lineage) so a DIFFERENT feature/branch's run can never # be selected. When it is omitted (fixtures), no lineage filter is applied. param( [Parameter(Mandatory)] [string] $RepoRoot, [AllowNull()] [string] $AncestorOfRef ) $resolvedRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path $inlineRoot = Join-Path $resolvedRepoRoot '.specrew/review/inline' if (-not (Test-Path -LiteralPath $inlineRoot -PathType Container)) { return @() } $passingStates = @('pass', 'escalated') $candidates = New-Object System.Collections.Generic.List[object] foreach ($runDirectory in @(Get-ChildItem -LiteralPath $inlineRoot -Directory -ErrorAction SilentlyContinue)) { $runPath = Join-Path $runDirectory.FullName 'review-run.json' if (-not (Test-Path -LiteralPath $runPath -PathType Leaf)) { continue } try { $record = Get-Content -LiteralPath $runPath -Raw | ConvertFrom-Json -Depth 100 } catch { continue } $status = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $record -Name 'status') if ($passingStates -notcontains $status) { continue } if (-not [string]::IsNullOrWhiteSpace($AncestorOfRef)) { $reviewedRef = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $record -Name 'reviewed_ref') if (-not (Get-ContinuousCoReviewGitIsAncestor -RepoRoot $resolvedRepoRoot -Ancestor $reviewedRef -Descendant $AncestorOfRef)) { continue } } [void] $candidates.Add($record) } # F6 (145 review): sort by parsed UTC DateTime, not the raw string. return @( $candidates | Sort-Object -Property ` @{ Expression = { $createdAtValue = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $_ -Name 'created_at') $parsed = [datetime]::MinValue [void] [datetime]::TryParse($createdAtValue, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref] $parsed) $parsed }; Descending = $true }, ` @{ Expression = { [string] (Get-ContinuousCoReviewRunIndexProperty -Object $_ -Name 'run_id') }; Descending = $true } ) } function Get-ContinuousCoReviewLastPassingReviewState { # T058/T066 / FR-025 / FR-027: the latest pass|escalated run on this lineage. The # incremental review baseline (FR-027) advances to its reviewed_ref; the gate (FR-025) # checks the current reviewed-state tree-id against it and verifies the chain reaches # the merge-base anchor. Identity is now git lineage (reviewed_ref ancestor of # -AncestorOfRef), not a scope string; -AncestorOfRef omitted = fixture mode (no filter). param( [Parameter(Mandatory)] [string] $RepoRoot, [AllowNull()] [string] $AncestorOfRef ) $runs = @(Get-ContinuousCoReviewPassingReviewRuns -RepoRoot $RepoRoot -AncestorOfRef $AncestorOfRef) if ($runs.Count -eq 0) { return $null } return ConvertTo-ContinuousCoReviewReviewState -Record $runs[0] } |