Files
ai-contract-platform/scripts/run-phase13-protected-apply-transition-gate.ps1
T
2026-05-02 14:56:27 +08:00

749 lines
32 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
param(
[string]$PreflightSummaryPath = "",
[string]$ApplySummaryPath = "",
[string]$LibraryRoot = "",
[string]$ArtifactRoot = "",
[switch]$AllowWrite,
[switch]$AllowWarnUpstream,
[switch]$FailOnBlocked
)
$ErrorActionPreference = "Stop"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss-fff"
if ([string]::IsNullOrWhiteSpace($LibraryRoot)) {
$LibraryRoot = Join-Path $repoRoot "project-docs\phase13-legal-knowledge-template-library"
}
if (-not [System.IO.Path]::IsPathRooted($LibraryRoot)) {
$LibraryRoot = Join-Path $repoRoot $LibraryRoot
}
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase13-protected-apply-transition-gate"
}
if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot $ArtifactRoot
}
$artifactDir = Join-Path $ArtifactRoot $timestamp
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
$summaryJson = Join-Path $artifactDir "phase13-protected-apply-transition-gate-summary.json"
$summaryMd = Join-Path $artifactDir "phase13-protected-apply-transition-gate-summary.md"
$protectedApplyPlanCsv = Join-Path $artifactDir "protected-apply-plan.csv"
$transitionPlanCsv = Join-Path $artifactDir "asset-status-transition-plan.csv"
$rollbackPlanCsv = Join-Path $artifactDir "rollback-plan.csv"
$evidenceRequirementsCsv = Join-Path $artifactDir "transition-evidence-requirements.csv"
$blockersCsv = Join-Path $artifactDir "protected-apply-transition-blockers.csv"
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
$backupRoot = Join-Path $artifactDir "inventory-backups"
$checks = New-Object System.Collections.Generic.List[object]
$blockers = New-Object System.Collections.Generic.List[object]
$protectedApplyPlan = New-Object System.Collections.Generic.List[object]
$transitionPlan = New-Object System.Collections.Generic.List[object]
$rollbackPlan = New-Object System.Collections.Generic.List[object]
$evidenceRows = New-Object System.Collections.Generic.List[object]
function Add-Check {
param(
[string]$Key,
[string]$Label,
[ValidateSet("pass", "fail", "warn", "skip", "blocked")]
[string]$Status,
[string]$Detail,
[bool]$Required = $true,
[hashtable]$Meta = @{}
)
$checks.Add([ordered]@{
key = $Key
label = $Label
status = $Status
required = $Required
detail = $Detail
meta = $Meta
}) | Out-Null
}
function Add-Blocker {
param(
[string]$Kind,
[string]$Id,
[string]$Severity,
[string]$Blocker,
[string]$Evidence
)
$blockers.Add([pscustomobject][ordered]@{
kind = $Kind
id = $Id
severity = $Severity
blocker = $Blocker
evidence = $Evidence
}) | Out-Null
}
function Save-Json {
param([string]$Path, [object]$Value)
$Value | ConvertTo-Json -Depth 60 | Set-Content -Encoding UTF8 -Path $Path
}
function Read-JsonFile {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return $null
}
return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Import-CsvRows {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return @()
}
return @(Import-Csv -LiteralPath $Path)
}
function Resolve-LatestArtifactSummary {
param([string]$RootRelativePath, [string]$SummaryFileName)
$root = Join-Path $repoRoot $RootRelativePath
$latest = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1
if ($latest) {
return Join-Path $latest.FullName $SummaryFileName
}
return ""
}
function Get-SummaryArtifact {
param([object]$Summary, [string]$Name)
if ($null -eq $Summary -or $null -eq $Summary.artifacts) {
return ""
}
$property = $Summary.artifacts.PSObject.Properties[$Name]
if ($null -eq $property -or $null -eq $property.Value) {
return ""
}
return [string]$property.Value
}
function Get-MetricValue {
param([object]$Summary, [string]$Name, [object]$Default = 0)
if ($null -eq $Summary -or $null -eq $Summary.metrics) {
return $Default
}
$property = $Summary.metrics.PSObject.Properties[$Name]
if ($null -eq $property -or $null -eq $property.Value) {
return $Default
}
return $property.Value
}
function Get-FieldValue {
param([object]$Row, [string]$Name)
if ($null -eq $Row) {
return ""
}
$property = $Row.PSObject.Properties[$Name]
if ($null -eq $property -or $null -eq $property.Value) {
return ""
}
return [string]$property.Value
}
function Test-Truthy {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $false
}
return @("true", "yes", "1", "required", "是", "已通过") -contains $Value.Trim().ToLowerInvariant()
}
function Convert-OverallStatusText {
param([string]$Status)
switch ($Status) {
"PASS" { return "通过" }
"WARN" { return "通过但有警告" }
"FAIL" { return "未通过" }
"BLOCKED" { return "阻塞" }
default { return $Status }
}
}
function Convert-CheckStatusText {
param([string]$Status)
switch ($Status) {
"pass" { return "通过" }
"fail" { return "失败" }
"warn" { return "警告" }
"skip" { return "跳过" }
"blocked" { return "阻塞" }
default { return $Status }
}
}
function New-MapByField {
param([object[]]$Rows, [string]$Field)
$map = @{}
foreach ($row in $Rows) {
$key = Get-FieldValue -Row $row -Name $Field
if (-not [string]::IsNullOrWhiteSpace($key)) {
$map[$key] = $row
}
}
return $map
}
function Get-DesiredStatus {
param([object]$ApplyRow)
$explicitStatus = Get-FieldValue -Row $ApplyRow -Name "asset_status_after_review"
if (-not [string]::IsNullOrWhiteSpace($explicitStatus)) {
return $explicitStatus
}
$reviewResult = Get-FieldValue -Row $ApplyRow -Name "review_result"
switch ($reviewResult) {
"APPROVED_FOR_PRODUCTION" { return "PUBLISHED" }
"APPROVED_FOR_REVIEW_IMPORT" { return "APPROVED" }
"CHANGES_REQUIRED" { return "CHANGES_REQUIRED" }
"REJECTED" { return "REJECTED" }
default { return "LEGAL_REVIEW" }
}
}
function Get-ProtectedAction {
param([string]$ReviewResult, [string]$DesiredStatus, [bool]$ProductionReady)
switch ($ReviewResult) {
"APPROVED_FOR_PRODUCTION" {
if ($ProductionReady -or $DesiredStatus -eq "PUBLISHED") {
return "MARK_PRODUCTION_READY"
}
return "MARK_APPROVED"
}
"APPROVED_FOR_REVIEW_IMPORT" { return "MARK_APPROVED" }
"CHANGES_REQUIRED" { return "MARK_CHANGES_REQUIRED" }
"REJECTED" { return "MARK_REJECTED" }
default { return "HOLD" }
}
}
function Add-Note {
param([System.Collections.Generic.List[string]]$List, [string]$Value)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
$List.Add($Value) | Out-Null
}
}
function Export-InventoryRows {
param([object[]]$Rows, [string]$Path)
$Rows | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
}
if ([string]::IsNullOrWhiteSpace($PreflightSummaryPath)) {
$PreflightSummaryPath = Resolve-LatestArtifactSummary `
-RootRelativePath "project-docs\qa-artifacts\phase13-review-return-preflight" `
-SummaryFileName "phase13-review-return-preflight-summary.json"
}
if ([string]::IsNullOrWhiteSpace($ApplySummaryPath)) {
$ApplySummaryPath = Resolve-LatestArtifactSummary `
-RootRelativePath "project-docs\qa-artifacts\phase13-review-result-apply" `
-SummaryFileName "phase13-review-result-apply-summary.json"
}
foreach ($pathName in @("PreflightSummaryPath", "ApplySummaryPath")) {
$value = Get-Variable -Name $pathName -ValueOnly
if (-not [string]::IsNullOrWhiteSpace($value) -and -not [System.IO.Path]::IsPathRooted($value)) {
Set-Variable -Name $pathName -Value (Join-Path $repoRoot $value)
}
}
if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) {
Add-Check -Key "gstack" -Label "gstack 前置检查" -Status "fail" -Detail "gstack 缺失"
} else {
Add-Check -Key "gstack" -Label "gstack 前置检查" -Status "pass" -Detail "GSTACK_OK"
}
$branch = (git -C $repoRoot rev-parse --abbrev-ref HEAD).Trim()
$commit = (git -C $repoRoot rev-parse HEAD).Trim()
$remoteUrl = (git -C $repoRoot remote get-url origin).Trim()
$gitStatus = git -C $repoRoot status --short
($gitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $gitStatusPath
if ($remoteUrl -match "192\.168\.3\.90") {
Add-Check -Key "origin_remote" -Label "origin 远端仓库" -Status "pass" -Detail "origin 指向 192.168.3.90" -Meta @{ branch = $branch; commit = $commit; remote = $remoteUrl }
} else {
Add-Check -Key "origin_remote" -Label "origin 远端仓库" -Status "fail" -Detail "origin 未指向 192.168.3.90" -Meta @{ branch = $branch; commit = $commit; remote = $remoteUrl }
}
if ($gitStatus) {
Add-Check -Key "working_tree" -Label "工作区状态" -Status "warn" -Required $false -Detail "工作区存在未提交改动;本门禁只记录证据,默认不写资产清单" -Meta @{ artifact = $gitStatusPath }
} else {
Add-Check -Key "working_tree" -Label "工作区状态" -Status "pass" -Detail "工作区干净" -Meta @{ artifact = $gitStatusPath }
}
if (Test-Path -LiteralPath $LibraryRoot -PathType Container) {
Add-Check -Key "library_root" -Label "阶段 13 资料包根目录" -Status "pass" -Detail "资料包根目录存在" -Meta @{ root = $LibraryRoot }
} else {
Add-Check -Key "library_root" -Label "阶段 13 资料包根目录" -Status "fail" -Detail "资料包根目录缺失" -Meta @{ root = $LibraryRoot }
}
$preflightSummary = Read-JsonFile -Path $PreflightSummaryPath
$applySummary = Read-JsonFile -Path $ApplySummaryPath
if ($null -eq $preflightSummary) {
Add-Check -Key "preflight_summary" -Label "回收预检摘要" -Status "fail" -Detail "缺少回收预检摘要" -Meta @{ path = $PreflightSummaryPath }
} else {
Add-Check -Key "preflight_summary" -Label "回收预检摘要" -Status "pass" -Detail "已读取回收预检摘要,状态=$($preflightSummary.status)" -Meta @{ path = $PreflightSummaryPath }
}
if ($null -eq $applySummary) {
Add-Check -Key "apply_summary" -Label "回填 DryRun 摘要" -Status "fail" -Detail "缺少回填 DryRun 摘要" -Meta @{ path = $ApplySummaryPath }
} else {
Add-Check -Key "apply_summary" -Label "回填 DryRun 摘要" -Status "pass" -Detail "已读取回填 DryRun 摘要,状态=$($applySummary.status)" -Meta @{ path = $ApplySummaryPath }
}
$preflightMergePlanPath = Get-SummaryArtifact -Summary $preflightSummary -Name "merge_plan"
$preflightValidationBoardPath = Get-SummaryArtifact -Summary $preflightSummary -Name "validation_board"
$applyPlanPath = Get-SummaryArtifact -Summary $applySummary -Name "apply_plan"
$applyBlockersPath = Get-SummaryArtifact -Summary $applySummary -Name "blockers"
$mergeRows = Import-CsvRows -Path $preflightMergePlanPath
$validationRows = Import-CsvRows -Path $preflightValidationBoardPath
$applyRows = Import-CsvRows -Path $applyPlanPath
$applyBlockerRows = Import-CsvRows -Path $applyBlockersPath
if ($mergeRows.Count -gt 0) {
Add-Check -Key "merge_plan" -Label "回收合并计划" -Status "pass" -Detail "合并计划行数=$($mergeRows.Count)" -Meta @{ path = $preflightMergePlanPath }
} else {
Add-Check -Key "merge_plan" -Label "回收合并计划" -Status "fail" -Detail "合并计划缺失或为空" -Meta @{ path = $preflightMergePlanPath }
}
if ($applyRows.Count -gt 0) {
Add-Check -Key "apply_plan" -Label "回填计划" -Status "pass" -Detail "回填计划行数=$($applyRows.Count)" -Meta @{ path = $applyPlanPath }
} else {
Add-Check -Key "apply_plan" -Label "回填计划" -Status "fail" -Detail "回填计划缺失或为空" -Meta @{ path = $applyPlanPath }
}
$preflightStatus = if ($null -eq $preflightSummary) { "MISSING" } else { [string]$preflightSummary.status }
$applyStatus = if ($null -eq $applySummary) { "MISSING" } else { [string]$applySummary.status }
$mergeReadyRows = [int](Get-MetricValue -Summary $preflightSummary -Name "merge_ready_rows")
$preflightBlockers = [int](Get-MetricValue -Summary $preflightSummary -Name "blocker_count")
$applyBlockers = [int](Get-MetricValue -Summary $applySummary -Name "blockers")
$applySummaryAllowWrite = [bool](Get-MetricValue -Summary $applySummary -Name "allow_write" -Default $false)
$preflightPassEquivalent = ($preflightStatus -eq "PASS" -or ([bool]$AllowWarnUpstream -and $preflightStatus -eq "WARN" -and $preflightBlockers -eq 0 -and $mergeReadyRows -gt 0))
$applyPassEquivalent = ($applyStatus -eq "PASS" -or ([bool]$AllowWarnUpstream -and $applyStatus -eq "WARN" -and $applyBlockers -eq 0))
if ($preflightPassEquivalent) {
$preflightPassDetail = if ($preflightStatus -eq "PASS") { "回收预检已 PASS" } else { "回收预检为 WARN,但阻塞项为 0,已通过 -AllowWarnUpstream 显式接受" }
Add-Check -Key "preflight_pass" -Label "回收预检通过" -Status "pass" -Detail $preflightPassDetail
} else {
Add-Check -Key "preflight_pass" -Label "回收预检通过" -Status "blocked" -Detail "回收预检状态=$preflightStatus;真实回收结果齐套前不得回填"
Add-Blocker -Kind "preflight" -Id $PreflightSummaryPath -Severity "P0" -Blocker "回收预检未通过" -Evidence "status=$preflightStatus; merge_ready_rows=$mergeReadyRows; blockers=$preflightBlockers"
}
if ($mergeReadyRows -gt 0) {
Add-Check -Key "merge_ready_rows" -Label "可回填合并项" -Status "pass" -Detail "可回填合并项=$mergeReadyRows"
} else {
Add-Check -Key "merge_ready_rows" -Label "可回填合并项" -Status "blocked" -Detail "可回填合并项为 0"
Add-Blocker -Kind "preflight" -Id "merge_ready_rows" -Severity "P0" -Blocker "没有可进入回填的合并项" -Evidence "merge_ready_rows=0"
}
if ($applyPassEquivalent) {
$applyPassDetail = if ($applyStatus -eq "PASS") { "回填 DryRun 已 PASS" } else { "回填门禁为 WARN,但阻塞项为 0,已通过 -AllowWarnUpstream 显式接受" }
Add-Check -Key "apply_pass" -Label "回填 DryRun 通过" -Status "pass" -Detail $applyPassDetail
} else {
Add-Check -Key "apply_pass" -Label "回填 DryRun 通过" -Status "blocked" -Detail "回填门禁状态=$applyStatus;不得迁移资产状态"
Add-Blocker -Kind "apply" -Id $ApplySummaryPath -Severity "P0" -Blocker "回填 DryRun 未通过" -Evidence "status=$applyStatus; apply_blockers=$applyBlockers"
}
if ($applyBlockers -eq 0 -and $applyBlockerRows.Count -eq 0) {
Add-Check -Key "apply_blockers" -Label "回填阻塞项" -Status "pass" -Detail "无回填阻塞项"
} else {
Add-Check -Key "apply_blockers" -Label "回填阻塞项" -Status "blocked" -Detail "回填阻塞项=$applyBlockers;阻塞文件行数=$($applyBlockerRows.Count)"
Add-Blocker -Kind "apply" -Id "apply_blockers" -Severity "P0" -Blocker "回填门禁仍有阻塞项" -Evidence $applyBlockersPath
}
$assetInventoryMap = @{
legal_source = @{ file = "inventories\legal-source-inventory.csv"; id = "source_id"; label = "法源" }
knowledge_card = @{ file = "inventories\legal-knowledge-card-inventory.csv"; id = "knowledge_card_id"; label = "法律知识卡" }
review_rule = @{ file = "inventories\review-risk-rule-inventory.csv"; id = "rule_id"; label = "审核风险规则" }
clause = @{ file = "inventories\clause-library-inventory.csv"; id = "clause_id"; label = "标准条款" }
contract_template = @{ file = "inventories\contract-template-inventory.csv"; id = "template_id"; label = "合同模板" }
review_golden_sample = @{ file = "inventories\review-golden-sample-inventory.csv"; id = "sample_id"; label = "审核黄金样本" }
generation_golden_sample = @{ file = "inventories\generation-golden-sample-inventory.csv"; id = "sample_id"; label = "生成黄金样本" }
kb_qa_golden_sample = @{ file = "inventories\kb-qa-golden-sample-inventory.csv"; id = "sample_id"; label = "知识库问答黄金样本" }
}
$inventoryCache = @{}
$inventoryRowMaps = @{}
foreach ($assetType in $assetInventoryMap.Keys) {
$relativePath = $assetInventoryMap[$assetType].file
$path = Join-Path $LibraryRoot $relativePath
$rows = Import-CsvRows -Path $path
$inventoryCache[$assetType] = [pscustomobject]@{ path = $path; rows = $rows; id_field = $assetInventoryMap[$assetType].id }
$inventoryRowMaps[$assetType] = New-MapByField -Rows $rows -Field $assetInventoryMap[$assetType].id
}
$mergeByReviewId = New-MapByField -Rows $mergeRows -Field "review_id"
$validationByReviewId = New-MapByField -Rows $validationRows -Field "review_id"
$applyByReviewId = New-MapByField -Rows $applyRows -Field "review_id"
$reviewIds = New-Object System.Collections.Generic.List[string]
foreach ($key in $mergeByReviewId.Keys) {
if (-not $reviewIds.Contains($key)) {
$reviewIds.Add($key) | Out-Null
}
}
foreach ($key in $applyByReviewId.Keys) {
if (-not $reviewIds.Contains($key)) {
$reviewIds.Add($key) | Out-Null
}
}
$transitionEligibleRows = 0
$writeEligibleRows = 0
$holdRows = 0
$missingApplyRows = 0
$unknownAssetRows = 0
$inventoryFilesToWrite = @{}
foreach ($reviewId in ($reviewIds | Sort-Object)) {
$mergeRow = if ($mergeByReviewId.ContainsKey($reviewId)) { $mergeByReviewId[$reviewId] } else { $null }
$validationRow = if ($validationByReviewId.ContainsKey($reviewId)) { $validationByReviewId[$reviewId] } else { $null }
$applyRow = if ($applyByReviewId.ContainsKey($reviewId)) { $applyByReviewId[$reviewId] } else { $null }
$assetType = Get-FieldValue -Row $applyRow -Name "asset_type"
if ([string]::IsNullOrWhiteSpace($assetType)) {
$assetType = Get-FieldValue -Row $mergeRow -Name "asset_type"
}
$assetId = Get-FieldValue -Row $applyRow -Name "asset_id"
if ([string]::IsNullOrWhiteSpace($assetId)) {
$assetId = Get-FieldValue -Row $mergeRow -Name "asset_id"
}
$mergeAction = Get-FieldValue -Row $mergeRow -Name "merge_action"
$reviewResult = Get-FieldValue -Row $applyRow -Name "review_result"
$desiredStatus = Get-DesiredStatus -ApplyRow $applyRow
$desiredProductionReady = Test-Truthy -Value (Get-FieldValue -Row $applyRow -Name "production_ready_after_review")
$inventoryPath = ""
$currentStatus = ""
$currentProductionReady = ""
$assetKnown = $false
$reasons = New-Object System.Collections.Generic.List[string]
if (-not $assetInventoryMap.ContainsKey($assetType)) {
Add-Note -List $reasons -Value "unknown_asset_type"
$unknownAssetRows += 1
Add-Blocker -Kind "asset" -Id "$assetType`:$assetId" -Severity "P0" -Blocker "未知资产类型,无法生成状态迁移计划" -Evidence $reviewId
} else {
$inventoryPath = $inventoryCache[$assetType].path
if (-not (Test-Path -LiteralPath $inventoryPath -PathType Leaf)) {
Add-Note -List $reasons -Value "inventory_file_missing"
Add-Blocker -Kind "asset" -Id "$assetType`:$assetId" -Severity "P0" -Blocker "资产清单文件缺失" -Evidence $inventoryPath
} elseif (-not $inventoryRowMaps[$assetType].ContainsKey($assetId)) {
Add-Note -List $reasons -Value "asset_not_found_in_inventory"
$unknownAssetRows += 1
Add-Blocker -Kind "asset" -Id "$assetType`:$assetId" -Severity "P0" -Blocker "资产不在清单中" -Evidence $inventoryPath
} else {
$assetKnown = $true
$inventoryRow = $inventoryRowMaps[$assetType][$assetId]
$currentStatus = Get-FieldValue -Row $inventoryRow -Name "status"
$currentProductionReady = Get-FieldValue -Row $inventoryRow -Name "production_ready"
}
}
if ($null -eq $applyRow) {
Add-Note -List $reasons -Value "missing_apply_plan_row"
$missingApplyRows += 1
}
if ($mergeAction -ne "READY_FOR_DRY_RUN_APPLY") {
Add-Note -List $reasons -Value "preflight_merge_not_ready"
}
if ($preflightStatus -ne "PASS") {
Add-Note -List $reasons -Value "preflight_not_pass"
}
if ($applyStatus -ne "PASS") {
Add-Note -List $reasons -Value "apply_not_pass"
}
if ($applyBlockers -gt 0 -or $applyBlockerRows.Count -gt 0) {
Add-Note -List $reasons -Value "apply_blockers_exist"
}
if ([string]::IsNullOrWhiteSpace($reviewResult) -or $reviewResult -eq "PENDING") {
Add-Note -List $reasons -Value "review_result_pending"
}
$protectedAction = Get-ProtectedAction -ReviewResult $reviewResult -DesiredStatus $desiredStatus -ProductionReady $desiredProductionReady
$canTransition = (
$assetKnown -and
$mergeAction -eq "READY_FOR_DRY_RUN_APPLY" -and
$preflightPassEquivalent -and
$applyPassEquivalent -and
$applyBlockers -eq 0 -and
$applyBlockerRows.Count -eq 0 -and
$protectedAction -ne "HOLD"
)
if ($canTransition) {
$transitionEligibleRows += 1
} else {
$holdRows += 1
}
$writeAllowed = ($canTransition -and [bool]$AllowWrite -and $applySummaryAllowWrite)
if ($writeAllowed) {
$writeEligibleRows += 1
$inventoryFilesToWrite[$assetType] = $true
}
$blockReason = $reasons -join ";"
$protectedApplyPlan.Add([pscustomobject][ordered]@{
review_id = $reviewId
asset_type = $assetType
asset_id = $assetId
review_result = $reviewResult
preflight_merge_action = $mergeAction
protected_action = if ($canTransition) { $protectedAction } else { "HOLD" }
write_allowed = $writeAllowed
block_reason = $blockReason
evidence_requirements = "真实复核结果;复核人;复核日期;证据路径;回收预检PASS;回填DryRun PASS;回滚备份"
}) | Out-Null
$transitionPlan.Add([pscustomobject][ordered]@{
review_id = $reviewId
asset_type = $assetType
asset_id = $assetId
inventory_path = $inventoryPath
current_status = $currentStatus
desired_status = $desiredStatus
current_production_ready = $currentProductionReady
desired_production_ready = $desiredProductionReady
transition_action = if ($canTransition) { $protectedAction } else { "HOLD" }
requires_backup = $true
write_allowed = $writeAllowed
block_reason = $blockReason
}) | Out-Null
$evidenceRows.Add([pscustomobject][ordered]@{
review_id = $reviewId
asset_type = $assetType
asset_id = $assetId
preflight_completed = Get-FieldValue -Row $validationRow -Name "completed"
evidence_ready = Get-FieldValue -Row $validationRow -Name "evidence_ready"
merge_action = $mergeAction
apply_plan_present = ($null -ne $applyRow)
review_result = $reviewResult
required_evidence = "回收预检验证行;回填计划行;复核证据文件;资产清单备份;回滚验证记录"
ready_for_transition = $canTransition
blocker = $blockReason
}) | Out-Null
}
$protectedApplyPlan | Export-Csv -LiteralPath $protectedApplyPlanCsv -NoTypeInformation -Encoding UTF8
$transitionPlan | Export-Csv -LiteralPath $transitionPlanCsv -NoTypeInformation -Encoding UTF8
$evidenceRows | Export-Csv -LiteralPath $evidenceRequirementsCsv -NoTypeInformation -Encoding UTF8
foreach ($assetType in ($assetInventoryMap.Keys | Sort-Object)) {
$path = $inventoryCache[$assetType].path
if (Test-Path -LiteralPath $path -PathType Leaf) {
$backupPath = Join-Path $backupRoot ($assetInventoryMap[$assetType].file -replace "\\", "__")
$rollbackPlan.Add([pscustomobject][ordered]@{
asset_type = $assetType
inventory_path = $path
backup_path = $backupPath
rollback_action = "Copy-Item -LiteralPath backup_path -Destination inventory_path -Force"
verification = "重新运行 run-phase13-protected-apply-transition-gate.ps1 并核对资产状态"
backup_created = $false
rollback_required_if = "资产状态迁移后发现错误、证据缺失或生产发布门禁失败"
}) | Out-Null
}
}
if ($transitionEligibleRows -gt 0) {
Add-Check -Key "transition_candidates" -Label "资产状态迁移候选" -Status "pass" -Detail "可迁移资产=$transitionEligibleRows"
} else {
Add-Check -Key "transition_candidates" -Label "资产状态迁移候选" -Status "blocked" -Detail "可迁移资产为 0"
Add-Blocker -Kind "transition" -Id "transition_candidates" -Severity "P0" -Blocker "没有可执行的资产状态迁移候选" -Evidence "transition_eligible_rows=0; hold_rows=$holdRows"
}
if ([bool]$AllowWrite) {
if (-not $applySummaryAllowWrite) {
Add-Check -Key "write_guard" -Label "真实写入保护" -Status "blocked" -Detail "已传入 -AllowWrite,但上游 apply-phase13-review-results.ps1 尚未以 -AllowWrite 成功回填登记表"
Add-Blocker -Kind "write_guard" -Id "apply_summary_allow_write" -Severity "P0" -Blocker "禁止在复核登记表尚未真实回填时迁移资产清单" -Evidence "apply_summary_allow_write=false"
} elseif ($blockers.Count -gt 0) {
Add-Check -Key "write_guard" -Label "真实写入保护" -Status "blocked" -Detail "已传入 -AllowWrite,但仍存在阻塞项,拒绝写入"
} elseif ($writeEligibleRows -eq 0) {
Add-Check -Key "write_guard" -Label "真实写入保护" -Status "blocked" -Detail "已传入 -AllowWrite,但没有可写入资产"
} else {
New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null
foreach ($assetType in $inventoryFilesToWrite.Keys) {
$sourcePath = $inventoryCache[$assetType].path
$backupPath = Join-Path $backupRoot ($assetInventoryMap[$assetType].file -replace "\\", "__")
Copy-Item -LiteralPath $sourcePath -Destination $backupPath -Force
foreach ($rollbackRow in $rollbackPlan) {
if ($rollbackRow.asset_type -eq $assetType) {
$rollbackRow.backup_created = $true
$rollbackRow.backup_path = $backupPath
}
}
}
foreach ($transition in ($transitionPlan | Where-Object { $_.write_allowed })) {
$assetType = $transition.asset_type
$assetId = $transition.asset_id
if ($inventoryRowMaps[$assetType].ContainsKey($assetId)) {
$row = $inventoryRowMaps[$assetType][$assetId]
if ($row.PSObject.Properties["status"]) {
$row.status = $transition.desired_status
}
if ($row.PSObject.Properties["production_ready"]) {
$row.production_ready = [string]$transition.desired_production_ready
}
}
}
foreach ($assetType in $inventoryFilesToWrite.Keys) {
Export-InventoryRows -Rows $inventoryCache[$assetType].rows -Path $inventoryCache[$assetType].path
}
Add-Check -Key "write_guard" -Label "真实写入保护" -Status "pass" -Detail "已显式允许写入并完成 $writeEligibleRows 条资产状态迁移"
}
} else {
Add-Check -Key "write_guard" -Label "真实写入保护" -Status "skip" -Required $false -Detail "默认 DryRun,只生成受保护回填与迁移预案;真实迁移需显式传入 -AllowWrite"
}
$rollbackPlan | Export-Csv -LiteralPath $rollbackPlanCsv -NoTypeInformation -Encoding UTF8
$blockers | Export-Csv -LiteralPath $blockersCsv -NoTypeInformation -Encoding UTF8
$failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }).Count
$blockedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "blocked" }).Count
$warnings = @($checks | Where-Object { $_.status -eq "warn" }).Count
$overallStatus = if ($failedRequired -gt 0) {
"FAIL"
} elseif ($blockedRequired -gt 0) {
"BLOCKED"
} elseif ($warnings -gt 0) {
"WARN"
} else {
"PASS"
}
$metrics = [ordered]@{
preflight_status = $preflightStatus
apply_status = $applyStatus
merge_plan_rows = $mergeRows.Count
merge_ready_rows = $mergeReadyRows
apply_plan_rows = $applyRows.Count
apply_blockers = $applyBlockers
protected_apply_plan_rows = $protectedApplyPlan.Count
transition_plan_rows = $transitionPlan.Count
transition_eligible_rows = $transitionEligibleRows
write_eligible_rows = $writeEligibleRows
hold_rows = $holdRows
missing_apply_rows = $missingApplyRows
unknown_asset_rows = $unknownAssetRows
rollback_plan_rows = $rollbackPlan.Count
evidence_requirement_rows = $evidenceRows.Count
blocker_count = $blockers.Count
allow_write = [bool]$AllowWrite
allow_warn_upstream = [bool]$AllowWarnUpstream
upstream_apply_allow_write = $applySummaryAllowWrite
}
$summary = [ordered]@{
name = "phase13-protected-apply-transition-gate"
status = $overallStatus
generated_at = (Get-Date).ToUniversalTime().ToString("o")
branch = $branch
commit = $commit
remote = $remoteUrl
library_root = $LibraryRoot
preflight_summary_path = $PreflightSummaryPath
apply_summary_path = $ApplySummaryPath
checks = $checks.ToArray()
metrics = $metrics
artifacts = [ordered]@{
summary_json = $summaryJson
summary_md = $summaryMd
protected_apply_plan = $protectedApplyPlanCsv
transition_plan = $transitionPlanCsv
rollback_plan = $rollbackPlanCsv
evidence_requirements = $evidenceRequirementsCsv
blockers = $blockersCsv
git_status = $gitStatusPath
}
totals = [ordered]@{
total_checks = $checks.Count
failed_required = $failedRequired
blocked_required = $blockedRequired
warnings = $warnings
}
}
Save-Json -Path $summaryJson -Value $summary
$md = New-Object System.Collections.Generic.List[string]
$md.Add("# 阶段 13 受保护回填与资产状态迁移门禁") | Out-Null
$md.Add("") | Out-Null
$md.Add("- 门禁状态:$(Convert-OverallStatusText -Status $overallStatus)($overallStatus)") | Out-Null
$md.Add("- 生成时间:$($summary.generated_at)") | Out-Null
$md.Add("- 分支:$branch") | Out-Null
$md.Add("- 提交:$commit") | Out-Null
$md.Add("- 写入模式:$([bool]$AllowWrite)") | Out-Null
$md.Add("- 允许无阻塞 WARN 上游:$([bool]$AllowWarnUpstream)") | Out-Null
$md.Add("- 上游回收预检:$preflightStatus") | Out-Null
$md.Add("- 上游回填门禁:$applyStatus") | Out-Null
$md.Add("- 产物目录:$artifactDir") | Out-Null
$md.Add("") | Out-Null
$md.Add("## 统计") | Out-Null
$md.Add("") | Out-Null
$md.Add("| 指标 | 数量 |") | Out-Null
$md.Add("|---|---:|") | Out-Null
$md.Add("| 合并计划行 | $($metrics.merge_plan_rows) |") | Out-Null
$md.Add("| 可回填合并项 | $($metrics.merge_ready_rows) |") | Out-Null
$md.Add("| 回填计划行 | $($metrics.apply_plan_rows) |") | Out-Null
$md.Add("| 回填阻塞项 | $($metrics.apply_blockers) |") | Out-Null
$md.Add("| 受保护回填计划行 | $($metrics.protected_apply_plan_rows) |") | Out-Null
$md.Add("| 资产迁移计划行 | $($metrics.transition_plan_rows) |") | Out-Null
$md.Add("| 可迁移资产 | $($metrics.transition_eligible_rows) |") | Out-Null
$md.Add("| 可写入资产 | $($metrics.write_eligible_rows) |") | Out-Null
$md.Add("| HOLD 资产 | $($metrics.hold_rows) |") | Out-Null
$md.Add("| 阻塞项 | $($metrics.blocker_count) |") | Out-Null
$md.Add("") | Out-Null
$md.Add("## 检查项") | Out-Null
$md.Add("") | Out-Null
$md.Add("| 检查项 | 必需 | 状态 | 说明 |") | Out-Null
$md.Add("|---|---:|---|---|") | Out-Null
foreach ($check in $checks) {
$requiredText = if ($check.required) { "是" } else { "否" }
$detail = ([string]$check.detail).Replace("|", "/")
$md.Add("| $($check.label) | $requiredText | $(Convert-CheckStatusText -Status $check.status) | $detail |") | Out-Null
}
$md.Add("") | Out-Null
$md.Add("## 当前结论") | Out-Null
$md.Add("") | Out-Null
if ($overallStatus -eq "BLOCKED") {
$md.Add("受保护回填与资产状态迁移预案已生成,但真实回收预检或回填门禁仍未通过,因此资产清单保持不变。") | Out-Null
} elseif ($overallStatus -eq "PASS" -and -not [bool]$AllowWrite) {
$md.Add("预案通过。当前仍为 DryRun,真实资产迁移需要在上游真实回填成功后显式传入 -AllowWrite。") | Out-Null
} elseif ($overallStatus -eq "PASS") {
$md.Add("受保护回填与资产状态迁移已完成,并已生成回滚备份与验证证据。") | Out-Null
} else {
$md.Add("受保护回填与资产状态迁移门禁未完全通过,请查看阻塞项和迁移计划。") | Out-Null
}
$md.Add("") | Out-Null
$md.Add("本地产物:") | Out-Null
$md.Add("") | Out-Null
$md.Add("- phase13-protected-apply-transition-gate-summary.json") | Out-Null
$md.Add("- phase13-protected-apply-transition-gate-summary.md") | Out-Null
$md.Add("- protected-apply-plan.csv") | Out-Null
$md.Add("- asset-status-transition-plan.csv") | Out-Null
$md.Add("- rollback-plan.csv") | Out-Null
$md.Add("- transition-evidence-requirements.csv") | Out-Null
$md.Add("- protected-apply-transition-blockers.csv") | Out-Null
$md | Set-Content -Encoding UTF8 -Path $summaryMd
Write-Host "PHASE13_PROTECTED_APPLY_TRANSITION_GATE_$overallStatus $artifactDir"
if ($overallStatus -eq "FAIL") {
throw "阶段 13 受保护回填与资产状态迁移门禁未通过。查看:$summaryMd"
}
if ($overallStatus -eq "BLOCKED" -and $FailOnBlocked) {
throw "阶段 13 受保护回填与资产状态迁移门禁被阻塞。查看:$summaryMd"
}