scripts/internal/continuous-co-review/review-design-context.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # T034b / FR-012 / FR-017: one shared design-context resolution and physical-containment # implementation for both the historical worktree path and the production campaign path. # The path-identity primitive is a HARD dependency, loaded into THIS scope. `_load.ps1` loads it # first, but this file is also dot-sourced directly (worktree-reviewer.ps1, the campaign # orchestrator, and tests all take that door). Without this, the primitive was absent at call time # and every containment comparison silently fell back to a DIFFERENT case rule - which is the exact # defect class DRIFT-198-I009-015 exists to remove, re-entering through the loader instead of the # comparison. See DRIFT-198-I009-018. if (-not (Get-Command -Name 'Get-ContinuousCoReviewPathCaseSensitive' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'path-identity.ps1') } function Get-ContinuousCoReviewPhysicalPath { # THE shared physical-path canonicalizer (FR-008 + FR-010 containment). Resolves a path to its REAL # physical location by walking EVERY component and following each existing symlink/junction to its # target - intermediate DIRECTORY links included, not just the final component. Both worktree # containment and strict design-context validation use this helper so their semantics cannot drift. # FAIL-CLOSED: an existing but unresolvable component returns $null. A not-yet-existing trailing # component is retained lexically so an external worktree path can be checked before creation. param([Parameter(Mandatory)][AllowEmptyString()][string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } $full = try { [System.IO.Path]::GetFullPath($Path) } catch { return $null } $root = [System.IO.Path]::GetPathRoot($full) if ([string]::IsNullOrEmpty($root)) { return $null } $segs = @($full.Substring($root.Length) -split '[\\/]' | Where-Object { $_ -ne '' }) $cur = $root.TrimEnd([char]'\', [char]'/') if ([string]::IsNullOrEmpty($cur)) { $cur = [string]$root } for ($i = 0; $i -lt $segs.Count; $i++) { $cur = Join-Path $cur $segs[$i] $item = try { Get-Item -LiteralPath $cur -Force -ErrorAction Stop } catch { $null } if ($null -eq $item) { for ($j = $i + 1; $j -lt $segs.Count; $j++) { $cur = Join-Path $cur $segs[$j] } break } $tgt = $null try { $tgt = $item.ResolveLinkTarget($true) } catch { return $null } if ($null -ne $tgt) { $cur = [System.IO.Path]::GetFullPath($tgt.FullName).TrimEnd([char]'\', [char]'/') } } return [System.IO.Path]::GetFullPath($cur).TrimEnd([char]'\', [char]'/') } function Test-ContinuousCoReviewPathUnderRoot { # Physical equality/descendance. Case semantics come from the ACTUAL volume, never the OS # family: macOS is POSIX but its default volume folds case, so the old `$IsWindows` shortcut # reported a case-aliased path as OUTSIDE the root there - letting an explicitly supplied # aliased external root sit physically inside the origin. Undetermined resolves to 'same' so # this predicate answers "inside" more readily, which is the refusing direction for the # containment guards that consume it. Unresolvable paths still fail closed. param([Parameter(Mandatory)][AllowEmptyString()][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Root) $pReal = Get-ContinuousCoReviewPhysicalPath -Path $Path $rReal = Get-ContinuousCoReviewPhysicalPath -Path $Root if ([string]::IsNullOrEmpty($pReal) -or [string]::IsNullOrEmpty($rReal)) { return $false } $cmp = Get-ContinuousCoReviewPathComparison -Path $rReal -WhenUndetermined 'same' return ($pReal.Equals($rReal, $cmp) -or $pReal.StartsWith($rReal + [System.IO.Path]::DirectorySeparatorChar, $cmp)) } function Resolve-ContinuousCoReviewWorktreeDesignContext { # Auto-resolve the active feature spec, latest design analysis, and formal contracts. The # result is repo-relative; a genuinely unresolved context returns an empty array so callers # can apply the explicit DESIGN_CONTEXT_EMPTY partial-evidence degrade. param( [Parameter(Mandatory)][string]$RepoRoot, [ValidatePattern('^[0-9]+-[a-z0-9][a-z0-9-]*$')][string]$FeatureId ) $out = New-Object System.Collections.Generic.List[string] # A public campaign already carries an exact FeatureId. Prefer that immutable command # identity over ignored, mutable session files so a clean detached review worktree resolves # the same design context as the origin. Legacy callers omit it and retain the existing # feature.json -> start-context -> single-unambiguous-spec fallback chain. $featureIdentitySupplied = -not [string]::IsNullOrWhiteSpace($FeatureId) $featureDir = if ($featureIdentitySupplied) { 'specs/' + $FeatureId } else { $null } if ($featureIdentitySupplied -and -not (Test-Path -LiteralPath (Join-Path $RepoRoot $featureDir) -PathType Container)) { return @() } $fj = Join-Path $RepoRoot '.specify/feature.json' if ([string]::IsNullOrWhiteSpace($featureDir) -and (Test-Path -LiteralPath $fj -PathType Leaf)) { try { $featureDir = ([string]((Get-Content $fj -Raw -Encoding UTF8 | ConvertFrom-Json).feature_directory)).Replace('\', '/').TrimEnd('/') } catch { $featureDir = $null } } if ([string]::IsNullOrWhiteSpace($featureDir)) { try { $scPath = Join-Path $RepoRoot '.specrew/start-context.json' if (Test-Path -LiteralPath $scPath -PathType Leaf) { $sc = Get-Content -LiteralPath $scPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($sc.PSObject.Properties['session_state'] -and $null -ne $sc.session_state) { $ref = if ($sc.session_state.PSObject.Properties['feature_ref']) { [string]$sc.session_state.feature_ref } else { '' } if (-not [string]::IsNullOrWhiteSpace($ref) -and (Test-Path -LiteralPath (Join-Path $RepoRoot (Join-Path 'specs' $ref)) -PathType Container)) { $featureDir = ('specs/' + $ref) } } } } catch { $null = $_ } } if ([string]::IsNullOrWhiteSpace($featureDir)) { try { $specDirs = @(Get-ChildItem -LiteralPath (Join-Path $RepoRoot 'specs') -Directory -ErrorAction Stop | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'spec.md') -PathType Leaf }) if ($specDirs.Count -eq 1) { $featureDir = ('specs/' + $specDirs[0].Name) } } catch { $null = $_ } } if ([string]::IsNullOrWhiteSpace($featureDir)) { return @() } if (Test-Path -LiteralPath (Join-Path $RepoRoot (Join-Path $featureDir 'spec.md')) -PathType Leaf) { [void]$out.Add("$featureDir/spec.md") } $iterRoot = Join-Path $RepoRoot (Join-Path $featureDir 'iterations') if (Test-Path -LiteralPath $iterRoot -PathType Container) { $latest = @(Get-ChildItem -LiteralPath $iterRoot -Directory -EA SilentlyContinue | Where-Object { $_.Name -match '^\d+$' -and (Test-Path -LiteralPath (Join-Path $_.FullName 'design-analysis.md') -PathType Leaf) } | Sort-Object { [int]$_.Name } -Descending | Select-Object -First 1) if ($latest) { [void]$out.Add(([System.IO.Path]::GetRelativePath($RepoRoot, (Join-Path $latest[0].FullName 'design-analysis.md')).Replace('\', '/'))) } } $contractsDir = Join-Path $RepoRoot (Join-Path $featureDir 'contracts') if (Test-Path -LiteralPath $contractsDir -PathType Container) { foreach ($cf in @(Get-ChildItem -LiteralPath $contractsDir -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Extension -match '(?i)^\.(json|ya?ml|proto|graphql|avsc|xsd)$' })) { [void]$out.Add(([System.IO.Path]::GetRelativePath($RepoRoot, $cf.FullName)).Replace('\', '/')) } } return @($out) } function Resolve-ContinuousCoReviewDesignContextSelection { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepoRoot, [AllowEmptyCollection()][string[]]$DesignContextFiles = @(), [ValidatePattern('^[0-9]+-[a-z0-9][a-z0-9-]*$')][string]$FeatureId ) $explicit = ($null -ne $DesignContextFiles -and @($DesignContextFiles).Count -gt 0) $resolved = if ($explicit) { @($DesignContextFiles) } elseif ([string]::IsNullOrWhiteSpace($FeatureId)) { @(Resolve-ContinuousCoReviewWorktreeDesignContext -RepoRoot $RepoRoot) } else { @(Resolve-ContinuousCoReviewWorktreeDesignContext -RepoRoot $RepoRoot -FeatureId $FeatureId) } $resolved = @($resolved) $unresolved = [System.Collections.Generic.List[string]]::new() if ($explicit) { foreach ($dc in $resolved) { $ref = [string]$dc $ok = $false if (-not [string]::IsNullOrWhiteSpace($ref) -and -not [System.IO.Path]::IsPathRooted($ref)) { $full = try { [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $ref)) } catch { $null } if ($full -and (Test-Path -LiteralPath $full -PathType Leaf) -and (Test-ContinuousCoReviewPathUnderRoot -Path $full -Root $RepoRoot)) { $ok = $true } } if (-not $ok) { [void]$unresolved.Add($ref) } } } if ($unresolved.Count -gt 0) { $reason = ('design-context-unresolved: explicit design-context ref(s) did not resolve to a file whose physical path is UNDER the repo root (no rooted paths, no ../ traversal, no symlink/junction escape - intermediate components included): {0} (fix the path(s) or omit the flag to use auto-resolution)' -f (@($unresolved) -join ', ')) return [pscustomobject][ordered]@{ valid = $false; explicit = $true; classification = 'unresolved'; reason = $reason resolved_refs = @(); unresolved_refs = @($unresolved); design_context_empty = $false } } return [pscustomobject][ordered]@{ valid = $true; explicit = $explicit; classification = $(if ($resolved.Count -eq 0) { 'empty' } else { 'resolved' }); reason = $null resolved_refs = @($resolved); unresolved_refs = @(); design_context_empty = ($resolved.Count -eq 0) } } function Add-ContinuousCoReviewDesignContextToScope { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ReviewScope, [Parameter(Mandatory)]$Selection, [ValidateRange(1, 16000)][int]$MaximumLength = 16000 ) if (-not [bool]$Selection.valid) { throw [string]$Selection.reason } $contextBlock = if ([bool]$Selection.design_context_empty) { 'DESIGN_CONTEXT_EMPTY: No spec, design analysis, or formal contract resolved. Review the frozen code, report the missing context, and return partial/incomplete evidence; it cannot approve the current target.' } else { $refsJson = ConvertTo-Json -InputObject @($Selection.resolved_refs) -Compress "Design context refs in the frozen snapshot (repo-relative JSON): $refsJson" } $composed = $ReviewScope.TrimEnd() + "`n`n" + $contextBlock if ($composed.Length -gt $MaximumLength) { throw "review-scope-too-large:$($composed.Length):$MaximumLength" } return $composed } |