import { createHash } from 'node:crypto'; import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDirectory, '..'); const assetRoot = path.join( repoRoot, 'activity', 'week-star', 'fami', 'assets' ); const layerDirectory = path.join(assetRoot, 'layers'); const fixtureDirectory = path.join(assetRoot, 'fixtures'); const manifestPath = path.join(layerDirectory, 'manifest.json'); const requiredRuntimeFiles = new Set([ 'body-watermark.webp', 'gift-frame.webp', 'gift-glow.webp', 'medal-1.webp', 'medal-2.webp', 'medal-3.webp', 'reward-heading.webp', 'reward-panel.webp', 'tab-active.webp', 'tab-inactive.webp', ]); const requiredFixtureFiles = new Set([ '../fixtures/mock-avatar-top.webp', '../fixtures/mock-avatar-user.webp', '../fixtures/mock-gift.webp', ]); const requiredAggregateNodes = new Set([ '721:2016', '837:4472', '837:4545', '837:4615', '837:4475', '837:4491', '837:4507', '837:4548', '837:4564', '837:4579', '837:4618', '837:4634', '837:4649', '837:4721', ]); // WebP bitstreams expose their codec, decoded dimensions, and alpha presence, // but do not serialize the cwebp -q/-alpha_q command values. Keep the approved // export recipe independent from the manifest so an edited manifest cannot // silently redefine either the Figma source pixels or production compression. const requiredAssetMetadata = new Map( Object.entries({ 'body-watermark.webp': { original: [841, 1870], exported: [841, 1870], exportFormat: 'webp-lossy', quality: 88, alphaQuality: null, hasAlpha: false, }, 'gift-frame.webp': { original: [1169, 1175], exported: [514, 516], exportFormat: 'webp-lossy', quality: 86, alphaQuality: 100, hasAlpha: true, }, 'gift-glow.webp': { original: [1183, 1183], exported: [438, 438], exportFormat: 'webp-lossy', quality: 84, alphaQuality: 100, hasAlpha: true, }, 'medal-1.webp': { original: [625, 553], exported: [207, 183], exportFormat: 'webp-lossy', quality: 90, alphaQuality: 100, hasAlpha: true, }, 'medal-2.webp': { original: [642, 582], exported: [207, 188], exportFormat: 'webp-lossy', quality: 90, alphaQuality: 100, hasAlpha: true, }, 'medal-3.webp': { original: [619, 595], exported: [207, 199], exportFormat: 'webp-lossy', quality: 90, alphaQuality: 100, hasAlpha: true, }, 'reward-heading.webp': { original: [632, 98], exported: [632, 98], exportFormat: 'webp-lossy', quality: 90, alphaQuality: 100, hasAlpha: true, }, 'reward-panel.webp': { original: [931, 1522], exported: [931, 1522], exportFormat: 'webp-lossy', quality: 85, alphaQuality: 100, hasAlpha: true, }, 'tab-active.webp': { original: [1567, 697], exported: [672, 298], exportFormat: 'webp-lossy', quality: 86, alphaQuality: 100, hasAlpha: true, }, 'tab-inactive.webp': { original: [1024, 455], exported: [672, 298], exportFormat: 'webp-lossy', quality: 86, alphaQuality: 100, hasAlpha: true, }, '../fixtures/mock-avatar-top.webp': { original: [1200, 1200], exported: [1200, 1200], exportFormat: 'webp-lossless', quality: null, alphaQuality: null, hasAlpha: false, }, '../fixtures/mock-avatar-user.webp': { original: [768, 1024], exported: [768, 1024], exportFormat: 'webp-lossless', quality: null, alphaQuality: null, hasAlpha: false, }, '../fixtures/mock-gift.webp': { original: [144, 144], exported: [144, 144], exportFormat: 'webp-lossless', quality: null, alphaQuality: null, hasAlpha: true, }, }) ); function invariant(condition, message) { if (!condition) { throw new Error(`[week-star-fami-layer-audit] ${message}`); } } function sameSet(actual, expected) { return ( actual.size === expected.size && [...actual].every((item) => expected.has(item)) ); } function webpMetadata(bytes, file) { invariant( bytes.length >= 20 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP', `${file}: file is not WebP` ); invariant( bytes.readUInt32LE(4) + 8 === bytes.length, `${file}: RIFF length mismatch` ); let width; let height; let encoding; let hasAlpha = false; const setDimensions = (nextWidth, nextHeight, chunk) => { invariant( nextWidth > 0 && nextHeight > 0, `${file}: invalid ${chunk} dimensions` ); if (width === undefined) { width = nextWidth; height = nextHeight; return; } invariant( width === nextWidth && height === nextHeight, `${file}: container and ${chunk} dimensions disagree` ); }; let offset = 12; while (offset + 8 <= bytes.length) { const chunk = bytes.toString('ascii', offset, offset + 4); const chunkSize = bytes.readUInt32LE(offset + 4); const payloadStart = offset + 8; const payloadEnd = payloadStart + chunkSize; invariant( payloadEnd <= bytes.length, `${file}: truncated ${chunk} chunk` ); if (chunk === 'VP8X') { invariant(chunkSize >= 10, `${file}: invalid VP8X chunk`); hasAlpha ||= Boolean(bytes[payloadStart] & 0x10); setDimensions( 1 + bytes.readUIntLE(payloadStart + 4, 3), 1 + bytes.readUIntLE(payloadStart + 7, 3), chunk ); } else if (chunk === 'ALPH') { hasAlpha = true; } else if (chunk === 'VP8L') { invariant(chunkSize >= 5, `${file}: invalid VP8L chunk`); invariant( bytes[payloadStart] === 0x2f, `${file}: invalid VP8L signature` ); const packed = bytes.readUInt32LE(payloadStart + 1); invariant(packed >>> 29 === 0, `${file}: unsupported VP8L version`); setDimensions( 1 + (packed & 0x3fff), 1 + ((packed >>> 14) & 0x3fff), chunk ); hasAlpha ||= Boolean((packed >>> 28) & 1); invariant( !encoding || encoding === 'lossless', `${file}: conflicting WebP codecs` ); encoding = 'lossless'; } else if (chunk === 'VP8 ') { invariant(chunkSize >= 10, `${file}: invalid VP8 chunk`); invariant( bytes[payloadStart + 3] === 0x9d && bytes[payloadStart + 4] === 0x01 && bytes[payloadStart + 5] === 0x2a, `${file}: invalid VP8 frame header` ); setDimensions( bytes.readUInt16LE(payloadStart + 6) & 0x3fff, bytes.readUInt16LE(payloadStart + 8) & 0x3fff, chunk ); invariant( !encoding || encoding === 'lossy', `${file}: conflicting WebP codecs` ); encoding = 'lossy'; } offset = payloadEnd + (chunkSize % 2); } invariant(offset === bytes.length, `${file}: invalid RIFF chunk padding`); invariant(width && height && encoding, `${file}: image payload is missing`); return { width, height, encoding, hasAlpha }; } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); invariant(manifest.schemaVersion === 2, 'unsupported manifest schema'); invariant( manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', 'unexpected Figma file key' ); invariant(manifest.figma?.canvas?.id === '721:2016', 'unexpected Figma canvas'); invariant( JSON.stringify(manifest.figma?.entryFrames) === JSON.stringify({ history: { id: '837:4472', name: '历史前三', width: 1080, height: 2651, }, current: { id: '837:4545', name: '历史前三', width: 1080, height: 2460, }, rewards: { id: '837:4615', name: '历史前三', width: 1080, height: 3532, }, }), 'entry-frame mapping changed' ); const heroReference = manifest.figma?.references?.hero; invariant(heroReference?.groupNode === '837:4721', 'hero group trace missing'); invariant(heroReference?.maskNode === '837:4722', 'hero mask trace missing'); invariant(heroReference?.imageNode === '837:4723', 'hero image trace missing'); invariant( heroReference?.runtimeExported === false, 'hero must remain a non-runtime reference' ); const forbiddenAggregateNodes = new Set( manifest.policy?.forbiddenAggregateNodes || [] ); for (const node of requiredAggregateNodes) { invariant( forbiddenAggregateNodes.has(node), `required aggregate-node ban is missing: ${node}` ); } const allowedSourceTypes = new Set(manifest.policy?.allowedSourceTypes || []); invariant( allowedSourceTypes.size === 1 && allowedSourceTypes.has('RECTANGLE'), 'only isolated rectangle image-fill nodes may be exported' ); const assets = manifest.assets; invariant(Array.isArray(assets), 'manifest assets must be an array'); invariant( assets.length === 13, 'manifest must trace 10 runtime layers and 3 fixtures' ); invariant( assets.length === requiredAssetMetadata.size, 'asset metadata recipe and manifest inventory disagree' ); const assetsByFile = new Map(); const sourceOwners = new Map(); for (const asset of assets) { invariant( typeof asset.file === 'string' && !assetsByFile.has(asset.file), `duplicate or missing asset file: ${asset.file}` ); assetsByFile.set(asset.file, asset); const expectedMetadata = requiredAssetMetadata.get(asset.file); invariant(expectedMetadata, `${asset.file}: export recipe is missing`); invariant( /^\d+:\d+$/.test(asset.sourceNode || ''), `${asset.file}: source node is required` ); invariant( !forbiddenAggregateNodes.has(asset.sourceNode), `${asset.file}: aggregate Figma node export is forbidden` ); invariant( allowedSourceTypes.has(asset.sourceType), `${asset.file}: source type must be an isolated visual leaf` ); invariant( asset.sourceName && asset.purpose, `${asset.file}: source name and purpose are required` ); invariant( !sourceOwners.has(asset.sourceNode), `${asset.file}: source node is already owned by ${sourceOwners.get(asset.sourceNode)}` ); sourceOwners.set(asset.sourceNode, asset.file); invariant( Array.isArray(asset.equivalentNodes), `${asset.file}: equivalentNodes must be an array` ); for (const node of asset.equivalentNodes) { invariant( /^\d+:\d+$/.test(node), `${asset.file}: invalid equivalent node ${node}` ); invariant( !forbiddenAggregateNodes.has(node), `${asset.file}: aggregate equivalent node is forbidden: ${node}` ); } invariant( String(asset.kind || '').startsWith('single-'), `${asset.file}: asset is not classified as one visual layer` ); invariant( asset.bounds?.width > 0 && asset.bounds?.height > 0, `${asset.file}: Figma bounds are required` ); invariant( asset.original?.width > 0 && asset.original?.height > 0, `${asset.file}: original pixel dimensions are required` ); invariant( asset.original.width === expectedMetadata.original[0] && asset.original.height === expectedMetadata.original[1], `${asset.file}: Figma source pixel dimensions changed` ); invariant( asset.exported?.width === expectedMetadata.exported[0] && asset.exported?.height === expectedMetadata.exported[1], `${asset.file}: exported pixel dimensions changed` ); invariant( asset.exportFormat === expectedMetadata.exportFormat, `${asset.file}: export format changed` ); invariant( asset.quality === expectedMetadata.quality, `${asset.file}: lossy quality changed` ); invariant( asset.alphaQuality === expectedMetadata.alphaQuality, `${asset.file}: alpha quality changed` ); invariant( asset.hasAlpha === expectedMetadata.hasAlpha, `${asset.file}: declared alpha presence changed` ); if (asset.exportFormat === 'webp-lossy') { invariant( Number.isInteger(asset.quality) && asset.quality >= 1 && asset.quality <= 100, `${asset.file}: lossy quality must be between 1 and 100` ); invariant( asset.hasAlpha ? Number.isInteger(asset.alphaQuality) && asset.alphaQuality >= 1 && asset.alphaQuality <= 100 : asset.alphaQuality === null, `${asset.file}: alpha quality does not match alpha usage` ); } else { invariant( asset.exportFormat === 'webp-lossless' && asset.quality === null && asset.alphaQuality === null, `${asset.file}: lossless exports must not declare lossy quality` ); } invariant( Number.isInteger(asset.bytes) && asset.bytes > 0, `${asset.file}: byte size is required` ); invariant( /^[a-f0-9]{64}$/.test(asset.sha256 || ''), `${asset.file}: SHA-256 is required` ); invariant( Array.isArray(asset.dependencies), `${asset.file}: dependencies must be an array` ); for (const flag of [ 'containsText', 'containsControls', 'containsDynamicMedia', 'mockOnly', 'dynamic', ]) { invariant( typeof asset[flag] === 'boolean', `${asset.file}: ${flag} must be boolean` ); } invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`); invariant( !asset.containsControls, `${asset.file}: rendered controls are forbidden` ); const resolvedPath = path.resolve(layerDirectory, asset.file); invariant( resolvedPath.startsWith(`${assetRoot}${path.sep}`), `${asset.file}: asset escapes the week-star asset root` ); if (asset.scope === 'runtime') { invariant( path.dirname(resolvedPath) === layerDirectory, `${asset.file}: runtime assets must stay in assets/layers` ); invariant( path.posix.basename(asset.file) === asset.file, `${asset.file}: runtime file must not use a nested path` ); invariant( !asset.containsDynamicMedia && !asset.mockOnly && !asset.dynamic, `${asset.file}: runtime layers cannot contain mock or dynamic media` ); for (const pattern of manifest.policy.forbiddenRuntimeNamePatterns || []) { invariant( !asset.file .toLowerCase() .includes(String(pattern).toLowerCase()), `${asset.file}: forbidden aggregate-style runtime name ${pattern}` ); } } else if (asset.scope === 'fixture') { invariant( path.dirname(resolvedPath) === fixtureDirectory, `${asset.file}: fixtures must stay in assets/fixtures` ); invariant( asset.file.startsWith('../fixtures/'), `${asset.file}: fixture path must be explicit` ); invariant( asset.containsDynamicMedia && asset.mockOnly && asset.dynamic, `${asset.file}: sample media must be marked mockOnly and dynamic` ); } else { invariant(false, `${asset.file}: unsupported scope ${asset.scope}`); } const [bytes, fileInfo] = await Promise.all([ readFile(resolvedPath), stat(resolvedPath), ]); const digest = createHash('sha256').update(bytes).digest('hex'); const metadata = webpMetadata(bytes, asset.file); invariant( fileInfo.size === asset.bytes, `${asset.file}: byte size mismatch` ); invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); invariant( metadata.width === asset.exported.width && metadata.height === asset.exported.height, `${asset.file}: decoded pixels do not match exported dimensions` ); invariant( asset.exportFormat === `webp-${metadata.encoding}`, `${asset.file}: decoded WebP codec does not match export format` ); invariant( metadata.hasAlpha === asset.hasAlpha, `${asset.file}: decoded alpha presence does not match manifest` ); } const actualRuntimeFiles = new Set( assets .filter((asset) => asset.scope === 'runtime') .map((asset) => asset.file) ); const actualFixtureFiles = new Set( assets .filter((asset) => asset.scope === 'fixture') .map((asset) => asset.file) ); invariant( sameSet(actualRuntimeFiles, requiredRuntimeFiles), 'runtime layer inventory changed' ); invariant( sameSet(actualFixtureFiles, requiredFixtureFiles), 'fixture inventory changed' ); const diskRuntimeFiles = new Set( (await readdir(layerDirectory)).filter((file) => file.endsWith('.webp')) ); const diskFixtureFiles = new Set( (await readdir(fixtureDirectory)) .filter((file) => file.endsWith('.webp')) .map((file) => `../fixtures/${file}`) ); invariant( sameSet(diskRuntimeFiles, requiredRuntimeFiles), 'every runtime layer must have exactly one manifest entry' ); invariant( sameSet(diskFixtureFiles, requiredFixtureFiles), 'every visual fixture must have exactly one manifest entry' ); for (const asset of assets) { for (const dependency of asset.dependencies) { const target = assetsByFile.get(dependency); invariant(target, `${asset.file}: unknown dependency ${dependency}`); invariant( dependency !== asset.file, `${asset.file}: self dependency is forbidden` ); if (asset.scope === 'runtime') { invariant( target.scope === 'runtime', `${asset.file}: runtime assets cannot depend on fixtures` ); } } } const visiting = new Set(); const visited = new Set(); function visit(file) { if (visited.has(file)) return; invariant(!visiting.has(file), `asset dependency cycle includes ${file}`); visiting.add(file); for (const dependency of assetsByFile.get(file).dependencies) visit(dependency); visiting.delete(file); visited.add(file); } for (const file of assetsByFile.keys()) visit(file); const compositions = manifest.compositions; invariant( compositions && typeof compositions === 'object', 'layer compositions are required' ); const compositionSlots = new Set(); const composedRuntimeFiles = new Set(); for (const [name, composition] of Object.entries(compositions)) { invariant( Array.isArray(composition.layers) && composition.layers.length > 0, `${name}: composition layers are required` ); invariant( Array.isArray(composition.dynamicSlots), `${name}: dynamicSlots must be an array` ); for (const file of composition.layers) { const asset = assetsByFile.get(file); invariant(asset, `${name}: unknown composition layer ${file}`); invariant( asset.scope === 'runtime', `${name}: production composition cannot include fixture ${file}` ); composedRuntimeFiles.add(file); } for (const slot of composition.dynamicSlots) { invariant( typeof slot === 'string' && slot.length > 0, `${name}: invalid dynamic slot` ); compositionSlots.add(slot); } } invariant( sameSet(composedRuntimeFiles, requiredRuntimeFiles), 'every runtime layer must belong to a declared composition' ); const fixtureBindings = manifest.fixtureBindings; invariant( Array.isArray(fixtureBindings) && fixtureBindings.length === 3, 'three explicit fixture bindings are required' ); const boundFixtures = new Set(); for (const binding of fixtureBindings) { const asset = assetsByFile.get(binding.file); invariant( asset?.scope === 'fixture', `invalid fixture binding ${binding.file}` ); invariant( binding.mockOnly === true, `${binding.file}: binding must be mockOnly` ); invariant( compositionSlots.has(binding.slot), `${binding.file}: fixture slot is not declared by a composition` ); invariant( !boundFixtures.has(binding.file), `${binding.file}: duplicate fixture binding` ); boundFixtures.add(binding.file); } invariant( sameSet(boundFixtures, requiredFixtureFiles), 'every fixture must bind exactly one dynamic slot' ); const shippedSourceNodes = new Set(assets.map((asset) => asset.sourceNode)); for (const node of [ heroReference.groupNode, heroReference.maskNode, heroReference.imageNode, ]) { invariant( !shippedSourceNodes.has(node), `hero reference node was shipped: ${node}` ); } console.log( `[week-star-fami-layer-audit] passed: ${requiredRuntimeFiles.size} isolated runtime layers, ${requiredFixtureFiles.size} mock-only dynamic fixtures` );