mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 03:46:16 -08:00
246 lines
14 KiB
JavaScript
246 lines
14 KiB
JavaScript
/* eslint-disable no-template-curly-in-string -- GitHub matrix expressions are literal workflow policy. */
|
|
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import semver from 'semver'
|
|
import ts from 'typescript'
|
|
import YAML from 'yaml'
|
|
|
|
export function repositoryPath(file) {
|
|
assert(typeof file === 'string' && file.length && !file.includes('\\') && !file.includes('\0') && !path.posix.isAbsolute(file) && !/^[A-Z]:/i.test(file), `Invalid repository path: ${file}`)
|
|
const normalized = path.posix.normalize(file)
|
|
assert(normalized !== '..' && !normalized.startsWith('../'), `Path escapes repository: ${file}`)
|
|
return normalized
|
|
}
|
|
|
|
export function analyzeImpact(model, changedFiles) {
|
|
const names = model.packages.map(pkg => pkg.name)
|
|
const affected = new Set()
|
|
const reasons = []
|
|
const unknownPaths = []
|
|
function include(name, reason) {
|
|
if (!affected.has(name)) {
|
|
affected.add(name)
|
|
reasons.push({ package: name, ...reason })
|
|
}
|
|
}
|
|
const files = [...new Set(changedFiles.map(repositoryPath))].sort()
|
|
const all = (file, rule) => names.forEach(name => include(name, { file, rule }))
|
|
for (const file of files) {
|
|
if (file.startsWith('packages/')) {
|
|
const owner = file.split('/')[1]
|
|
assert(names.includes(owner), `Changed package is not mapped: ${owner}`)
|
|
include(owner, { file, rule: 'package-owner' })
|
|
}
|
|
else if (model.policy.sharedFiles.includes(file) || model.policy.sharedPrefixes.some(prefix => file.startsWith(prefix))) {
|
|
all(file, 'shared-engineering')
|
|
}
|
|
else {
|
|
const generated = names.find(name => file.startsWith(`docs/uncompiled/${name}/`) || file === `docs/compiled/${name}.js` || file === `docs/compiled/${name}.legacy.js` || file === `docs/compiled/${name}.mjs`)
|
|
const examples = model.policy.examples.filter(example => example.file === file)
|
|
if (generated) {
|
|
include(generated, { file, rule: 'generated-package-artifact' })
|
|
}
|
|
else if (examples.length) {
|
|
examples.forEach(example => include(example.owner, { file, rule: 'mapped-example' }))
|
|
}
|
|
else {
|
|
unknownPaths.push(file)
|
|
all(file, 'unclassified-conservative-fallback')
|
|
}
|
|
}
|
|
}
|
|
if (files.length && model.dynamicImports.length)
|
|
all(model.dynamicImports[0], 'unresolved-dynamic-import-conservative-fallback')
|
|
let previousSize = -1
|
|
while (affected.size !== previousSize) {
|
|
previousSize = affected.size
|
|
for (const edge of model.edges) {
|
|
if (affected.has(edge.dependency))
|
|
include(edge.consumer, { rule: edge.kind, via: edge.dependency, evidence: edge.evidence, explanation: edge.reason })
|
|
}
|
|
}
|
|
const affectedPackages = [...affected].sort()
|
|
return {
|
|
files,
|
|
affectedPackages,
|
|
reasons,
|
|
unknownPaths,
|
|
reviewRequired: Boolean(unknownPaths.length || model.dynamicImports.length),
|
|
requiredChecks: model.policy.gates,
|
|
requiredCIJobs: [...new Set(model.policy.gates.map(gate => gate.job))].sort(),
|
|
installedConsumerGaps: affectedPackages.filter(name => name !== model.policy.site && !model.policy.installedConsumerPackages.includes(name)),
|
|
limitation: 'CI jobs remain required; both browser scopes also run after ordinary step failures. This dependency report selects no tests for omission and certifies neither missing package suites nor device/vendor acceptance.',
|
|
}
|
|
}
|
|
|
|
function importsIn(file, text) {
|
|
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
|
assert.equal(source.parseDiagnostics.length, 0, `Cannot reliably scan malformed dependency source: ${file}`)
|
|
const imports = []
|
|
const dynamic = []
|
|
const add = (node) => {
|
|
if (node && ts.isStringLiteralLike(node))
|
|
imports.push(node.text)
|
|
else
|
|
dynamic.push(file)
|
|
}
|
|
function visit(node) {
|
|
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
if (node.moduleSpecifier)
|
|
add(node.moduleSpecifier)
|
|
}
|
|
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
add(node.argument.literal)
|
|
}
|
|
else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
|
|
add(node.arguments[0])
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(source)
|
|
return { imports, dynamic }
|
|
}
|
|
|
|
export function readImpactModel(directory) {
|
|
const read = file => JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'))
|
|
const policy = read('refactor/impact-policy.json')
|
|
assert.equal(policy.schemaVersion, 1)
|
|
const gateCommands = policy.gates.map(gate => [gate.job, gate.command, ...(gate.arguments || [])].join(' '))
|
|
assert.equal(new Set(gateCommands).size, gateCommands.length, 'Duplicate impact gate')
|
|
for (const command of [
|
|
'checks yarn ci:check',
|
|
'checks yarn ci:build',
|
|
'browser-consumers yarn test:package',
|
|
'browser-consumers yarn test:ecosystem-types',
|
|
'browser-smoke yarn test:package --browser',
|
|
'browser-smoke yarn test:browser:source --project=${{ matrix.browser }}',
|
|
'browser-smoke yarn test:browser:installed --project=${{ matrix.browser }}',
|
|
'coverage yarn test:coverage',
|
|
'browser-consumers yarn test:performance',
|
|
])
|
|
assert(gateCommands.includes(command), `Missing mandatory ecosystem gate: ${command}`)
|
|
const names = policy.packages.map(pkg => pkg.name).sort()
|
|
assert.equal(new Set(names).size, names.length, 'Duplicate mapped package')
|
|
const actual = fs.readdirSync(path.join(directory, 'packages')).filter(name => fs.existsSync(path.join(directory, 'packages', name, 'package.json'))).sort()
|
|
assert.deepEqual(actual, names, 'Workspace package inventory changed; update impact ownership explicitly')
|
|
assert(names.includes(policy.core) && names.includes(policy.site), 'Missing core/site mapping')
|
|
assert.deepEqual([...policy.coreValidationConsumers].sort(), names.filter(name => name !== policy.core), 'Core changes must trigger every ecosystem validation consumer')
|
|
const packages = names.map((name) => {
|
|
const manifest = read(`packages/${name}/package.json`)
|
|
assert.equal(manifest.name, name, 'Package directory and manifest name disagree')
|
|
return { name, version: manifest.version, manifest }
|
|
})
|
|
const edges = []
|
|
const dynamicImports = []
|
|
const exists = (file) => {
|
|
repositoryPath(file)
|
|
assert(fs.existsSync(path.join(directory, file)), `Impact evidence is missing: ${file}`)
|
|
}
|
|
const edge = (dependency, consumer, kind, evidence, reason) => {
|
|
assert(names.includes(dependency) && names.includes(consumer), 'Unknown dependency or consumer in impact graph')
|
|
evidence.forEach(exists)
|
|
if (dependency !== consumer)
|
|
edges.push({ dependency, consumer, kind, evidence, reason })
|
|
}
|
|
for (const consumer of policy.coreValidationConsumers)
|
|
edge(policy.core, consumer, 'core-validation-contract', [policy.coreEvidence], 'Core API/DOM/type and media contracts require ecosystem validation, even without manifest dependencies')
|
|
for (const name of names.filter(name => name !== policy.site))
|
|
edge(name, policy.site, 'site-consumer-contract', [policy.siteEvidence], 'Library examples, generated declarations and docs consume the package')
|
|
for (const item of policy.edges)
|
|
edge(item.dependency, item.consumer, 'explicit-integration-contract', item.evidence, item.reason)
|
|
for (const example of policy.examples) {
|
|
assert(names.includes(example.owner), 'Example owner is not mapped')
|
|
exists(example.file)
|
|
}
|
|
for (const pkg of packages) {
|
|
for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
for (const [declaredName, value] of Object.entries(pkg.manifest[field] || {})) {
|
|
assert.equal(typeof value, 'string', `Invalid dependency declaration: ${pkg.name}/${declaredName}`)
|
|
const alias = /^npm:(.+)@([^@]+)$/.exec(value)
|
|
const name = alias?.[1] || declaredName
|
|
const range = alias?.[2] || value
|
|
if (!names.includes(name)) {
|
|
assert(!/^artplayer(?:-|$)/.test(name), `Unknown workspace dependency: ${name}`)
|
|
continue
|
|
}
|
|
const target = packages.find(candidate => candidate.name === name)
|
|
assert(semver.validRange(range) && semver.satisfies(target.version, range), `Workspace dependency range mismatch: ${pkg.name} ${field} ${name}@${range}, current ${target.version}`)
|
|
edge(name, pkg.name, `manifest-${field}`, [`packages/${pkg.name}/package.json`], `${declaredName}@${value}`)
|
|
}
|
|
}
|
|
const walk = (relative) => {
|
|
const absolute = path.join(directory, relative)
|
|
if (!fs.existsSync(absolute))
|
|
return
|
|
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
|
|
const file = `${relative}/${entry.name}`
|
|
assert(!entry.isSymbolicLink(), `Review redirected source before dependency scanning: ${file}`)
|
|
if (entry.isDirectory()) {
|
|
walk(file)
|
|
}
|
|
else if (/\.[cm]?[jt]sx?$/.test(file)) {
|
|
const found = importsIn(file, fs.readFileSync(path.join(directory, file), 'utf8'))
|
|
dynamicImports.push(...found.dynamic)
|
|
for (const specifier of found.imports) {
|
|
const resolved = specifier.startsWith('.') ? path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)) : specifier
|
|
const dependency = names.find(name => resolved === name || resolved.startsWith(`${name}/`) || resolved.startsWith(`packages/${name}/`))
|
|
if (dependency)
|
|
edge(dependency, pkg.name, 'static-source-import', [file], specifier)
|
|
else
|
|
assert(!/^artplayer(?:-|$)/.test(specifier), `Unknown workspace import ${specifier} in ${file}`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const folder of ['src', 'types', 'public']) walk(`packages/${pkg.name}/${folder}`)
|
|
}
|
|
const scripts = read('package.json').scripts
|
|
for (const gate of policy.gates) {
|
|
assert(/^yarn [a-z][\w:-]*$/.test(gate.command), 'Impact gates require an explicit repository script')
|
|
assert(Object.hasOwn(scripts, gate.command.slice(5)), `Missing required command: ${gate.command}`)
|
|
if (gate.arguments !== undefined)
|
|
assert(Array.isArray(gate.arguments) && gate.arguments.length === 1 && ['--browser', '--project=${{ matrix.browser }}'].includes(gate.arguments[0]), 'Impact gate arguments must use a reviewed complete browser scope')
|
|
if (gate.condition !== undefined)
|
|
assert(gate.condition === '${{ !cancelled() }}' && ['yarn test:browser:source', 'yarn test:browser:installed'].includes(gate.command), 'Only browser scopes may collect evidence after ordinary failures')
|
|
}
|
|
const consumerSource = ts.createSourceFile('package-consumer.mjs', fs.readFileSync(path.join(directory, 'scripts/package-consumer.mjs'), 'utf8'), ts.ScriptTarget.Latest, true)
|
|
const consumerNames = consumerSource.statements.filter(ts.isVariableStatement).flatMap(statement => [...statement.declarationList.declarations]).filter(node => ts.isIdentifier(node.name) && node.name.text === 'names')
|
|
assert(consumerNames.length === 1 && consumerNames[0].initializer && ts.isArrayLiteralExpression(consumerNames[0].initializer), 'Review changed installed consumer scope extraction')
|
|
const installed = consumerNames[0].initializer.elements.map((element) => {
|
|
assert(ts.isStringLiteralLike(element), 'Installed consumer scope must be statically reviewable')
|
|
return element.text
|
|
})
|
|
assert.deepEqual(installed.sort(), [...policy.installedConsumerPackages].sort(), 'Installed consumer coverage changed; update its reported scope')
|
|
const coveragePackages = read('scripts/coverage-policy.json').packages
|
|
assert(coveragePackages.every(name => names.includes(name)), 'Unknown coverage package')
|
|
return { policy, packages, edges, dynamicImports: [...new Set(dynamicImports)].sort(), coveragePackages }
|
|
}
|
|
|
|
export function validateImpactWorkflow(text, model) {
|
|
const workflow = YAML.parse(text, { uniqueKeys: true })
|
|
assert(workflow.on && Object.hasOwn(workflow.on, 'pull_request') && Object.hasOwn(workflow.on, 'push'), 'Impact checks require both pull_request and push triggers')
|
|
for (const event of ['pull_request', 'push']) {
|
|
const config = workflow.on[event]
|
|
assert(!config?.paths && !config?.['paths-ignore'], 'Path filters may skip required ecosystem gates')
|
|
}
|
|
for (const gate of model.policy.gates) {
|
|
const job = workflow.jobs?.[gate.job]
|
|
assert(job && !Object.hasOwn(job, 'if') && !job['continue-on-error'], `Required impact job must not be conditional or allowed to fail: ${gate.job}`)
|
|
const steps = job.steps || []
|
|
const invocation = [gate.command, ...(gate.arguments || [])].join(' ')
|
|
const command = invocation.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
assert(steps.some((step) => {
|
|
if ((gate.condition ? step.if !== gate.condition : Object.hasOwn(step, 'if')) || step['continue-on-error'] || typeof step.run !== 'string')
|
|
return false
|
|
const shell = step.shell || job.defaults?.run?.shell || workflow.defaults?.run?.shell
|
|
const defaultBash = !shell && /^(?:ubuntu|macos)-/.test(job['runs-on'])
|
|
if (shell !== 'bash' && !defaultBash)
|
|
return false
|
|
const first = step.run.split(/\r?\n/).map(line => line.trim()).find(line => line && !line.startsWith('#'))
|
|
return first === invocation || (shell === 'bash' && new RegExp(`^${command} 2>&1 \\| tee refactor/\\.cache/ci/[a-z-]+\\.log$`).test(first))
|
|
}), `Required command or execution condition missing from ${gate.job}: ${invocation}`)
|
|
assert(steps.some(step => step.uses?.startsWith('actions/checkout@') && !Object.hasOwn(step, 'if') && !step['continue-on-error'] && step.with?.['fetch-depth'] === 0), `Impact and compatibility checks require full history: ${gate.job}`)
|
|
}
|
|
}
|