255 lines
12 KiB
JavaScript
255 lines
12 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|
const rootDir = path.resolve(scriptDir, '..')
|
|
const defaultApprovalPath = path.join(
|
|
rootDir,
|
|
'docs',
|
|
'content-candidates',
|
|
'aug1-release-approval.json',
|
|
)
|
|
|
|
let strict = false
|
|
let scenarioSnapshots = false
|
|
const positional = []
|
|
for (const argument of process.argv.slice(2)) {
|
|
if (argument === '--strict') {
|
|
strict = true
|
|
} else if (argument === '--scenario-snapshots') {
|
|
strict = true
|
|
scenarioSnapshots = true
|
|
} else if (argument.startsWith('-')) {
|
|
console.error(`Unknown option: ${argument}`)
|
|
process.exit(2)
|
|
} else {
|
|
positional.push(argument)
|
|
}
|
|
}
|
|
if (positional.length > 1) {
|
|
console.error('Usage: node verify-aug1-formal-content.mjs [--strict] [--scenario-snapshots] [approval-json]')
|
|
process.exit(2)
|
|
}
|
|
|
|
const approvalPath = positional[0] ? path.resolve(positional[0]) : defaultApprovalPath
|
|
const approval = JSON.parse(await readFile(approvalPath, 'utf8'))
|
|
const failures = []
|
|
const check = (condition, message) => {
|
|
if (!condition) failures.push(message)
|
|
}
|
|
const nonBlank = (value) => typeof value === 'string' && value.trim().length > 0
|
|
const sha256Pattern = /^[0-9a-f]{64}$/
|
|
const reviewedAtPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/
|
|
const sourceIdPattern = /^[A-Z0-9][A-Z0-9._-]{1,79}$/
|
|
const scenarioCodePattern = /^[a-z0-9][a-z0-9-]{1,79}$/
|
|
const placeholderPattern = /(?:待补|待定|待填写|待签|TBD|TODO)/i
|
|
|
|
check(approval.schemaVersion === '1.0', 'approval schemaVersion must be 1.0')
|
|
check(approval.releaseTarget === '2026-08-01', 'release target must be 2026-08-01')
|
|
check(/^[0-9A-Za-z_-]{1,20}$/.test(approval.tenantId ?? ''), 'tenantId is invalid')
|
|
const candidateFileValid = nonBlank(approval.candidateFile)
|
|
&& path.basename(approval.candidateFile) === approval.candidateFile
|
|
&& approval.candidateFile.endsWith('.json')
|
|
check(candidateFileValid, 'candidateFile must be a JSON basename beside the approval file')
|
|
check(sha256Pattern.test(approval.candidateSha256 ?? ''), 'candidateSha256 must be lowercase SHA-256')
|
|
|
|
if (!candidateFileValid) {
|
|
for (const failure of failures) console.error(`FAIL: ${failure}`)
|
|
process.exit(1)
|
|
}
|
|
const candidatePath = path.resolve(path.dirname(approvalPath), approval.candidateFile ?? '')
|
|
let candidateBytes
|
|
try {
|
|
candidateBytes = await readFile(candidatePath)
|
|
} catch {
|
|
console.error('FAIL: candidate file cannot be read beside the approval manifest')
|
|
process.exit(1)
|
|
}
|
|
const candidateHash = createHash('sha256').update(candidateBytes).digest('hex')
|
|
check(candidateHash === approval.candidateSha256, 'candidate file SHA-256 does not match approval manifest')
|
|
const candidate = JSON.parse(candidateBytes.toString('utf8'))
|
|
check(candidate.candidateStatus === 'PENDING_BUSINESS_REVIEW', 'candidate source must remain pending review')
|
|
check(candidate.publishable === false, 'candidate source must remain non-publishable')
|
|
check(Array.isArray(candidate.scenarios) && candidate.scenarios.length === 5, 'candidate source must contain five scenarios')
|
|
check(
|
|
Array.isArray(candidate.policyQuestionCandidates) && candidate.policyQuestionCandidates.length === 30,
|
|
'candidate source must contain thirty policy questions',
|
|
)
|
|
|
|
const expectedScenarios = new Map()
|
|
for (const item of candidate.scenarios ?? []) {
|
|
check(nonBlank(item.candidateId), 'candidate scenario ID is required')
|
|
check(nonBlank(item.scenarioDraft?.id), `${item.candidateId ?? 'unknown scenario'}: scenario code is required`)
|
|
check(!expectedScenarios.has(item.candidateId), `${item.candidateId}: duplicate candidate scenario ID`)
|
|
expectedScenarios.set(item.candidateId, item)
|
|
}
|
|
const expectedQuestions = new Map()
|
|
for (const item of candidate.policyQuestionCandidates ?? []) {
|
|
check(nonBlank(item.id), 'candidate question ID is required')
|
|
check(!expectedQuestions.has(item.id), `${item.id}: duplicate candidate question ID`)
|
|
expectedQuestions.set(item.id, item)
|
|
}
|
|
|
|
const sourceRegistry = Array.isArray(approval.sourceRegistry) ? approval.sourceRegistry : []
|
|
check(Array.isArray(approval.sourceRegistry), 'sourceRegistry must be an array')
|
|
const sourceIds = new Set()
|
|
for (const source of sourceRegistry) {
|
|
const label = source.sourceId ?? 'unknown source'
|
|
check(sourceIdPattern.test(source.sourceId ?? ''), `${label}: sourceId is invalid`)
|
|
check(!sourceIds.has(source.sourceId), `${label}: duplicate sourceId`)
|
|
sourceIds.add(source.sourceId)
|
|
check(nonBlank(source.title) && !placeholderPattern.test(source.title), `${label}: formal source title is required`)
|
|
check(nonBlank(source.version) && !placeholderPattern.test(source.version), `${label}: formal source version is required`)
|
|
check(/^\d{4}-\d{2}-\d{2}$/.test(source.effectiveDate ?? ''), `${label}: effectiveDate must be YYYY-MM-DD`)
|
|
check(nonBlank(source.scope) && !placeholderPattern.test(source.scope), `${label}: source scope is required`)
|
|
check(sha256Pattern.test(source.sha256 ?? ''), `${label}: source SHA-256 is required`)
|
|
check(source.formalPolicy === true, `${label}: source must be marked formalPolicy=true`)
|
|
}
|
|
|
|
const scenarioApprovals = approval.scenarioApprovals
|
|
const questionApprovals = approval.policyQuestionApprovals
|
|
check(
|
|
scenarioApprovals && typeof scenarioApprovals === 'object' && !Array.isArray(scenarioApprovals),
|
|
'scenarioApprovals must be an object keyed by candidate ID',
|
|
)
|
|
check(
|
|
questionApprovals && typeof questionApprovals === 'object' && !Array.isArray(questionApprovals),
|
|
'policyQuestionApprovals must be an object keyed by question ID',
|
|
)
|
|
const scenarioEntries = Object.entries(scenarioApprovals ?? {})
|
|
const questionEntries = Object.entries(questionApprovals ?? {})
|
|
for (const [candidateId] of scenarioEntries) {
|
|
check(expectedScenarios.has(candidateId), `${candidateId}: approval references an unknown scenario`)
|
|
}
|
|
for (const [questionId] of questionEntries) {
|
|
check(expectedQuestions.has(questionId), `${questionId}: approval references an unknown question`)
|
|
}
|
|
|
|
const resolveSourceRefs = (refs, label) => {
|
|
check(Array.isArray(refs) && refs.length > 0, `${label}: at least one formal source reference is required`)
|
|
for (const reference of refs ?? []) {
|
|
check(nonBlank(reference), `${label}: source reference must be non-empty`)
|
|
const sourceId = typeof reference === 'string' ? reference.split(':', 1)[0] : ''
|
|
check(sourceIds.has(sourceId), `${label}: source reference ${reference} is not in sourceRegistry`)
|
|
}
|
|
}
|
|
const requireReview = (entry, label, prefix = '') => {
|
|
const reviewedBy = prefix ? entry[`${prefix}ReviewedBy`] : entry.reviewedBy
|
|
const reviewedAt = prefix ? entry[`${prefix}ReviewedAt`] : entry.reviewedAt
|
|
check(nonBlank(reviewedBy) && !placeholderPattern.test(reviewedBy), `${label}: ${prefix || 'first'} reviewer is required`)
|
|
check(reviewedAtPattern.test(reviewedAt ?? ''), `${label}: ${prefix || 'first'} review time must include timezone`)
|
|
}
|
|
|
|
if (strict) {
|
|
check(approval.releaseStatus === 'APPROVED', 'releaseStatus must be APPROVED')
|
|
check(
|
|
nonBlank(approval.formalContentVersion) && !placeholderPattern.test(approval.formalContentVersion),
|
|
'formalContentVersion is required',
|
|
)
|
|
check(sourceRegistry.length > 0, 'at least one formal source is required')
|
|
check(scenarioEntries.length === expectedScenarios.size, 'scenario approvals must cover exactly 5/5 candidates')
|
|
check(questionEntries.length === expectedQuestions.size, 'question approvals must cover exactly 30/30 candidates')
|
|
|
|
for (const [candidateId, candidateItem] of expectedScenarios) {
|
|
const entry = scenarioApprovals?.[candidateId]
|
|
const label = candidateId
|
|
check(Boolean(entry), `${label}: scenario approval is missing`)
|
|
if (!entry) continue
|
|
check(entry.status === 'APPROVED', `${label}: scenario status must be APPROVED`)
|
|
check(entry.scenarioCode === candidateItem.scenarioDraft.id, `${label}: production scenario code does not match candidate`)
|
|
check(scenarioCodePattern.test(entry.scenarioCode ?? ''), `${label}: production scenario code is invalid`)
|
|
check(
|
|
['常规', '高风险'].includes(entry.riskLevel) && entry.riskLevel === candidateItem.proposedRiskLevel,
|
|
`${label}: risk level must match the reviewed candidate proposal`,
|
|
)
|
|
resolveSourceRefs(entry.sourceRefs, label)
|
|
requireReview(entry, label)
|
|
if (entry.riskLevel === '高风险') {
|
|
requireReview(entry, label, 'second')
|
|
check(entry.secondReviewedBy !== entry.reviewedBy, `${label}: high-risk reviewers must be different people`)
|
|
}
|
|
check(nonBlank(entry.businessEvidenceRef), `${label}: business evidence reference is required`)
|
|
check(nonBlank(entry.productionContentVersion), `${label}: production content version is required`)
|
|
check(sha256Pattern.test(entry.productionContentHash ?? ''), `${label}: production content hash is required`)
|
|
}
|
|
|
|
const dispositions = new Set([
|
|
'FORMALLY_SOURCED',
|
|
'FORMAL_NO_EVIDENCE_BOUNDARY',
|
|
'FORMAL_UNAUTHORIZED_BOUNDARY',
|
|
])
|
|
for (const [questionId, candidateItem] of expectedQuestions) {
|
|
const entry = questionApprovals?.[questionId]
|
|
const label = questionId
|
|
check(Boolean(entry), `${label}: question approval is missing`)
|
|
if (!entry) continue
|
|
check(entry.status === 'APPROVED', `${label}: question status must be APPROVED`)
|
|
check(dispositions.has(entry.answerDisposition), `${label}: answerDisposition is invalid`)
|
|
if (candidateItem.category === 'NO_EVIDENCE') {
|
|
check(
|
|
entry.answerDisposition === 'FORMAL_NO_EVIDENCE_BOUNDARY',
|
|
`${label}: no-evidence question must preserve the formal refusal boundary`,
|
|
)
|
|
} else if (candidateItem.category === 'UNAUTHORIZED') {
|
|
check(
|
|
entry.answerDisposition === 'FORMAL_UNAUTHORIZED_BOUNDARY',
|
|
`${label}: unauthorized question must preserve the permission boundary`,
|
|
)
|
|
} else {
|
|
check(entry.answerDisposition === 'FORMALLY_SOURCED', `${label}: answer must be formally sourced`)
|
|
}
|
|
resolveSourceRefs(entry.sourceRefs, label)
|
|
check(nonBlank(entry.expectedBehavior), `${label}: expected behavior is required`)
|
|
check(nonBlank(entry.observedBehavior), `${label}: observed behavior is required`)
|
|
check(nonBlank(entry.evidenceRef), `${label}: acceptance evidence reference is required`)
|
|
check(sha256Pattern.test(entry.evidenceSha256 ?? ''), `${label}: acceptance evidence SHA-256 is required`)
|
|
requireReview(entry, label)
|
|
}
|
|
|
|
const requiredSignoffs = [
|
|
'businessOwner',
|
|
'knowledgeOwner',
|
|
'trainingOwner',
|
|
'channelOwner',
|
|
'releaseOwner',
|
|
]
|
|
check(
|
|
approval.signoffs && typeof approval.signoffs === 'object' && !Array.isArray(approval.signoffs),
|
|
'signoffs must be an object',
|
|
)
|
|
for (const role of requiredSignoffs) {
|
|
const signoff = approval.signoffs?.[role]
|
|
check(signoff?.status === 'APPROVED', `${role}: sign-off must be APPROVED`)
|
|
if (signoff) requireReview(signoff, role)
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
for (const failure of failures) console.error(`FAIL: ${failure}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (scenarioSnapshots) {
|
|
for (const [candidateId, candidateItem] of expectedScenarios) {
|
|
const entry = scenarioApprovals[candidateId]
|
|
console.log([
|
|
candidateItem.scenarioDraft.id,
|
|
entry.productionContentVersion,
|
|
entry.productionContentHash,
|
|
].join('|'))
|
|
}
|
|
} else {
|
|
const approvedScenarios = scenarioEntries.filter(([, value]) => value?.status === 'APPROVED').length
|
|
const approvedQuestions = questionEntries.filter(([, value]) => value?.status === 'APPROVED').length
|
|
const approvedSignoffs = Object.values(approval.signoffs ?? {}).filter((value) => value?.status === 'APPROVED').length
|
|
console.log('August 1 formal content approval audit passed')
|
|
console.log(`- releaseStatus: ${approval.releaseStatus}`)
|
|
console.log(`- formal sources: ${sourceRegistry.length}`)
|
|
console.log(`- approved scenarios: ${approvedScenarios}/5`)
|
|
console.log(`- approved policy questions: ${approvedQuestions}/30`)
|
|
console.log(`- approved owner sign-offs: ${approvedSignoffs}/5`)
|
|
console.log(`- strict release ready: ${strict ? 'true' : 'not requested'}`)
|
|
}
|