feat: add reviewer assignment matrix gate

This commit is contained in:
2026-05-13 12:10:52 +08:00
parent 22fa40d879
commit 61cb3daf68
3 changed files with 427 additions and 7 deletions
@@ -715,3 +715,39 @@
- 真实 reviewer 完成审查、签收、证据留存。
- 最终上线批准人签署 `FINAL_APPROVER/final-launch-approval-checklist.md`。
- 本轮只导入正式治理台账和修正统计口径,没有伪造任何批准结论。
## 2026-05-13 真实 reviewer 分配矩阵入口补强
### 本轮完成项
- 新增 `scripts/apply-formal-launch-reviewer-assignment-matrix.ps1`:
- 默认只校验 `reviewer-assignment-matrix.csv`,不修改台账。
- 真实法务、业务、运营、最终批准人四组仍为 `TBD`、`PENDING`、`待复核` 等占位时输出 `BLOCKED`。
- 四组均填写真实姓名后输出 `PASS`。
- 只有显式加 `-Apply` 时,才会把真实分配写回复核任务包、生产签收台账和法务/业务/运营协作包回填表。
- 脚本不会批准任何复核结果,不会签收证据,也不会自动签署最终上线批准清单。
- 已更新协作包说明,把真实 reviewer 分配矩阵作为真实复核之前的第一步。
### 本轮验证
- gstack 预检:`GSTACK_OK`。
- PowerShell Parser 检查:
- `scripts/apply-formal-launch-reviewer-assignment-matrix.ps1` 通过。
- 当前正式矩阵校验:
- `FORMAL_LAUNCH_REVIEWER_ASSIGNMENT_MATRIX_BLOCKED`
- 产物:`project-docs/qa-artifacts/formal-launch-reviewer-assignment-matrix/2026-05-13T12-08-45-433/formal-launch-reviewer-assignment-matrix-summary.md`
- 结果:`missing_groups=4`,包括 `LEGAL_CORE`、`BUSINESS_TEMPLATE`、`OPS_MATERIAL`、`FINAL_APPROVER`。
- 临时真实姓名矩阵烟测:
- `FORMAL_LAUNCH_REVIEWER_ASSIGNMENT_MATRIX_PASS`
- 产物:`project-docs/qa-artifacts/formal-launch-reviewer-assignment-matrix/2026-05-13T12-09-07-434/formal-launch-reviewer-assignment-matrix-summary.md`
- 说明:该烟测未使用 `-Apply`,没有修改正式台账。
### 当前状态更新
- 真实 reviewer 分配已经有可执行入口:填矩阵、先校验、再显式 Apply。
- 当前正式矩阵仍未填写真实人员,因此门禁继续停在真实分配/签收/证据阶段。
### 当前阻塞项
- 需要真实业务输入:
- `LEGAL_CORE.reviewer_name`
- `BUSINESS_TEMPLATE.reviewer_name`
- `OPS_MATERIAL.reviewer_name`
- `FINAL_APPROVER.reviewer_name`
- 填完后再运行分配矩阵脚本 `-Apply`、远程 PostgreSQL 台账导入、数据库真相源批准门禁复跑。
@@ -13,13 +13,16 @@
## 使用方式
1. 真实复核人打开自己目录下的 `return-bundle.csv`。
2. 填写真实姓名、复核日期、复核状态、批准结论和修改意见。
3. 打开对应 `evidence/` 目录下的证据文件。
4. 完成复核后,将 `EVIDENCE_STATUS: TEMPLATE` 改为 `EVIDENCE_STATUS: READY`,并删除所有 `TODO_REAL_REVIEWER`。
5. 法务、业务、运营包使用 `scripts/import-formal-launch-review-return-bundle.ps1` 校验回填包。
6. 校验通过后,才可以加 `-Apply` 写回主台账。
7. `FINAL_APPROVER/` 不使用 CSV 导入脚本;最终批准人填写 `final-launch-approval-checklist.md` 后,通过 `scripts/run-formal-launch-approval-gate.ps1` 统一验证。
1. 管理员先在 `project-docs/formal-launch-ops/reviewers/reviewer-assignment-matrix.csv` 填写真实法务、业务、运营和最终批准人。
2. 运行 `scripts/apply-formal-launch-reviewer-assignment-matrix.ps1` 校验分配矩阵;仍有 `TBD`、`PENDING`、`待复核` 等占位时会保持 `BLOCKED`。
3. 分配矩阵校验通过后,才可以加 `-Apply` 写回复核任务包、签收台账和协作包回填表。
4. 真实复核人打开自己目录下的 `return-bundle.csv`。
5. 填写真实姓名、复核日期、复核状态、批准结论和修改意见。
6. 打开对应 `evidence/` 目录下的证据文件。
7. 完成复核后,将 `EVIDENCE_STATUS: TEMPLATE` 改为 `EVIDENCE_STATUS: READY`,并删除所有 `TODO_REAL_REVIEWER`。
8. 法务、业务、运营包使用 `scripts/import-formal-launch-review-return-bundle.ps1` 校验回填包。
9. 校验通过后,才可以加 `-Apply` 写回主台账。
10. `FINAL_APPROVER/` 不使用 CSV 导入脚本;最终批准人填写 `final-launch-approval-checklist.md` 后,通过 `scripts/run-formal-launch-approval-gate.ps1` 统一验证。
## 安全规则
@@ -0,0 +1,381 @@
param(
[string]$AssignmentMatrixCsv = "",
[string]$ArtifactRoot = "",
[switch]$Apply
)
$ErrorActionPreference = "Stop"
function Get-RepoRoot {
$root = (& git rev-parse --show-toplevel 2>$null)
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($root)) {
throw "unable to resolve git repository root"
}
return $root.Trim()
}
function New-Utf8NoBomEncoding {
return New-Object System.Text.UTF8Encoding($false)
}
function Write-Utf8File {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text
)
[System.IO.File]::WriteAllText($Path, $Text, (New-Utf8NoBomEncoding))
}
function Import-CsvRows {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
return @()
}
return @(Import-Csv -LiteralPath $Path)
}
function Export-CsvRows {
param(
[Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Rows,
[Parameter(Mandatory = $true)][string]$Path
)
$Rows | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
}
function ConvertFrom-CodePoints {
param([Parameter(Mandatory = $true)][int[]]$CodePoints)
return (-join ($CodePoints | ForEach-Object { [char]$_ }))
}
$script:CnLegal = ConvertFrom-CodePoints @(0x6cd5, 0x52a1)
$script:CnBusiness = ConvertFrom-CodePoints @(0x4e1a, 0x52a1)
$script:CnOps = ConvertFrom-CodePoints @(0x8fd0, 0x8425)
$script:CnOpsMaintenance = ConvertFrom-CodePoints @(0x8fd0, 0x7ef4)
$script:CnDelivery = ConvertFrom-CodePoints @(0x4ea4, 0x4ed8)
$script:CnOwner = ConvertFrom-CodePoints @(0x8d1f, 0x8d23, 0x4eba)
$script:CnOnlineApprovalOwner = ConvertFrom-CodePoints @(0x4e0a, 0x7ebf, 0x6279, 0x51c6, 0x8d1f, 0x8d23, 0x4eba)
$script:CnPending = ConvertFrom-CodePoints @(0x5f85)
$script:CnReview = ConvertFrom-CodePoints @(0x590d, 0x6838)
$script:CnSignoff = ConvertFrom-CodePoints @(0x7b7e, 0x6536)
$script:CnApproval = ConvertFrom-CodePoints @(0x6279, 0x51c6)
$script:CnConfirm = ConvertFrom-CodePoints @(0x786e, 0x8ba4)
function Test-RealName {
param([AllowNull()][string]$Value)
$rawValue = ([string]$Value).Trim()
if ([string]::IsNullOrWhiteSpace($rawValue)) {
return $false
}
$normalized = $rawValue.ToUpperInvariant()
$placeholderValues = @(
"TBD",
"TODO",
"TBD_REAL_REVIEWER",
"TBD_REAL_LEGAL_REVIEWER",
"TBD_REAL_BUSINESS_REVIEWER",
"TBD_REAL_OPS_REVIEWER",
"TBD_REAL_LAUNCH_APPROVER",
"UNASSIGNED",
"PENDING",
"$script:CnLegal$script:CnOwner",
"$script:CnBusiness$script:CnOwner",
"$script:CnOps$script:CnOwner",
"$script:CnOpsMaintenance$script:CnOwner",
"$script:CnDelivery$script:CnOwner",
$script:CnOnlineApprovalOwner,
"$script:CnPending$script:CnLegal$script:CnReview",
"$script:CnPending$script:CnBusiness$script:CnReview",
"$script:CnPending$script:CnOps$script:CnReview",
"$script:CnPending$script:CnOnlineApprovalOwner",
"$script:CnPending$script:CnReview",
"$script:CnPending$script:CnSignoff"
)
if ($placeholderValues -contains $normalized -or $placeholderValues -contains $rawValue) {
return $false
}
if ($normalized.Contains("TODO") -or $normalized.Contains("TBD")) {
return $false
}
if ($rawValue.StartsWith($script:CnPending) -and ($rawValue.Contains($script:CnReview) -or $rawValue.Contains($script:CnSignoff) -or $rawValue.Contains($script:CnApproval) -or $rawValue.Contains($script:CnConfirm))) {
return $false
}
return $true
}
function Get-ReviewerName {
param(
[Parameter(Mandatory = $true)][hashtable]$Matrix,
[Parameter(Mandatory = $true)][string]$Group
)
if (-not $Matrix.ContainsKey($Group)) {
return ""
}
return ([string]$Matrix[$Group].reviewer_name).Trim()
}
function Get-DueDate {
param(
[Parameter(Mandatory = $true)][hashtable]$Matrix,
[Parameter(Mandatory = $true)][string]$Group
)
if (-not $Matrix.ContainsKey($Group)) {
return ""
}
return ([string]$Matrix[$Group].due_date).Trim()
}
function Get-ReviewTaskAssignee {
param(
[Parameter(Mandatory = $true)][object]$Row,
[Parameter(Mandatory = $true)][hashtable]$Matrix
)
$track = (([string]$Row.review_track) + " " + ([string]$Row.required_reviewer_role)).ToLowerInvariant()
$names = New-Object System.Collections.Generic.List[string]
if ($track.Contains("legal") -or $track.Contains($script:CnLegal)) {
$names.Add((Get-ReviewerName -Matrix $Matrix -Group "LEGAL_CORE")) | Out-Null
}
if ($track.Contains("business") -or $track.Contains($script:CnBusiness)) {
$names.Add((Get-ReviewerName -Matrix $Matrix -Group "BUSINESS_TEMPLATE")) | Out-Null
}
if ($track.Contains("ops") -or $track.Contains($script:CnOps) -or $track.Contains($script:CnDelivery)) {
$names.Add((Get-ReviewerName -Matrix $Matrix -Group "OPS_MATERIAL")) | Out-Null
}
if ($track.Contains("final") -or $track.Contains($script:CnApproval)) {
$names.Add((Get-ReviewerName -Matrix $Matrix -Group "FINAL_APPROVER")) | Out-Null
}
$realNames = @($names.ToArray() | Where-Object { Test-RealName $_ } | Select-Object -Unique)
if ($realNames.Count -eq 0) {
return ""
}
return ($realNames -join "; ")
}
function Set-IfColumnExists {
param(
[Parameter(Mandatory = $true)][object]$Row,
[Parameter(Mandatory = $true)][string]$Column,
[AllowNull()][string]$Value
)
if ($Row.PSObject.Properties.Name -contains $Column) {
$Row.$Column = $Value
}
}
$repoRoot = Get-RepoRoot
if ([string]::IsNullOrWhiteSpace($AssignmentMatrixCsv)) {
$AssignmentMatrixCsv = Join-Path $repoRoot "project-docs\formal-launch-ops\reviewers\reviewer-assignment-matrix.csv"
}
if (-not [System.IO.Path]::IsPathRooted($AssignmentMatrixCsv)) {
$AssignmentMatrixCsv = Join-Path $repoRoot $AssignmentMatrixCsv
}
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss-fff"
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\formal-launch-reviewer-assignment-matrix\$timestamp"
}
if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot $ArtifactRoot
}
New-Item -ItemType Directory -Force -Path $ArtifactRoot | Out-Null
$summaryJson = Join-Path $ArtifactRoot "formal-launch-reviewer-assignment-matrix-summary.json"
$summaryMd = Join-Path $ArtifactRoot "formal-launch-reviewer-assignment-matrix-summary.md"
$errorsPath = Join-Path $ArtifactRoot "formal-launch-reviewer-assignment-matrix-errors.txt"
$warningsPath = Join-Path $ArtifactRoot "formal-launch-reviewer-assignment-matrix-warnings.txt"
$reviewTaskPath = Join-Path $repoRoot "project-docs\formal-launch-ops\reviewers\real-review-task-package.csv"
$materialSignoffPath = Join-Path $repoRoot "project-docs\formal-launch-ops\signoff\production-material-signoff-register.csv"
$templateSignoffPath = Join-Path $repoRoot "project-docs\formal-launch-ops\signoff\production-template-signoff-register.csv"
$knowledgeSignoffPath = Join-Path $repoRoot "project-docs\formal-launch-ops\signoff\production-knowledge-signoff-register.csv"
$legalBundlePath = Join-Path $repoRoot "project-docs\formal-launch-ops\reviewers\collaboration-packages\LEGAL_CORE\return-bundle.csv"
$businessBundlePath = Join-Path $repoRoot "project-docs\formal-launch-ops\reviewers\collaboration-packages\BUSINESS_TEMPLATE\return-bundle.csv"
$opsBundlePath = Join-Path $repoRoot "project-docs\formal-launch-ops\reviewers\collaboration-packages\OPS_MATERIAL\return-bundle.csv"
$requiredGroups = @("LEGAL_CORE", "BUSINESS_TEMPLATE", "OPS_MATERIAL", "FINAL_APPROVER")
$errors = New-Object System.Collections.Generic.List[string]
$warnings = New-Object System.Collections.Generic.List[string]
$matrixRows = Import-CsvRows -Path $AssignmentMatrixCsv
if ($matrixRows.Count -eq 0) {
$errors.Add("assignment matrix has no rows: $AssignmentMatrixCsv") | Out-Null
}
$matrix = @{}
foreach ($row in $matrixRows) {
$group = ([string]$row.assignment_group).Trim()
if (-not [string]::IsNullOrWhiteSpace($group)) {
$matrix[$group] = $row
}
}
$missingGroups = New-Object System.Collections.Generic.List[string]
foreach ($group in $requiredGroups) {
if (-not $matrix.ContainsKey($group)) {
$errors.Add("assignment group missing: $group") | Out-Null
$missingGroups.Add($group) | Out-Null
continue
}
$reviewerName = ([string]$matrix[$group].reviewer_name).Trim()
if (-not (Test-RealName $reviewerName)) {
$missingGroups.Add($group) | Out-Null
}
if ([string]::IsNullOrWhiteSpace(([string]$matrix[$group].due_date).Trim())) {
$warnings.Add("assignment group $group has no due_date") | Out-Null
}
}
$changedFiles = New-Object System.Collections.Generic.List[string]
if ($Apply -and $errors.Count -eq 0 -and $missingGroups.Count -eq 0) {
$legalName = Get-ReviewerName -Matrix $matrix -Group "LEGAL_CORE"
$businessName = Get-ReviewerName -Matrix $matrix -Group "BUSINESS_TEMPLATE"
$opsName = Get-ReviewerName -Matrix $matrix -Group "OPS_MATERIAL"
$finalName = Get-ReviewerName -Matrix $matrix -Group "FINAL_APPROVER"
foreach ($row in $matrixRows) {
if ($requiredGroups -contains ([string]$row.assignment_group).Trim()) {
Set-IfColumnExists -Row $row -Column "status" -Value "ASSIGNED"
}
}
Export-CsvRows -Rows $matrixRows -Path $AssignmentMatrixCsv
$changedFiles.Add($AssignmentMatrixCsv) | Out-Null
$reviewTasks = Import-CsvRows -Path $reviewTaskPath
foreach ($row in $reviewTasks) {
$assignee = Get-ReviewTaskAssignee -Row $row -Matrix $matrix
if (Test-RealName $assignee) {
Set-IfColumnExists -Row $row -Column "assigned_reviewer" -Value $assignee
$dueDates = @(
Get-DueDate -Matrix $matrix -Group "LEGAL_CORE",
Get-DueDate -Matrix $matrix -Group "BUSINESS_TEMPLATE",
Get-DueDate -Matrix $matrix -Group "OPS_MATERIAL"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
if ($dueDates.Count -gt 0) {
Set-IfColumnExists -Row $row -Column "due_date" -Value ($dueDates -join "; ")
}
}
}
Export-CsvRows -Rows $reviewTasks -Path $reviewTaskPath
$changedFiles.Add($reviewTaskPath) | Out-Null
$materialSignoffs = Import-CsvRows -Path $materialSignoffPath
foreach ($row in $materialSignoffs) {
$materialId = ([string]$row.material_id).Trim()
$roleText = (([string]$row.signoff_role) + " " + ([string]$row.owner) + " " + $materialId)
$name = $opsName
if ($roleText.Contains($script:CnLegal) -or @("MAT-USER-AGREEMENT-001", "MAT-PRIVACY-POLICY-001", "MAT-AI-DISCLAIMER-001", "MAT-LEGAL-REVIEW-SOP-001") -contains $materialId) {
$name = $legalName
}
Set-IfColumnExists -Row $row -Column "signer_name" -Value $name
}
Export-CsvRows -Rows $materialSignoffs -Path $materialSignoffPath
$changedFiles.Add($materialSignoffPath) | Out-Null
$templateSignoffs = Import-CsvRows -Path $templateSignoffPath
foreach ($row in $templateSignoffs) {
Set-IfColumnExists -Row $row -Column "legal_reviewer" -Value $legalName
Set-IfColumnExists -Row $row -Column "business_reviewer" -Value $businessName
}
Export-CsvRows -Rows $templateSignoffs -Path $templateSignoffPath
$changedFiles.Add($templateSignoffPath) | Out-Null
$knowledgeSignoffs = Import-CsvRows -Path $knowledgeSignoffPath
foreach ($row in $knowledgeSignoffs) {
Set-IfColumnExists -Row $row -Column "reviewer" -Value $legalName
}
Export-CsvRows -Rows $knowledgeSignoffs -Path $knowledgeSignoffPath
$changedFiles.Add($knowledgeSignoffPath) | Out-Null
foreach ($bundlePath in @($legalBundlePath, $businessBundlePath, $opsBundlePath)) {
if (-not (Test-Path -LiteralPath $bundlePath)) {
continue
}
$bundleRows = Import-CsvRows -Path $bundlePath
foreach ($row in $bundleRows) {
if ($bundlePath -eq $legalBundlePath) {
Set-IfColumnExists -Row $row -Column "reviewer_name" -Value $legalName
Set-IfColumnExists -Row $row -Column "legal_reviewer" -Value $legalName
Set-IfColumnExists -Row $row -Column "signer_name" -Value $legalName
} elseif ($bundlePath -eq $businessBundlePath) {
Set-IfColumnExists -Row $row -Column "reviewer_name" -Value $businessName
Set-IfColumnExists -Row $row -Column "legal_reviewer" -Value $legalName
Set-IfColumnExists -Row $row -Column "business_reviewer" -Value $businessName
} else {
Set-IfColumnExists -Row $row -Column "reviewer_name" -Value $opsName
Set-IfColumnExists -Row $row -Column "signer_name" -Value $opsName
}
}
Export-CsvRows -Rows $bundleRows -Path $bundlePath
$changedFiles.Add($bundlePath) | Out-Null
}
$warnings.Add("FINAL_APPROVER checklist is not auto-signed; final approval must be completed after all review and signoff evidence is ready. Final approver candidate: $finalName") | Out-Null
}
$status = if ($errors.Count -gt 0) {
"FAIL"
} elseif ($missingGroups.Count -gt 0) {
"BLOCKED"
} else {
"PASS"
}
Write-Utf8File -Path $errorsPath -Text (($errors.ToArray()) -join [Environment]::NewLine)
Write-Utf8File -Path $warningsPath -Text (($warnings.ToArray()) -join [Environment]::NewLine)
$summary = [ordered]@{
status = $status
generated_at = (Get-Date).ToString("o")
apply = [bool]$Apply
assignment_matrix = $AssignmentMatrixCsv
metrics = [ordered]@{
required_groups = $requiredGroups.Count
assigned_groups = ($requiredGroups.Count - $missingGroups.Count)
missing_groups = $missingGroups.Count
errors = $errors.Count
warnings = $warnings.Count
changed_files = $changedFiles.Count
}
missing_groups = $missingGroups.ToArray()
changed_files = $changedFiles.ToArray()
artifacts = [ordered]@{
summary_json = $summaryJson
summary_md = $summaryMd
errors = $errorsPath
warnings = $warningsPath
}
}
$summary | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $summaryJson -Encoding UTF8
$md = New-Object System.Collections.Generic.List[string]
$md.Add("# Formal Launch Reviewer Assignment Matrix Summary") | Out-Null
$md.Add("") | Out-Null
$md.Add("- status: $status") | Out-Null
$md.Add("- apply: $([bool]$Apply)") | Out-Null
$md.Add("- assignment_matrix: $AssignmentMatrixCsv") | Out-Null
$md.Add("") | Out-Null
$md.Add("## Metrics") | Out-Null
$md.Add("") | Out-Null
$md.Add("| metric | value |") | Out-Null
$md.Add("|---|---:|") | Out-Null
foreach ($property in $summary.metrics.GetEnumerator()) {
$md.Add("| $($property.Key) | $($property.Value) |") | Out-Null
}
$md.Add("") | Out-Null
$md.Add("## Missing Groups") | Out-Null
$md.Add("") | Out-Null
if ($missingGroups.Count -eq 0) {
$md.Add("- none") | Out-Null
} else {
foreach ($group in $missingGroups) {
$md.Add("- $group") | Out-Null
}
}
$md.Add("") | Out-Null
$md.Add("This script never approves review results or evidence. It only validates or applies real reviewer assignment names from the assignment matrix.") | Out-Null
Write-Utf8File -Path $summaryMd -Text ($md -join [Environment]::NewLine)
Write-Output "FORMAL_LAUNCH_REVIEWER_ASSIGNMENT_MATRIX_$status $ArtifactRoot"
if ($status -eq "FAIL") {
exit 1
}