ci(refactor): [ENG-IMPACT-01] map workspace changes to required ecosystem checks

This commit is contained in:
Harvey Zhao committed 2026-09-12 10:14:54 +08:00
1 parent e0aacc5d6a
commit 6abac891ce
14 files changed
+1070 -9

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
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 gates remain unconditional. 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.command)
assert.equal(new Set(gateCommands).size, gateCommands.length, 'Duplicate impact gate')
for (const command of ['yarn ci:check', 'yarn ci:build', 'yarn test:package', 'yarn test:browser', 'yarn test:coverage', '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}`)
}
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 command = gate.command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
assert(steps.some((step) => {
if (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 === gate.command || (shell === 'bash' && new RegExp(`^${command} 2>&1 \\| tee refactor/\\.cache/ci/[a-z-]+\\.log$`).test(first))
}), `Required unconditional command missing from ${gate.job}: ${gate.command}`)
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}`)
}
}
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { analyzeImpact, readImpactModel, validateImpactWorkflow } from './impact-model.mjs'
import { hash } from './releases.mjs'
const root = fileURLToPath(new URL('../../', import.meta.url))
export function changedFiles(directory, options = {}) {
const git = args => execFileSync('git', args, { cwd: directory, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd()
const head = git(['rev-parse', 'HEAD'])
const split = text => text.split('\0').filter(Boolean)
const local = [...split(git(['diff', '--name-only', '--no-renames', '-z', 'HEAD', '--'])), ...split(git(['ls-files', '--others', '--exclude-standard', '-z']))]
let requested = options.base
let origin = requested ? 'explicit-base' : 'worktree'
if (!requested && options.eventName) {
const event = options.eventPath ? JSON.parse(fs.readFileSync(options.eventPath, 'utf8')) : {}
requested = options.eventName === 'pull_request' ? event.pull_request?.base?.sha : options.eventName === 'push' ? event.before : null
if (requested && /^0+$/.test(requested))
requested = null
if (requested)
assert(/^[a-f\d]{40}$/.test(requested), 'Invalid GitHub event base SHA')
origin = requested ? `github-${options.eventName}` : 'full-repository-event-fallback'
}
let base = null
let mergeBase = null
let committed = []
if (requested) {
try {
base = git(['rev-parse', '--verify', '--end-of-options', `${requested}^{commit}`])
mergeBase = git(['merge-base', base, head])
}
catch (error) {
if (options.base)
throw error
origin = 'full-repository-missing-event-base'
}
if (mergeBase)
committed = split(git(['diff', '--name-only', '--no-renames', '-z', mergeBase, head, '--']))
}
if (origin.startsWith('full-repository-'))
committed = split(git(['ls-files', '-z']))
return { head, requestedBase: requested || null, base, mergeBase, origin, worktreeChanged: local.length > 0, files: [...new Set([...committed, ...local])].sort() }
}
export function createImpactReport(directory = root, options = {}) {
const model = readImpactModel(directory)
validateImpactWorkflow(fs.readFileSync(path.join(directory, '.github/workflows/nodejs.yml'), 'utf8'), model)
const range = changedFiles(directory, options)
const impact = analyzeImpact(model, range.files)
const metadata = ['refactor/impact-policy.json', 'package.json', 'yarn.lock', ...model.packages.map(pkg => `packages/${pkg.name}/package.json`)]
const inputs = [...new Set([...metadata, ...range.files])].map((file) => {
const absolute = path.join(directory, file)
if (!fs.existsSync(absolute))
return { file, state: 'deleted-or-missing' }
if (fs.lstatSync(absolute).isSymbolicLink())
return { file, state: 'symlink', sha256: hash(fs.readlinkSync(absolute)) }
return { file, state: 'file', sha256: hash(fs.readFileSync(absolute)) }
})
return {
schemaVersion: 1,
range,
...impact,
packages: model.packages.map(pkg => ({ name: pkg.name, version: pkg.version })),
edges: model.edges,
unresolvedDynamicImports: model.dynamicImports,
coveragePackages: model.coveragePackages,
installedConsumerPackages: model.policy.installedConsumerPackages,
inputs,
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
let base
let report = false
const args = process.argv.slice(2)
for (let index = 0; index < args.length; index++) {
if (args[index] === '--report') {
report = true
}
else if (args[index] === '--base') {
base = args[++index]
assert(base && !base.startsWith('--'), 'Missing --base commit/ref')
}
else {
assert.fail('Use --report and/or --base <commit/ref>')
}
}
const result = createImpactReport(root, { base, eventName: process.env.GITHUB_EVENT_NAME, eventPath: process.env.GITHUB_EVENT_PATH })
if (report) {
const directory = path.join(root, 'refactor/.cache/ci')
fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(path.join(directory, 'impact.json'), `${JSON.stringify(result, null, 2)}\n`)
}
console.log(`Impact: ${result.files.length} changed paths, ${result.affectedPackages.length}/${result.packages.length} packages; ${result.range.origin}; required jobs ${result.requiredCIJobs.join(', ')}`)
if (result.reviewRequired)
console.warn(`Impact review: ${result.unknownPaths.length} unclassified paths and ${result.unresolvedDynamicImports.length} dynamic-import files conservatively require the full ecosystem`)
console.log(`Installed-consumer gaps among affected packages: ${result.installedConsumerGaps.length}; impact mapping is not release acceptance`)
}
+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Dependency and real Git change-selection contracts use the baseline runner.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import YAML from 'yaml'
import { analyzeImpact, readImpactModel, validateImpactWorkflow } from './impact-model.mjs'
import { changedFiles } from './impact.mjs'
const root = fileURLToPath(new URL('../../', import.meta.url))
const actual = readImpactModel(root)
const cache = path.join(root, 'refactor/.cache')
function fixture(t) {
const directory = fs.mkdtempSync(path.join(cache, 'impact-test-'))
t.after(() => {
const relative = path.relative(cache, directory)
assert(relative.startsWith('impact-test-') && !relative.includes(path.sep))
fs.rmSync(directory, { recursive: true, force: true })
})
const write = (file, value) => {
fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true })
fs.writeFileSync(path.join(directory, file), typeof value === 'string' ? value : JSON.stringify(value))
}
const names = ['artplayer', 'artplayer-plugin-a', 'artplayer-plugin-b', 'artplayer-vitepress']
const policy = { ...structuredClone(actual.policy), packages: names.map(name => ({ name })), coreValidationConsumers: names.slice(1), edges: [], examples: [], installedConsumerPackages: ['artplayer'] }
write('refactor/impact-policy.json', policy)
write('refactor/compatibility.md', 'Explicit core validation contract')
write('scripts/build-ts.js', '// Shared docs declaration consumer')
write('scripts/package-consumer.mjs', 'export const names = ["artplayer"]')
write('scripts/coverage-policy.json', { packages: ['artplayer'] })
write('package.json', { scripts: Object.fromEntries(policy.gates.map(gate => [gate.command.slice(5), 'echo fixture'])) })
for (const name of names) {
write(`packages/${name}/package.json`, { name, version: '1.0.0' })
write(`packages/${name}/src/index.ts`, 'export default function example() {}')
}
const git = args => execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_AUTHOR_NAME: 'Impact Fixture', GIT_AUTHOR_EMAIL: 'impact@example.test', GIT_COMMITTER_NAME: 'Impact Fixture', GIT_COMMITTER_EMAIL: 'impact@example.test', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null' } }).trim()
const commit = (message) => {
git(['add', '.'])
git(['-c', 'core.hooksPath=', '-c', 'commit.gpgSign=false', 'commit', '-m', message])
return git(['rev-parse', 'HEAD'])
}
return { directory, names, policy, write, git, commit, model: () => readImpactModel(directory) }
}
test('Actual impact graph covers core ecosystem and real plugin/DOM/example consumers without inventing installed coverage', () => {
assert.equal(actual.packages.length, 22)
const core = analyzeImpact(actual, ['packages/artplayer/src/index.ts'])
assert.equal(core.affectedPackages.length, 22)
assert.equal(core.installedConsumerGaps.length, 19)
const ads = analyzeImpact(actual, ['packages/artplayer-plugin-ads/src/session.ts'])
assert.deepEqual(ads.affectedPackages, ['artplayer-plugin-ads', 'artplayer-vitepress'])
const danmuku = analyzeImpact(actual, ['packages/artplayer-plugin-danmuku/src/danmuku.js'])
assert(danmuku.affectedPackages.includes('artplayer-plugin-danmuku-mask'))
assert(danmuku.reasons.some(reason => reason.rule === 'explicit-integration-contract'))
for (const file of ['docs/compiled/artplayer-plugin-ads.legacy.js', 'docs/uncompiled/artplayer-plugin-ads/index.js', 'docs/assets/example/ads.js'])
assert.deepEqual(analyzeImpact(actual, [file]).affectedPackages, ads.affectedPackages)
validateImpactWorkflow(fs.readFileSync(path.join(root, '.github/workflows/nodejs.yml'), 'utf8'), actual)
})
test('Static imports and manifest aliases propagate transitively; contradictory ranges and unknown packages fail', (t) => {
const repo = fixture(t)
repo.write('packages/artplayer-plugin-a/src/index.ts', 'export { default } from "../../artplayer-plugin-b/src/index"')
repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0', dependencies: { alias: 'npm:artplayer@^1.0.0' } })
const model = repo.model()
assert(model.edges.some(edge => edge.consumer === 'artplayer-plugin-b' && edge.dependency === 'artplayer' && edge.kind === 'manifest-dependencies'))
assert.deepEqual(analyzeImpact(model, ['packages/artplayer-plugin-b/src/index.ts']).affectedPackages, ['artplayer-plugin-a', 'artplayer-plugin-b', 'artplayer-vitepress'])
repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0', peerDependencies: { artplayer: '^2.0.0' } })
assert.throws(repo.model, /range mismatch/)
repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0' })
repo.write('packages/artplayer-plugin-a/src/index.ts', 'import unknown from "artplayer-plugin-missing"')
assert.throws(repo.model, /Unknown workspace import/)
repo.write('packages/artplayer-plugin-new/package.json', { name: 'artplayer-plugin-new', version: '1.0.0' })
assert.throws(repo.model, /inventory changed/)
})
test('Policy cannot omit core consumers or claim stale installed-package coverage', (t) => {
const missingGate = fixture(t)
missingGate.policy.gates = []
missingGate.write('refactor/impact-policy.json', missingGate.policy)
assert.throws(missingGate.model, /Missing mandatory ecosystem gate/)
const repo = fixture(t)
repo.policy.coreValidationConsumers.pop()
repo.write('refactor/impact-policy.json', repo.policy)
assert.throws(repo.model, /every ecosystem validation consumer/)
repo.policy.coreValidationConsumers = repo.names.slice(1)
repo.write('refactor/impact-policy.json', repo.policy)
repo.write('scripts/package-consumer.mjs', 'export const names = ["artplayer", "artplayer-plugin-a"]')
assert.throws(repo.model, /consumer coverage changed/)
repo.write('scripts/package-consumer.mjs', 'export const names = computeNames()')
assert.throws(repo.model, /scope extraction/)
})
test('Shared/unknown files and computed imports conservatively expand; unsafe or unmapped package paths are rejected', (t) => {
for (const file of ['yarn.lock', 'scripts/new-build-stage.mjs', 'types/new-boundary.d.ts'])
assert.equal(analyzeImpact(actual, [file]).affectedPackages.length, 22)
const unknown = analyzeImpact(actual, ['new-shared-config.toml'])
assert.equal(unknown.affectedPackages.length, 22)
assert.equal(unknown.reviewRequired, true)
for (const file of ['../outside', '/absolute', 'C:/absolute', 'packages\\artplayer\\src\\index.ts'])
assert.throws(() => analyzeImpact(actual, [file]), /path|Path/)
assert.throws(() => analyzeImpact(actual, ['packages/unmapped/src/index.js']), /not mapped/)
const repo = fixture(t)
repo.write('packages/artplayer-plugin-a/src/index.ts', 'export const load = (name: string) => import(name)')
const model = repo.model()
assert.deepEqual(model.dynamicImports, ['packages/artplayer-plugin-a/src/index.ts'])
assert.equal(analyzeImpact(model, ['packages/artplayer-plugin-b/src/index.ts']).affectedPackages.length, 4)
repo.write('packages/artplayer-plugin-a/src/index.ts', 'export { from')
assert.throws(repo.model, /malformed dependency source/)
})
test('CI workflow cannot silently skip or soften required impact gates', () => {
const source = fs.readFileSync(path.join(root, '.github/workflows/nodejs.yml'), 'utf8')
for (const edit of [
(workflow) => { workflow.on.pull_request = { paths: ['packages/artplayer/**'] } },
(workflow) => { workflow.jobs.coverage.if = 'false' },
(workflow) => { workflow.jobs.coverage.if = false },
(workflow) => { workflow.jobs.coverage['continue-on-error'] = true },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).if = 'false' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'echo yarn ci:check' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'if false; then\nyarn ci:check\nfi' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'yarn ci:check || true' },
(workflow) => { workflow.jobs.checks.defaults.run.shell = 'sh' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).if = false },
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).if = false },
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).with['fetch-depth'] = 1 },
]) {
const workflow = YAML.parse(source)
edit(workflow)
assert.throws(() => validateImpactWorkflow(YAML.stringify(workflow), actual), /Path filters|Required|full history/)
}
})
test('Real Git selection includes both sides of renames, staged/unstaged/untracked paths and deletions', (t) => {
const repo = fixture(t)
repo.git(['init', '-b', 'main'])
repo.write('packages/artplayer-plugin-a/src/old.ts', 'old content')
repo.write('packages/artplayer-plugin-a/src/deleted.ts', 'delete later')
const base = repo.commit('base')
repo.git(['mv', 'packages/artplayer-plugin-a/src/old.ts', 'packages/artplayer-plugin-b/src/renamed.ts'])
repo.commit('rename across packages')
repo.write('packages/artplayer-plugin-a/src/staged.ts', 'staged')
repo.git(['add', 'packages/artplayer-plugin-a/src/staged.ts'])
repo.write('packages/artplayer-plugin-a/src/space name.ts', 'untracked')
repo.write('packages/artplayer-plugin-b/src/index.ts', 'unstaged edit')
fs.unlinkSync(path.join(repo.directory, 'packages/artplayer-plugin-a/src/deleted.ts'))
const range = changedFiles(repo.directory, { base })
assert.equal(range.origin, 'explicit-base')
assert.equal(range.mergeBase, base)
assert.deepEqual(range.files, [
'packages/artplayer-plugin-a/src/deleted.ts',
'packages/artplayer-plugin-a/src/old.ts',
'packages/artplayer-plugin-a/src/space name.ts',
'packages/artplayer-plugin-a/src/staged.ts',
'packages/artplayer-plugin-b/src/index.ts',
'packages/artplayer-plugin-b/src/renamed.ts',
])
assert.equal(range.worktreeChanged, true)
assert(!changedFiles(repo.directory).files.includes('packages/artplayer-plugin-a/src/old.ts'))
assert.throws(() => changedFiles(repo.directory, { base: 'missing-ref' }))
})
test('GitHub event base is read as data; absent, zero and unavailable event bases require full repository checks', (t) => {
const repo = fixture(t)
repo.git(['init', '-b', 'main'])
const base = repo.commit('base')
repo.write('packages/artplayer-plugin-a/src/index.ts', 'changed source')
repo.commit('change')
const eventPath = path.join(repo.directory, '.git/event.json')
const event = data => fs.writeFileSync(eventPath, JSON.stringify(data))
event({ pull_request: { base: { sha: base } } })
assert.deepEqual(changedFiles(repo.directory, { eventName: 'pull_request', eventPath }).files, ['packages/artplayer-plugin-a/src/index.ts'])
for (const before of ['0'.repeat(40), 'a'.repeat(40), undefined]) {
event({ before })
const range = changedFiles(repo.directory, { eventName: 'push', eventPath })
assert(range.origin.startsWith('full-repository-'))
assert(range.files.includes('packages/artplayer-plugin-b/src/index.ts'))
}
event({ before: '--not-a-sha' })
assert.throws(() => changedFiles(repo.directory, { eventName: 'push', eventPath }), /Invalid GitHub event base/)
})