scripts/internal/continuous-co-review/verification-plan-supplier.ps1
|
# FR-049 / T062 — deterministic, framework-neutral verification-plan selection. # # The selector is intentionally PURE: callers resolve filesystem/config/profile/provider facts into the # bounded identities accepted here. It never scans file extensions, guesses a framework, executes a command, # or mutates `.specrew/verification-plan.json`. T063 owns capture/materialization; T018 owns execution. if (-not (Get-Command -Name 'Test-ContinuousCoReviewVerificationPlan' -ErrorAction SilentlyContinue)) { $contractPath = Join-Path $PSScriptRoot 'verification-plan-contract.ps1' if (Test-Path -LiteralPath $contractPath -PathType Leaf) { . $contractPath } } function Get-ContinuousCoReviewSupplierProp { param([AllowNull()]$Object, [Parameter(Mandatory)][string]$Name) if ($null -eq $Object) { return $null } if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Name)) { return , $Object[$Name] } return $null } $prop = $Object.PSObject.Properties[$Name] if ($null -eq $prop) { return $null } return , $prop.Value } function ConvertTo-ContinuousCoReviewSupplierCanonicalValue { param([AllowNull()]$Value) if ($null -eq $Value) { return $null } if ($Value -is [string] -or $Value -is [char] -or $Value -is [bool] -or $Value.GetType().IsPrimitive -or $Value -is [decimal] -or $Value -is [System.Numerics.BigInteger]) { return $Value } if ($Value -is [System.Collections.IDictionary]) { $ordered = [ordered]@{} foreach ($key in @($Value.Keys | ForEach-Object { [string]$_ } | Sort-Object -CaseSensitive)) { $ordered[$key] = ConvertTo-ContinuousCoReviewSupplierCanonicalValue -Value $Value[$key] } return $ordered } if ($Value -is [System.Collections.IEnumerable]) { $items = [System.Collections.Generic.List[object]]::new() foreach ($item in $Value) { $items.Add((ConvertTo-ContinuousCoReviewSupplierCanonicalValue -Value $item)) } return , $items.ToArray() } $object = [ordered]@{} foreach ($name in @($Value.PSObject.Properties.Name | Sort-Object -CaseSensitive)) { $object[$name] = ConvertTo-ContinuousCoReviewSupplierCanonicalValue -Value $Value.PSObject.Properties[$name].Value } return $object } function ConvertTo-ContinuousCoReviewSupplierCanonicalJson { param([AllowNull()]$Value) $normalized = ConvertTo-ContinuousCoReviewSupplierCanonicalValue -Value $Value return (ConvertTo-Json -InputObject $normalized -Depth 30 -Compress) } function Get-ContinuousCoReviewSupplierSha256 { param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) return [Convert]::ToHexString($hash).ToLowerInvariant() } function Test-ContinuousCoReviewSupplierIdentity { param([AllowNull()]$Value) return (($Value -is [string]) -and ([string]$Value -cmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$')) } function Get-ContinuousCoReviewSupplierRows { param([AllowNull()]$Catalog, [Parameter(Mandatory)][string]$Name) $present = if ($Catalog -is [System.Collections.IDictionary]) { $Catalog.Contains($Name) } else { $null -ne $Catalog.PSObject.Properties[$Name] } if (-not $present) { return [pscustomobject]@{ valid = $true; rows = @() } } $raw = $null if ($Catalog -is [System.Collections.IDictionary]) { $raw = $Catalog[$Name] } else { $raw = $Catalog.PSObject.Properties[$Name].Value } if ($raw -is [string] -or -not ($raw -is [System.Collections.IEnumerable])) { return [pscustomobject]@{ valid = $false; rows = @() } } return [pscustomobject]@{ valid = $true; rows = @($raw) } } function Test-ContinuousCoReviewVerificationPlanCatalog { param([Parameter(Mandatory)][AllowNull()]$Catalog) if ($null -eq $Catalog) { return [pscustomobject]@{ valid = $false; reason = 'supplier catalog is required' } } $unknownCatalog = @(Get-ContinuousCoReviewUnknownProperties -Object $Catalog -Allowed @('schema_version', 'catalog_id', 'project_metadata', 'quality_profiles', 'providers')) if ($unknownCatalog.Count -gt 0) { return [pscustomobject]@{ valid = $false; reason = 'supplier catalog contains unsupported fields (the catalog contract is closed)' } } if ([string](Get-ContinuousCoReviewSupplierProp $Catalog 'schema_version') -cne '1.0') { return [pscustomobject]@{ valid = $false; reason = 'supplier catalog schema_version must be 1.0' } } $catalogId = Get-ContinuousCoReviewSupplierProp $Catalog 'catalog_id' if (-not (Test-ContinuousCoReviewSupplierIdentity $catalogId)) { return [pscustomobject]@{ valid = $false; reason = 'supplier catalog_id is missing or unsafe' } } $sections = @( @{ name = 'project_metadata'; identity = 'detector_id' }, @{ name = 'quality_profiles'; identity = 'profile_id' }, @{ name = 'providers'; identity = 'provider_id' } ) $entryIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($section in $sections) { $sectionResult = Get-ContinuousCoReviewSupplierRows -Catalog $Catalog -Name $section.name if (-not $sectionResult.valid) { return [pscustomobject]@{ valid = $false; reason = "supplier catalog '$($section.name)' must be an array" } } $rows = @($sectionResult.rows) $identities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($row in $rows) { $unknownRow = @(Get-ContinuousCoReviewUnknownProperties -Object $row -Allowed @('entry_id', $section.identity, 'plan')) if ($unknownRow.Count -gt 0) { return [pscustomobject]@{ valid = $false; reason = "supplier catalog '$($section.name)' contains unsupported row fields" } } $entryId = Get-ContinuousCoReviewSupplierProp $row 'entry_id' $identity = Get-ContinuousCoReviewSupplierProp $row $section.identity if (-not (Test-ContinuousCoReviewSupplierIdentity $entryId) -or -not (Test-ContinuousCoReviewSupplierIdentity $identity)) { return [pscustomobject]@{ valid = $false; reason = "supplier catalog '$($section.name)' contains a missing or unsafe identity" } } if (-not $entryIds.Add(([string]$entryId).ToLowerInvariant())) { return [pscustomobject]@{ valid = $false; reason = 'supplier catalog entry_id values must be unique' } } if (-not $identities.Add(([string]$identity).ToLowerInvariant())) { return [pscustomobject]@{ valid = $false; reason = "supplier catalog '$($section.name)' identities must be unique" } } if ($null -eq (Get-ContinuousCoReviewSupplierProp $row 'plan')) { return [pscustomobject]@{ valid = $false; reason = "supplier catalog entry '$entryId' has no plan" } } } } return [pscustomobject]@{ valid = $true; reason = $null } } function Copy-ContinuousCoReviewSupplierPlan { param( [Parameter(Mandatory)]$Plan, [Parameter(Mandatory)][ValidateSet('project-config', 'project-detected', 'profile-selected', 'provider-gated')][string]$Kind, [Parameter(Mandatory)][string]$Source, [string]$Profile, [string]$Provider ) # JSON round-trip makes a detached copy so this pure selector never mutates caller/catalog inputs. $copy = (ConvertTo-ContinuousCoReviewSupplierCanonicalJson $Plan) | ConvertFrom-Json foreach ($command in @($copy.commands)) { $provenance = [ordered]@{ kind = $Kind; source = $Source } if ($Kind -eq 'profile-selected') { $provenance.profile = $Profile } if ($Kind -eq 'provider-gated') { $provenance.provider = $Provider } $command | Add-Member -NotePropertyName provenance -NotePropertyValue ([pscustomobject]$provenance) -Force } return $copy } function New-ContinuousCoReviewSupplierSelection { param( [Parameter(Mandatory)][ValidateSet('selected', 'verification-not-configured', 'invalid')][string]$State, [AllowNull()][string]$SourceKind, [AllowNull()]$SourceIdentity, [AllowNull()]$Plan, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$SkippedSources, [AllowNull()][string]$FailureReason, [AllowNull()][string]$Action ) $normalizedSourceKind = if ([string]::IsNullOrWhiteSpace($SourceKind)) { $null } else { $SourceKind } $normalizedFailureReason = if ([string]::IsNullOrWhiteSpace($FailureReason)) { $null } else { $FailureReason } $normalizedAction = if ([string]::IsNullOrWhiteSpace($Action)) { $null } else { $Action } $planJson = if ($null -eq $Plan) { $null } else { ConvertTo-ContinuousCoReviewSupplierCanonicalJson $Plan } $planDigest = if ($null -eq $planJson) { $null } else { Get-ContinuousCoReviewSupplierSha256 $planJson } $basis = [ordered]@{ state = $State source_kind = $normalizedSourceKind source_identity = $SourceIdentity plan_digest = $planDigest skipped_sources = @($SkippedSources) failure_reason = $normalizedFailureReason } $selectionId = 'selection-' + (Get-ContinuousCoReviewSupplierSha256 (ConvertTo-ContinuousCoReviewSupplierCanonicalJson $basis)) return [pscustomobject][ordered]@{ schema_version = '1.0' selection_id = $selectionId state = $State source_kind = $normalizedSourceKind source_identity = $SourceIdentity plan_id = if ($null -eq $Plan) { $null } else { [string]$Plan.plan_id } plan_digest = $planDigest skipped_sources = @($SkippedSources) failure_reason = $normalizedFailureReason action = $normalizedAction generated_content_hash = $planDigest plan = $Plan } } function Select-ContinuousCoReviewVerificationPlan { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepoRoot, [Parameter(Mandatory)][AllowNull()]$Catalog, [switch]$ExplicitPlanPresent, [AllowNull()]$ExplicitPlan, [string[]]$DetectedMetadataIds = @(), [AllowNull()][string]$QualityProfileId, [string[]]$ActiveProviders = @() ) $catalogCheck = Test-ContinuousCoReviewVerificationPlanCatalog -Catalog $Catalog if (-not $catalogCheck.valid) { return New-ContinuousCoReviewSupplierSelection -State invalid -SourceKind $null -SourceIdentity $null -Plan $null -SkippedSources @() -FailureReason 'verification-plan-invalid' -Action 'Repair the versioned verification-plan supplier catalog before retrying.' } $skipped = [System.Collections.Generic.List[object]]::new() if ($ExplicitPlanPresent) { $explicitCopy = if ($null -eq $ExplicitPlan) { $null } else { Copy-ContinuousCoReviewSupplierPlan -Plan $ExplicitPlan -Kind 'project-config' -Source '.specrew/verification-plan.json' } $check = Test-ContinuousCoReviewVerificationPlan -Plan $explicitCopy -RepoRoot $RepoRoot if (-not $check.valid) { return New-ContinuousCoReviewSupplierSelection -State invalid -SourceKind 'project-config' -SourceIdentity ([pscustomobject][ordered]@{ path = '.specrew/verification-plan.json' }) -Plan $null -SkippedSources @() -FailureReason 'verification-plan-invalid' -Action 'Repair or remove the explicit .specrew/verification-plan.json; lower-precedence sources are not consulted while it is present.' } return New-ContinuousCoReviewSupplierSelection -State selected -SourceKind 'project-config' -SourceIdentity ([pscustomobject][ordered]@{ path = '.specrew/verification-plan.json' }) -Plan $explicitCopy -SkippedSources @() -FailureReason $null -Action $null } $skipped.Add([pscustomobject][ordered]@{ source_kind = 'project-config'; reason = 'absent' }) $detected = @($DetectedMetadataIds | Where-Object { Test-ContinuousCoReviewSupplierIdentity $_ } | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) # specrew-dedup-not-a-path $metadataRows = @((Get-ContinuousCoReviewSupplierRows $Catalog 'project_metadata').rows) $metadataRow = $metadataRows | Where-Object { ([string]$_.detector_id).ToLowerInvariant() -in $detected } | Select-Object -First 1 if ($null -ne $metadataRow) { $source = [string]$metadataRow.detector_id $plan = Copy-ContinuousCoReviewSupplierPlan -Plan $metadataRow.plan -Kind 'project-detected' -Source $source $check = Test-ContinuousCoReviewVerificationPlan -Plan $plan -RepoRoot $RepoRoot if (-not $check.valid) { return New-ContinuousCoReviewSupplierSelection -State invalid -SourceKind 'project-detected' -SourceIdentity ([pscustomobject][ordered]@{ detector_id = $source; entry_id = [string]$metadataRow.entry_id }) -Plan $null -SkippedSources $skipped.ToArray() -FailureReason 'verification-plan-invalid' -Action 'Repair the selected project-metadata catalog entry before retrying.' } return New-ContinuousCoReviewSupplierSelection -State selected -SourceKind 'project-detected' -SourceIdentity ([pscustomobject][ordered]@{ detector_id = $source; entry_id = [string]$metadataRow.entry_id }) -Plan $plan -SkippedSources $skipped.ToArray() -FailureReason $null -Action $null } $skipped.Add([pscustomobject][ordered]@{ source_kind = 'project-detected'; reason = 'no eligible named detector' }) $profile = if (Test-ContinuousCoReviewSupplierIdentity $QualityProfileId) { $QualityProfileId.ToLowerInvariant() } else { $null } $profileRows = @((Get-ContinuousCoReviewSupplierRows $Catalog 'quality_profiles').rows) $profileRow = if ($null -eq $profile) { $null } else { $profileRows | Where-Object { ([string]$_.profile_id).ToLowerInvariant() -ceq $profile } | Select-Object -First 1 } if ($null -ne $profileRow) { $source = [string]$profileRow.profile_id $plan = Copy-ContinuousCoReviewSupplierPlan -Plan $profileRow.plan -Kind 'profile-selected' -Source $source -Profile $source $check = Test-ContinuousCoReviewVerificationPlan -Plan $plan -RepoRoot $RepoRoot if (-not $check.valid) { return New-ContinuousCoReviewSupplierSelection -State invalid -SourceKind 'profile-selected' -SourceIdentity ([pscustomobject][ordered]@{ profile_id = $source; entry_id = [string]$profileRow.entry_id }) -Plan $null -SkippedSources $skipped.ToArray() -FailureReason 'verification-plan-invalid' -Action 'Repair the selected quality-profile catalog entry before retrying.' } return New-ContinuousCoReviewSupplierSelection -State selected -SourceKind 'profile-selected' -SourceIdentity ([pscustomobject][ordered]@{ profile_id = $source; entry_id = [string]$profileRow.entry_id }) -Plan $plan -SkippedSources $skipped.ToArray() -FailureReason $null -Action $null } $skipped.Add([pscustomobject][ordered]@{ source_kind = 'profile-selected'; reason = 'no eligible explicit profile' }) $providers = @($ActiveProviders | Where-Object { Test-ContinuousCoReviewSupplierIdentity $_ } | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) # specrew-dedup-not-a-path $providerRows = @((Get-ContinuousCoReviewSupplierRows $Catalog 'providers').rows) $providerRow = $providerRows | Where-Object { ([string]$_.provider_id).ToLowerInvariant() -in $providers } | Select-Object -First 1 if ($null -ne $providerRow) { $source = [string]$providerRow.provider_id $plan = Copy-ContinuousCoReviewSupplierPlan -Plan $providerRow.plan -Kind 'provider-gated' -Source $source -Provider $source $check = Test-ContinuousCoReviewVerificationPlan -Plan $plan -RepoRoot $RepoRoot if (-not $check.valid) { return New-ContinuousCoReviewSupplierSelection -State invalid -SourceKind 'provider-gated' -SourceIdentity ([pscustomobject][ordered]@{ provider_id = $source; entry_id = [string]$providerRow.entry_id }) -Plan $null -SkippedSources $skipped.ToArray() -FailureReason 'verification-plan-invalid' -Action 'Repair the selected provider-gated catalog entry before retrying.' } return New-ContinuousCoReviewSupplierSelection -State selected -SourceKind 'provider-gated' -SourceIdentity ([pscustomobject][ordered]@{ provider_id = $source; entry_id = [string]$providerRow.entry_id }) -Plan $plan -SkippedSources $skipped.ToArray() -FailureReason $null -Action $null } $skipped.Add([pscustomobject][ordered]@{ source_kind = 'provider-gated'; reason = 'no eligible active provider row' }) return New-ContinuousCoReviewSupplierSelection -State 'verification-not-configured' -SourceKind $null -SourceIdentity $null -Plan $null -SkippedSources $skipped.ToArray() -FailureReason 'verification-not-configured' -Action 'Create .specrew/verification-plan.json, configure a supported named metadata source, explicitly select a supported quality profile, or activate a provider with a reviewed catalog row.' } |