Files
ArtPlayer/refactor/scripts/release-ledger-model.mjs

115 lines
6.8 KiB
JavaScript

import assert from 'node:assert/strict'
export const libraryGates = ['build', 'runtime', 'types', 'combinations', 'browser', 'devices', 'licenses', 'rollback', 'remote-ci', 'review-01', 'review-02', 'review-03']
export const siteGates = ['build', 'site-urls', 'site-assets', 'editor', 'devices', 'licenses', 'rollback', 'remote-ci', 'review-01', 'review-02', 'review-03']
export const sharedTasks = ['CI-01', 'CI-03', 'CI-04', 'REL-01', 'REL-09', 'REL-02', 'REL-03', 'REL-04', 'REVIEW-01', 'REVIEW-02', 'REVIEW-03']
export function dependencyClosure(name, names, edges, dynamicImports = []) {
if (dynamicImports.length)
return [...names].sort()
const dependencies = new Set([name])
let count = -1
while (count !== dependencies.size) {
count = dependencies.size
for (const edge of edges) {
if (dependencies.has(edge.consumer))
dependencies.add(edge.dependency)
}
}
return [...dependencies].sort()
}
export function findingPackages(item, tasks, names) {
const explicit = new Set(item.owners.flatMap(id => tasks.get(id)?.scope || []).filter(scope => names.includes(scope)))
// A generic review owner must not turn a package-specific finding into a global one.
return explicit.size ? [...explicit] : [...names]
}
export function validateLedger(ledger, inventory, tasks) {
assert.equal(ledger.schemaVersion, 1)
const expected = inventory.map(pkg => pkg.name).sort()
assert.deepEqual(ledger.packages.map(pkg => pkg.name).sort(), expected, 'Ledger must cover exactly every workspace package once')
assert.deepEqual(ledger.libraryGates, libraryGates, 'Missing mandatory library gate')
assert.deepEqual(ledger.siteGates, siteGates, 'Missing mandatory site gate')
assert.deepEqual(ledger.sharedTasks, sharedTasks, 'Missing mandatory shared release/review task')
for (const row of ledger.packages) {
const initial = inventory.find(pkg => pkg.name === row.name)
assert.equal(row.targetVersion, `${Number(initial.version.split('.')[0]) + 1}.0.0`, `Unexpected next major: ${row.name}`)
assert(['npm', 'renamed-npm', 'recovered-npm', 'site'].includes(row.distribution), `Unknown distribution: ${row.name}`)
assert.equal(row.distribution === 'site', initial.kind === 'docs', 'A site must not become a new npm publication implicitly')
assert(row.history?.file && row.history.rationale, `Missing distribution basis: ${row.name}`)
assert(row.rollback?.basis && row.rollback.strategy, `Missing rollback mapping: ${row.name}`)
assert(Array.isArray(row.extraRequirements) && row.extraRequirements.length, `Missing capability requirements: ${row.name}`)
assert(row.extraRequirements.every(item => typeof item === 'string' && item.trim()), 'Invalid capability requirement')
assert(new Set(row.extraRequirements).size === row.extraRequirements.length, 'Duplicate requirement')
assert(row.evidence && !Array.isArray(row.evidence) && typeof row.evidence === 'object')
assert(Object.keys(row.evidence).every(gate => (row.distribution === 'site' ? siteGates : libraryGates).includes(gate)), 'Unknown evidence gate')
assert(row.candidate === null || typeof row.candidate === 'object', 'Invalid candidate')
}
for (const id of ledger.sharedTasks)
assert(tasks.has(id), `Unknown release task: ${id}`)
}
export function evaluatePackage({ row, fingerprint, version, candidate, evidence, taskGaps, risks, assets, history }) {
const gates = row.distribution === 'site' ? siteGates : libraryGates
const blockers = []
const add = (kind, detail) => blockers.push({ kind, detail })
if (version !== row.targetVersion)
add('version', `${version} must be prepared as ${row.targetVersion} before final candidate validation`)
if (!history.verified)
add('distribution-history', history.reason)
if (!candidate) {
add('candidate', 'No candidate artifact is bound')
}
else {
for (const error of candidate.errors)
add('candidate', error)
if (candidate.inputFingerprint !== fingerprint)
add('stale-candidate', 'Source, dependencies, tooling or test inputs changed')
if (candidate.version !== version)
add('candidate-version', 'Artifact version differs from current manifest')
}
for (const id of taskGaps) add('task', id)
for (const risk of risks.filter(risk => risk.status === 'open')) add('risk', risk.id)
const checks = {}
for (const gate of gates) {
const report = evidence[gate]
const errors = []
if (!report) {
errors.push('Missing candidate-bound evidence')
}
else {
errors.push(...report.errors)
if (report.result !== 'pass')
errors.push(`Result is ${report.result}`)
if (report.package !== row.name || report.gate !== gate)
errors.push('Package/gate scope mismatch')
if (report.version !== version)
errors.push('Evidence version mismatch')
if (!candidate || report.candidateIntegrity !== candidate.integrity)
errors.push('Candidate integrity mismatch')
if (report.inputFingerprint !== fingerprint)
errors.push('Evidence input fingerprint is stale')
if (!report.checks?.length || report.checks.some(check => check.result !== 'pass'))
errors.push('Required checks contain missing, failed, skipped or unknown results')
if (!report.environment?.length || !report.command || !report.reviewedBy)
errors.push('Missing environment, command or review attribution')
if (gate === 'devices' && report.environment?.some(env => env.emulated !== false || !env.device || !env.os || !env.browser))
errors.push('Device evidence must explicitly identify real devices, OS and browser')
if (gate === 'devices' && report.checks?.some(check => check.mode !== 'native'))
errors.push('Device/capability checks cannot use mocks or emulation')
if (gate === 'remote-ci' && !report.environment?.some(env => env.remote === true && /^https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+$/.test(env.runUrl)))
errors.push('Remote CI evidence needs an actual GitHub Actions run reference')
const required = gate === 'devices' ? row.extraRequirements : gate === 'licenses' ? assets.map(asset => asset.id) : []
const covered = new Set(report.checks?.filter(check => check.result === 'pass').map(check => check.id))
for (const requirement of required) {
if (!covered.has(requirement))
errors.push(`Missing coverage: ${requirement}`)
}
}
checks[gate] = errors.length ? { status: 'blocked', errors } : { status: 'evidence-recorded' }
for (const error of errors) add(`evidence:${gate}`, error)
}
return { name: row.name, version, targetVersion: row.targetVersion, distribution: row.distribution, fingerprint, candidate, history, rollback: row.rollback, risks, assets, checks, blockers, status: blockers.length ? 'blocked' : 'evidence-complete', publicationAuthorized: false }
}