Files
ArtPlayer/refactor/scripts/contracts-model.mjs
T

201 lines
15 KiB
JavaScript
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.
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import ts from 'typescript'
import { repositoryPath } from './impact-model.mjs'
export const sha256 = value => createHash('sha256').update(value).digest('hex')
export const readJson = (directory, file) => JSON.parse(fs.readFileSync(localFile(directory, file), 'utf8'))
export function localFile(directory, file) {
assert.equal(repositoryPath(file), file, `Noncanonical contract path: ${file}`)
const absolute = path.join(directory, file)
const relative = path.relative(fs.realpathSync(directory), fs.realpathSync(absolute))
assert(relative && !relative.startsWith('..') && !path.isAbsolute(relative), `Redirected contract path: ${file}`)
assert(fs.statSync(absolute).isFile(), `Contract evidence is not a file: ${file}`)
return absolute
}
export function pointer(document, value) {
assert(/^\//.test(value), 'Expected a JSON pointer')
return value.slice(1).split('/').reduce((item, part) => {
const key = part.replaceAll('~1', '/').replaceAll('~0', '~')
assert(item && Object.hasOwn(item, key), `Missing version evidence pointer: ${value}`)
return item[key]
}, document)
}
export function candidateInputs(directory, model) {
const files = new Set(['package.json', 'yarn.lock', 'refactor/contract-policy.json', '.node-version'])
const walk = (folder) => {
if (!fs.existsSync(path.join(directory, folder)))
return
for (const entry of fs.readdirSync(path.join(directory, folder), { withFileTypes: true })) {
const file = `${folder}/${entry.name}`
assert(!entry.isSymbolicLink(), `Review redirected contract input: ${file}`)
if (entry.isDirectory())
walk(file)
else files.add(file)
}
}
for (const pkg of model.policy.packages) {
files.add(`packages/${pkg.name}/package.json`)
for (const folder of ['src', 'types', 'public']) walk(`packages/${pkg.name}/${folder}`)
}
for (const folder of ['scripts', 'test', 'types', 'refactor/scripts', 'refactor/fixtures']) walk(folder)
for (const version of model.policy.versions) files.add(version.file)
const inputs = [...files].sort().map(file => ({ file, sha256: sha256(fs.readFileSync(localFile(directory, file))) }))
return { digest: sha256(JSON.stringify(inputs)), inputs }
}
function validateTest(directory, item) {
assert(/^CT-[A-Z0-9-]+$/.test(item.id), 'Invalid stable contract test ID')
assert(item.command === 'yarn test:contracts' && item.runner === 'node', 'Unsupported contract runner/command')
const source = ts.createSourceFile(item.file, fs.readFileSync(localFile(directory, item.file), 'utf8'), ts.ScriptTarget.Latest, true)
assert.equal(source.parseDiagnostics.length, 0, `Malformed contract test: ${item.file}`)
const imported = source.statements.some(statement => ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === 'node:test'
&& (statement.importClause?.name?.text === 'test' || statement.importClause?.namedBindings?.elements?.some(element => element.name.text === 'test' && (!element.propertyName || element.propertyName.text === 'test'))))
assert(imported, `Contract case requires the Node test binding: ${item.id}`)
const matches = []
function visit(node) {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'test' && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0]) && node.arguments[0].text === item.title)
matches.push(node)
ts.forEachChild(node, visit)
}
visit(source)
assert.equal(matches.length, 1, `Contract test title must identify exactly one literal test: ${item.id}`)
assert(item.scope?.length && item.limitations?.length, `Missing assertion scope/limits: ${item.id}`)
}
export function readContractModel(directory) {
const policy = readJson(directory, 'refactor/contract-policy.json')
assert.equal(policy.schemaVersion, 1)
const scripts = readJson(directory, 'package.json').scripts
assert.equal(scripts?.['test:contracts'], 'node refactor/scripts/run-contracts.mjs', 'Contract runner command changed; review its evidence adapter')
assert.equal(scripts?.['check:contracts'], 'node refactor/scripts/contracts.mjs', 'Contract index command changed')
const stages = scripts['ci:check']?.split(' && ') || []
assert(stages.includes('yarn test:contracts') && stages.indexOf('yarn check:contracts --report') > stages.indexOf('yarn test:contracts'), 'CI must run contract observations before checking their report')
const tasks = readJson(directory, 'refactor/tasks.json').tasks
const inventory = readJson(directory, 'refactor/package-inventory.json').packages
const families = [...fs.readFileSync(localFile(directory, 'refactor/compatibility.md'), 'utf8').matchAll(/^\| (API-\d{2}) \| ([^|]+) \|/gm)].map(match => ({ id: match[1], name: match[2].trim() }))
assert(families.length > 0)
assert.deepEqual(policy.families, families, 'Public contract families changed; update their coverage ownership')
const names = inventory.map(pkg => pkg.name).sort()
assert.deepEqual(policy.packages.map(pkg => pkg.name).sort(), names, 'Contract package ownership is incomplete or duplicated')
const actual = fs.readdirSync(path.join(directory, 'packages')).filter(name => fs.existsSync(path.join(directory, 'packages', name, 'package.json'))).sort()
assert.deepEqual(actual, names, 'New package must be registered in inventory and contracts')
const unique = (items, label) => assert.equal(new Set(items.map(item => item.id)).size, items.length, `Duplicate ${label} ID`)
unique(policy.versions, 'version')
unique(policy.cases, 'case')
assert(policy.cases.length > 0, 'Contract runner requires explicit cases; empty selection must not discover unrelated tests')
assert.equal(new Set(policy.cases.map(item => JSON.stringify([item.file, item.title]))).size, policy.cases.length, 'Duplicate file/title under different contract IDs')
for (const version of policy.versions) {
assert(names.includes(version.package), 'Version references unknown package')
const origin = pointer(readJson(directory, version.file), version.pointer)
assert.equal(origin.name, version.package, `Wrong package in version evidence: ${version.id}`)
assert.equal(origin.version, version.version, `Wrong version evidence: ${version.id}`)
if (version.kind === 'published-baseline') {
assert(/^[a-f\d]{64}$/.test(origin.sha256) && origin.integrity?.startsWith('sha512-'), `Published version needs archived integrity: ${version.id}`)
assert.equal(version.archiveSha256, origin.sha256, `Published archive identity changed: ${version.id}`)
}
else {
assert.equal(version.kind, 'workspace-baseline')
assert.equal(version.file, 'refactor/package-inventory.json', 'Workspace version must use the frozen inventory')
}
}
for (const pkg of policy.packages) {
assert.deepEqual(Object.keys(pkg.owners).sort(), families.map(family => family.id).sort(), `Missing contract owner: ${pkg.name}`)
for (const owner of Object.values(pkg.owners)) {
const task = tasks.find(task => task.id === owner)
assert(task && (task.scope.includes(pkg.name) || task.scope.includes('workspace')), `Invalid contract responsibility: ${pkg.name}/${owner}`)
}
assert(pkg.versionIds.length && pkg.versionIds.every(id => policy.versions.some(version => version.id === id && version.package === pkg.name)), `Missing package version basis: ${pkg.name}`)
assert.equal(pkg.supportWindow, 'not-inferred-from-baseline', 'A baseline must not become an invented supported version range')
assert(pkg.scopeReview?.length, 'Contract applicability must remain explicit')
}
for (const item of policy.cases) {
const pkg = policy.packages.find(pkg => pkg.name === item.package)
assert(pkg && item.contracts.length && item.contracts.every(id => families.some(family => family.id === id)), 'Unknown case package/contract')
assert(item.versionIds.length && item.versionIds.every(id => policy.versions.some(version => version.id === id && version.package === item.package)), 'Unknown test version basis')
validateTest(directory, item)
}
const historical = tasks.flatMap(task => task.evidence.filter(file => /\.json$/.test(file)).map(file => ({ task: task.id, scope: task.scope, file: `refactor/${file}` })))
for (const item of historical) localFile(directory, item.file)
return { policy, families, tasks, historical }
}
export function evaluateRun(model, report, currentDigest) {
assert.equal(report.schemaVersion, 1)
assert.equal(report.command, 'yarn test:contracts')
assert(/^[a-f\d]{40}$/.test(report.head) && report.environment?.node && report.environment?.platform, 'Run lacks candidate/environment provenance')
assert.equal(report.candidate.digest, sha256(JSON.stringify(report.candidate.inputs)), 'Candidate input fingerprint mismatch')
assert(report.candidate.inputs.length && report.candidate.inputs.every(input => /^[a-f\d]{64}$/.test(input.sha256)), 'Invalid candidate inputs')
for (const input of report.candidate.inputs) repositoryPath(input.file)
assert.equal(new Set(report.cases.map(item => item.id)).size, report.cases.length, 'Duplicate run case identity')
assert(report.events.length && ['passed', 'failed'].includes(report.outcome), 'Run is missing test events/outcome')
const summaries = report.events.filter(event => event.type === 'test:summary' && !event.file)
assert.equal(summaries.length, 1, 'Run requires one process summary')
assert.equal(report.outcome === 'passed', summaries[0].success === true, 'Run outcome contradicts process summary')
if (report.outcome === 'passed')
assert(!report.events.some(event => event.type === 'test:fail' && !event.todo), 'Passed run contains a failed test')
return report.cases.map((item) => {
const defined = model.policy.cases.find(candidate => candidate.id === item.id)
assert(defined, `Unknown reported test ID: ${item.id}`)
assert.equal(item.definition?.id, item.id, `Wrong recorded definition: ${item.id}`)
assert.equal(item.definitionSha256, sha256(JSON.stringify(item.definition)), `Reported test definition fingerprint changed: ${item.id}`)
assert.equal(item.definition.package, defined.package, `Stable test ID was reassigned to another package: ${item.id}`)
const matches = report.events.filter(event => ['test:pass', 'test:fail'].includes(event.type) && event.file === item.definition.file && event.name === item.definition.title)
assert.equal(matches.length, 1, `Missing or ambiguous executed test: ${item.id}`)
const event = matches[0]
const status = event.skip || event.todo ? 'skipped' : event.type === 'test:fail' ? 'failed' : 'passed'
assert.equal(item.status, status, `Reported test outcome mismatch: ${item.id}`)
return { id: item.id, package: item.definition.package, contracts: item.definition.contracts, versionIds: item.definition.versionIds, status, freshness: item.definitionSha256 !== sha256(JSON.stringify(defined)) ? 'historical-definition' : report.candidate.digest === currentDigest ? 'current-inputs' : 'historical-inputs', scope: item.definition.scope }
})
}
export function contractStatus(directory, model) {
const current = candidateInputs(directory, model)
const registry = readJson(directory, 'refactor/contract-runs.json')
assert.equal(registry.schemaVersion, 1)
assert.equal(new Set(registry.runs.map(run => run.file)).size, registry.runs.length, 'Duplicate registered report')
const observations = []
for (const ref of registry.runs) {
const bytes = fs.readFileSync(localFile(directory, ref.file))
assert.equal(sha256(bytes.toString('utf8').replaceAll('\r\n', '\n')), ref.sha256LF, `Contract report changed: ${ref.file}`)
const report = JSON.parse(bytes)
observations.push({ file: ref.file, outcome: report.outcome, cases: evaluateRun(model, report, current.digest) })
}
const latest = 'refactor/.cache/ci/contracts-run.json'
if (fs.existsSync(path.join(directory, latest))) {
const report = readJson(directory, latest)
observations.push({ file: latest, outcome: report.outcome, cases: evaluateRun(model, report, current.digest) })
}
const rows = model.policy.packages.flatMap(pkg => model.families.map((family) => {
const cases = model.policy.cases.filter(item => item.package === pkg.name && item.contracts.includes(family.id))
const results = observations.flatMap(run => run.cases.filter(item => item.package === pkg.name && item.contracts.includes(family.id)).map(item => ({ ...item, report: run.file, runOutcome: run.outcome })))
return { package: pkg.name, contract: family.id, owner: pkg.owners[family.id], versionIds: pkg.versionIds, supportWindow: pkg.supportWindow, state: results.length ? 'partial-observations' : cases.length ? 'planned-not-observed' : 'tests-not-indexed', cases: cases.map(item => item.id), observations: results, remaining: 'Family-wide acceptance, version combinations and environment scope are not implied by individual test observations' }
}))
return { schemaVersion: 1, candidateDigest: current.digest, rows, observations, historicalRecords: model.historical.map(item => ({ ...item, state: 'recorded-not-normalized' })), limitation: 'Index status is not package compatibility or npm release approval' }
}
export function renderContracts(model) {
const lines = ['# 公开契约覆盖索引', '', '> 由 contract-policy.json 生成;`yarn check:contracts --write` 更新。动态候选/报告状态见 `yarn check:contracts --report`。', '', '覆盖12类兼容契约与22包的验证归属;不是每个公开成员的穷尽测试证明。没有固定测试ID的行仍需逐成员补齐。', '版本均为精确冻结对照点,不能推导连续支持区间。历史报告保留原状态,只有精确用例和候选指纹对应时才列为观察结果。', '', '| 包 | 契约 | 责任任务 | 固定测试ID |', '| --- | --- | --- | --- |']
for (const pkg of model.policy.packages) {
for (const family of model.families) {
const cases = model.policy.cases.filter(item => item.package === pkg.name && item.contracts.includes(family.id))
lines.push(`| ${pkg.name} | ${family.id} ${family.name} | ${pkg.owners[family.id]} | ${cases.map(item => item.id).join(', ') || '待索引;不代表没有历史测试'} |`)
}
}
lines.push('', '## 精确测试及限制', '')
for (const item of model.policy.cases)
lines.push(`- ${item.id}: [${item.file}](../${item.file}) — ${item.title}。${item.scope}。限制:${item.limitations}。命令:\`${item.command}\`。版本依据:${item.versionIds.join(', ')}。`)
lines.push('', '## 版本依据', '')
for (const version of model.policy.versions)
lines.push(`- ${version.id}: ${version.package}@${version.version} (${version.kind});[来源](../${version.file}) JSON pointer \`${version.pointer}\`。`)
return `${lines.join('\n')}\n`
}
export const currentHead = directory => execFileSync('git', ['rev-parse', 'HEAD'], { cwd: directory, encoding: 'utf8' }).trim()