scripts/internal/continuous-co-review/verification-plan-materializer.ps1
|
# FR-049 / T063 — project source capture plus hash-guarded materialization for the pure T062 selector. if (-not (Get-Command -Name 'Select-ContinuousCoReviewVerificationPlan' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'verification-plan-supplier.ps1') } function Resolve-ContinuousCoReviewVerificationPlanCatalogPath { param([Parameter(Mandatory)][string]$RepoRoot, [AllowNull()][string]$CatalogPath) if (-not [string]::IsNullOrWhiteSpace($CatalogPath)) { return $CatalogPath } $deployed = Join-Path $RepoRoot '.specify/extensions/specrew-speckit/data/verification-plan-catalog.json' if (Test-Path -LiteralPath $deployed -PathType Leaf) { return $deployed } $moduleRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../..')) return (Join-Path $moduleRoot 'extensions/specrew-speckit/data/verification-plan-catalog.json') } function Get-ContinuousCoReviewProjectMetadataIds { param([Parameter(Mandatory)][string]$RepoRoot) $packagePath = Join-Path $RepoRoot 'package.json' if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { return @() } try { $package = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json $scripts = $package.PSObject.Properties['scripts'] if ($null -eq $scripts -or $null -eq $scripts.Value) { return @() } $test = $scripts.Value.PSObject.Properties['test'] if ($null -eq $test -or -not ($test.Value -is [string]) -or [string]::IsNullOrWhiteSpace([string]$test.Value)) { return @() } $declared = ([string]$test.Value).Trim() # npm's generated placeholder is not a trustworthy verification command. if ($declared -match '(?i)no\s+test\s+specified' -or $declared -match '(?i)^echo\b.*\bexit\s+1\s*$') { return @() } return @('package-json.scripts-test.v1') } catch { return @() } } function Get-ContinuousCoReviewActiveProjectProviders { param([Parameter(Mandatory)][string]$RepoRoot) $path = Join-Path $RepoRoot '.specrew/repository-governance.yml' if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return @() } $block = '' foreach ($rawLine in @(Get-Content -LiteralPath $path)) { $line = ($rawLine -replace '\s+#.*$', '').TrimEnd() if ($line -match '^(?<name>[A-Za-z0-9_-]+):\s*$') { $block = $Matches.name; continue } $value = $null if ($line -match '^provider:\s*(?<value>[^\s]+)\s*$') { $value = $Matches.value } elseif ($block -eq 'provider' -and $line -match '^\s+name:\s*(?<value>[^\s]+)\s*$') { $value = $Matches.value } elseif ($block -eq 'repository_governance' -and $line -match '^\s{2}provider:\s*(?<value>[^\s]+)\s*$') { $value = $Matches.value } if (-not [string]::IsNullOrWhiteSpace($value)) { $normalized = $value.Trim('"', "'", ' ').ToLowerInvariant() if (Test-ContinuousCoReviewSupplierIdentity $normalized) { return @($normalized) } return @() } } return @() } function Write-ContinuousCoReviewVerificationPlanFile { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Content) $parent = Split-Path -Parent $Path if (-not (Test-Path -LiteralPath $parent -PathType Container)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } $temp = Join-Path $parent ('.verification-plan-' + [guid]::NewGuid().ToString('N') + '.tmp') try { [System.IO.File]::WriteAllText($temp, $Content, [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::Move($temp, $Path, $true) } finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } # T007 / FR-012, FR-013. The N4 default allowlist, plus TMPDIR. # # PSModulePath is deliberately ABSENT, and that is a MEASUREMENT rather than a judgement (maintainer # instruction, 2026-08-10): a child process started with the variable REMOVED is handed a fully # populated default module path by pwsh at startup, and the governance validator behaves identically - # exit 0 both ways, 11.9 s against 11.2 s, empty stderr. The env_ref is therefore not load-bearing, and # a default that lists it would teach every consumer to inherit something they do not need. $script:SpecrewStarterVerificationEnvRefs = @( 'PATH', 'PATHEXT', 'SYSTEMROOT', 'COMSPEC', 'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMDATA' ) function New-ContinuousCoReviewStarterVerificationPlan { # The plan every GOVERNED project can run on day one, so a fresh project is never left unable to # verify (DRIFT-199-I001-008: the campaign preflight cannot run without a plan, and the stop surface # then demands a review that cannot start - a bootstrap deadlock at the gate). # # ONE command, and it is the governance validator because that is the one thing EVERY governed # project has: `.specify/extensions/...` is deployed by init itself. Build and test commands cannot # be guessed from an empty tree, so they ship as TEMPLATES beside the plan rather than as commands # that would fail on first run and teach the consumer that verification is broken. # # DELIBERATELY GENERIC - no feature id in the plan_id, and no `-IterationPath`. Measured: the # validator resolves the ACTIVE iteration itself (`mode=scoped`, `iterations_validated=1`, exit 0). # That is what lets this survive a clone, a new worktree, and a new feature - the three things # DRIFT-199-I001-010 recorded the beta2 definition failing, because it hardcoded # `-IterationPath specs/198-beta2-hardening/iterations/008`. [OutputType([pscustomobject])] [CmdletBinding()] param() return [pscustomobject][ordered]@{ schema_version = '1.0' plan_id = 'verification.starter.governance.v1' commands = @( [pscustomobject][ordered]@{ command_id = 'specrew-governance' executable = 'pwsh' arguments = @( '-NoProfile' '-File' '.specify/extensions/specrew-speckit/scripts/validate-governance.ps1' '-ProjectPath' '.' '-NoCacheRead' '-NoParallel' ) timeout_seconds = 180 env_refs = @($script:SpecrewStarterVerificationEnvRefs) provenance = [pscustomobject][ordered]@{ kind = 'project-config' source = '.specrew/verification-plan.json' } label = 'Specrew governance validation - the one check every governed project can run. Add your build and test commands beside it; see verification-plan.templates.md.' } ) } } function Get-ContinuousCoReviewStarterVerificationTemplates { # Templates live BESIDE the plan, never inside it. The plan schema admits schema_version, plan_id # and commands only, with the explicit rationale that no secret value can ride an unrecognized # field - so an extra key or a "disabled command" flag would reopen a containment rule that exists # for secrets, to carry documentation. A markdown sidecar costs nothing and is readable. [OutputType([string])] [CmdletBinding()] param() return @' # Verification plan - templates `.specrew/verification-plan.json` is yours to edit. It starts with one command: Specrew's own governance validation, which is the only check that works in every project without configuration. **Add the command that builds and tests YOUR project.** Until you do, a review verifies that your governance records are consistent - it does not verify that your code works. Copy one of these into the `commands` array and adjust it. ## .NET ```json { "command_id": "dotnet-test", "executable": "dotnet", "arguments": ["test", "--configuration", "Release", "--nologo"], "timeout_seconds": 900, "env_refs": ["PATH", "PATHEXT", "SYSTEMROOT", "COMSPEC", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMDATA", "DOTNET_CLI_HOME", "NUGET_PACKAGES"], "provenance": { "kind": "project-config", "source": ".specrew/verification-plan.json" }, "label": "Build and test" } ``` ## Node / npm ```json { "command_id": "npm-test", "executable": "npm", "arguments": ["test"], "timeout_seconds": 900, "env_refs": ["PATH", "PATHEXT", "SYSTEMROOT", "COMSPEC", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMDATA", "npm_config_cache"], "provenance": { "kind": "project-config", "source": ".specrew/verification-plan.json" }, "label": "Build and test" } ``` ## Two rules worth knowing before you edit **Environment variables are declared by NAME, never by value.** `env_refs` lists the names a command is allowed to inherit. A literal `env` or `environment` map is refused, so that no plan can carry a secret. If a command needs something not listed, add its NAME to `env_refs`. **Every command must finish inside the review's time window.** The sum of the `timeout_seconds` values has to fit within the window a review round is given, or a slow day ends in a failure that reports the window rather than your tests. A timeout is a hang-catcher, not an estimate: set it well above how long the command really takes, and still well inside the round. '@ } function New-ContinuousCoReviewVerificationMaterializationResult { param( [Parameter(Mandatory)][string]$State, [Parameter(Mandatory)][string]$Action, [Parameter(Mandatory)][bool]$Mutated, [AllowNull()][string]$Warning, [AllowNull()]$Selection, [Parameter(Mandatory)][string]$PlanPath, [Parameter(Mandatory)][string]$MarkerPath ) return [pscustomobject][ordered]@{ schema_version = '1.0' state = $State action = $Action mutated = $Mutated warning = if ([string]::IsNullOrWhiteSpace($Warning)) { $null } else { $Warning } plan_path = $PlanPath marker_path = $MarkerPath selection = $Selection } } function Invoke-ContinuousCoReviewVerificationPlanMaterialization { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepoRoot, [AllowNull()][string]$CatalogPath, [AllowNull()][string]$QualityProfileId, [AllowNull()][string[]]$ActiveProviders, [switch]$PreviewOnly ) $root = [System.IO.Path]::GetFullPath($RepoRoot) $planPath = Join-Path $root '.specrew/verification-plan.json' $markerPath = Join-Path $root '.specrew/verification-plan.generated.json' $resolvedCatalog = Resolve-ContinuousCoReviewVerificationPlanCatalogPath -RepoRoot $root -CatalogPath $CatalogPath if (-not (Test-Path -LiteralPath $resolvedCatalog -PathType Leaf)) { return New-ContinuousCoReviewVerificationMaterializationResult -State invalid -Action 'catalog-missing' -Mutated $false -Warning 'Verification-plan catalog is missing; no project plan was changed.' -Selection $null -PlanPath $planPath -MarkerPath $markerPath } try { $catalog = Get-Content -LiteralPath $resolvedCatalog -Raw | ConvertFrom-Json } catch { return New-ContinuousCoReviewVerificationMaterializationResult -State invalid -Action 'catalog-invalid' -Mutated $false -Warning 'Verification-plan catalog is malformed; no project plan was changed.' -Selection $null -PlanPath $planPath -MarkerPath $markerPath } $planExists = Test-Path -LiteralPath $planPath -PathType Leaf $markerExists = Test-Path -LiteralPath $markerPath -PathType Leaf $generatedAndUnmodified = $false if ($planExists -and $markerExists) { try { $marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json $expectedHash = [string](Get-ContinuousCoReviewSupplierProp $marker 'generated_content_hash') $currentHash = Get-ContinuousCoReviewSupplierSha256 (Get-Content -LiteralPath $planPath -Raw) $generatedAndUnmodified = ([string](Get-ContinuousCoReviewSupplierProp $marker 'schema_version') -ceq '1.0') -and ($expectedHash -cmatch '^[0-9a-f]{64}$') -and ($currentHash -ceq $expectedHash) } catch { $generatedAndUnmodified = $false } if (-not $generatedAndUnmodified) { return New-ContinuousCoReviewVerificationMaterializationResult -State 'preserved-modified' -Action 'preserved-modified-generated-plan' -Mutated $false -Warning 'The generated verification plan or its ownership marker changed; preserving both files byte-for-byte. Reconcile them explicitly before refresh.' -Selection $null -PlanPath $planPath -MarkerPath $markerPath } } elseif ($planExists) { $explicit = $null try { $explicit = Get-Content -LiteralPath $planPath -Raw | ConvertFrom-Json } catch { $explicit = $null } $selection = Select-ContinuousCoReviewVerificationPlan -RepoRoot $root -Catalog $catalog -ExplicitPlanPresent -ExplicitPlan $explicit $action = if ($selection.state -eq 'selected') { 'preserved-explicit-plan' } else { 'preserved-invalid-explicit-plan' } $warning = if ($selection.state -eq 'selected') { $null } else { $selection.action } return New-ContinuousCoReviewVerificationMaterializationResult -State $selection.state -Action $action -Mutated $false -Warning $warning -Selection $selection -PlanPath $planPath -MarkerPath $markerPath } elseif ($markerExists) { if (-not $PreviewOnly) { Remove-Item -LiteralPath $markerPath -Force } $markerExists = $false } $metadataIds = @(Get-ContinuousCoReviewProjectMetadataIds -RepoRoot $root) $providers = if ($PSBoundParameters.ContainsKey('ActiveProviders')) { @($ActiveProviders) } else { @(Get-ContinuousCoReviewActiveProjectProviders -RepoRoot $root) } $selection = Select-ContinuousCoReviewVerificationPlan -RepoRoot $root -Catalog $catalog -DetectedMetadataIds $metadataIds -QualityProfileId $QualityProfileId -ActiveProviders $providers if ($selection.state -ne 'selected') { $removed = $false if ($generatedAndUnmodified) { $removed = $true if (-not $PreviewOnly) { Remove-Item -LiteralPath $planPath -Force Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue } } $action = if ($removed) { if ($PreviewOnly) { 'would-remove-unconfigured-generated-plan' } else { 'removed-unconfigured-generated-plan' } } else { 'verification-not-configured' } if ($selection.state -eq 'invalid') { $action = if ($removed) { 'removed-invalid-generated-plan' } else { 'verification-plan-invalid' } } # T007 / FR-012: THE BOOTSTRAP DEADLOCK ENDS HERE. Reaching this point used to mean the project # had no verification plan and no way to get one - the campaign preflight then failed with # `verification-not-configured`, while the stop surface demanded a review that could not start # (DRIFT-199-I001-008, reproduced on this very repository). A project matches nothing simply by # not being an npm project, which is most of them. # # Only for the genuinely UNCONFIGURED state: an INVALID plan is a different situation and must # keep failing loudly, because scaffolding over a broken plan would hide the consumer's mistake. # Only when no plan file exists at all - never overwriting or resurrecting one. # GATED ON THE VALIDATOR ACTUALLY EXISTING, and that gate is load-bearing rather than defensive. # The starter's whole value is that its one command RUNS on day one; scaffolding a plan whose # only command is a missing script would fail on first review and teach the consumer that # verification is broken - worse than no plan, because it looks configured. The plan contract # validates shape, not the presence of an executable, so this is checked here. # # It also scopes the behaviour correctly: `.specify/extensions/...` is deployed by init itself, # so its presence is exactly the question "is this a governed project". $governanceValidator = Join-Path $root '.specify/extensions/specrew-speckit/scripts/validate-governance.ps1' if ($selection.state -eq 'verification-not-configured' -and -not (Test-Path -LiteralPath $planPath -PathType Leaf) -and (Test-Path -LiteralPath $governanceValidator -PathType Leaf)) { $starter = New-ContinuousCoReviewStarterVerificationPlan $starterCheck = Test-ContinuousCoReviewVerificationPlan -Plan $starter -RepoRoot $root if ($starterCheck.valid) { # Written as an EXPLICIT plan with NO ownership marker, so from here on it is found by # the project-config branch and PRESERVED byte-for-byte. A marked generated plan would # delete itself on the very next run, because the block above removes a generated plan # whose source has disappeared - and this one's source never appears. if (-not $PreviewOnly) { Write-ContinuousCoReviewVerificationPlanFile -Path $planPath -Content (ConvertTo-ContinuousCoReviewSupplierCanonicalJson $starter) Write-ContinuousCoReviewVerificationPlanFile -Path (Join-Path $root '.specrew/verification-plan.templates.md') -Content (Get-ContinuousCoReviewStarterVerificationTemplates) } $starterSelection = [pscustomobject][ordered]@{ state = 'selected'; source_kind = 'starter' source_identity = [pscustomobject][ordered]@{ path = '.specrew/verification-plan.json' } plan = $starter; plan_id = $starter.plan_id } return New-ContinuousCoReviewVerificationMaterializationResult -State 'selected' ` -Action $(if ($PreviewOnly) { 'would-create-starter-plan' } else { 'created-starter-plan' }) ` -Mutated (-not $PreviewOnly) -Warning $null -Selection $starterSelection -PlanPath $planPath -MarkerPath $markerPath } } return New-ContinuousCoReviewVerificationMaterializationResult -State $selection.state -Action $action -Mutated ($removed -and -not $PreviewOnly) -Warning $selection.action -Selection $selection -PlanPath $planPath -MarkerPath $markerPath } $planContent = ConvertTo-ContinuousCoReviewSupplierCanonicalJson $selection.plan $catalogId = [string](Get-ContinuousCoReviewSupplierProp $catalog 'catalog_id') $markerObject = [ordered]@{ schema_version = '1.0' selection_id = $selection.selection_id generated_content_hash = $selection.generated_content_hash plan_id = $selection.plan_id catalog_id = $catalogId source_kind = $selection.source_kind source_identity = $selection.source_identity } $markerContent = ConvertTo-ContinuousCoReviewSupplierCanonicalJson $markerObject if ($generatedAndUnmodified -and (Get-Content -LiteralPath $planPath -Raw) -ceq $planContent -and (Get-Content -LiteralPath $markerPath -Raw) -ceq $markerContent) { return New-ContinuousCoReviewVerificationMaterializationResult -State selected -Action 'generated-plan-current' -Mutated $false -Warning $null -Selection $selection -PlanPath $planPath -MarkerPath $markerPath } $action = if ($generatedAndUnmodified) { if ($PreviewOnly) { 'would-refresh-generated-plan' } else { 'refreshed-generated-plan' } } else { if ($PreviewOnly) { 'would-create-generated-plan' } else { 'created-generated-plan' } } if (-not $PreviewOnly) { Write-ContinuousCoReviewVerificationPlanFile -Path $planPath -Content $planContent Write-ContinuousCoReviewVerificationPlanFile -Path $markerPath -Content $markerContent } return New-ContinuousCoReviewVerificationMaterializationResult -State selected -Action $action -Mutated (-not $PreviewOnly) -Warning $null -Selection $selection -PlanPath $planPath -MarkerPath $markerPath } |