749 lines
32 KiB
PowerShell
749 lines
32 KiB
PowerShell
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"
|
||
}
|
||
|