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 entryDirectory = path.join(repoRoot, 'activity', 'week-star'); const pageDirectory = path.join(entryDirectory, 'huwaa'); const layerDirectory = path.join(pageDirectory, 'assets', 'layers'); const manifestPath = path.join(layerDirectory, 'manifest.json'); const expectedFrames = { history: { id: '818:1999', name: '历史前三', width: 1080, height: 2309, }, weekly: { id: '818:2066', name: '本周榜单', width: 1080, height: 2309, }, rewards: { id: '818:2132', name: '奖励说明', width: 1080, height: 3138, }, }; const forbiddenRootNodes = new Set([ '543:1998', '818:1999', '818:2066', '818:2132', ]); const expectedFiles = new Set([ 'background-stars.webp', 'date-ribbon.webp', 'gift-frame.webp', 'hero-background.webp', 'rank-1.webp', 'rank-2.webp', 'rank-3.webp', 'reward-heading.webp', 'reward-item-frame.webp', 'reward-item-glow.webp', 'reward-panel.webp', 'sample-gift.webp', 'tab-active.webp', 'tab-inactive.webp', ]); const aggregateNamePattern = /(?:whole[-_ ]?page|full[-_ ]?page|page[-_ ]?screenshot|screen[-_ ]?shot|root[-_ ]?frame|entry[-_ ]?frame|figma[-_ ]?frame|aggregate)/i; function invariant(condition, message) { if (!condition) { throw new Error(`[week-star-huwaa-asset-audit] ${message}`); } } function sameSet(actual, expected) { return ( actual.size === expected.size && [...actual].every((value) => expected.has(value)) ); } function readUInt24LE(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); } // WebP can store dimensions in VP8X, VP8L, or VP8. Walk RIFF chunks instead of // assuming the image payload is the first chunk, because alpha/metadata chunks // may precede it in otherwise valid exports. function webpDimensions(bytes, filename) { invariant( bytes.length >= 20 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP', `${filename}: file is not a RIFF WebP` ); let offset = 12; while (offset + 8 <= bytes.length) { const type = bytes.toString('ascii', offset, offset + 4); const size = bytes.readUInt32LE(offset + 4); const payload = offset + 8; invariant( payload + size <= bytes.length, `${filename}: truncated ${type} WebP chunk` ); if (type === 'VP8X') { invariant(size >= 10, `${filename}: invalid VP8X chunk`); return { width: 1 + readUInt24LE(bytes, payload + 4), height: 1 + readUInt24LE(bytes, payload + 7), }; } if (type === 'VP8L') { invariant( size >= 5 && bytes[payload] === 0x2f, `${filename}: invalid VP8L signature` ); const packed = bytes.readUInt32LE(payload + 1); return { width: 1 + (packed & 0x3fff), height: 1 + ((packed >>> 14) & 0x3fff), }; } if (type === 'VP8 ') { invariant( size >= 10 && bytes[payload + 3] === 0x9d && bytes[payload + 4] === 0x01 && bytes[payload + 5] === 0x2a, `${filename}: invalid VP8 frame header` ); return { width: bytes.readUInt16LE(payload + 6) & 0x3fff, height: bytes.readUInt16LE(payload + 8) & 0x3fff, }; } offset = payload + size + (size & 1); } throw new Error( `[week-star-huwaa-asset-audit] ${filename}: no WebP image chunk found` ); } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); invariant(manifest.schemaVersion === 1, 'unsupported manifest schema'); invariant( manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', 'unexpected Figma file key' ); invariant(manifest.figma?.canvas?.id === '543:1998', 'unexpected Figma canvas'); invariant( JSON.stringify(manifest.figma?.entryFrames) === JSON.stringify(expectedFrames), 'entry-frame mapping changed' ); const manifestForbiddenNodes = new Set( manifest.policy?.forbiddenSourceNodes || [] ); for (const node of forbiddenRootNodes) { invariant( manifestForbiddenNodes.has(node), `required root/canvas-node ban is missing: ${node}` ); } const maxLayer = manifest.policy?.maxReasonableFileLayer; invariant( maxLayer?.width > 0 && maxLayer?.height > 0 && maxLayer?.pixels > 0, 'reasonable single-layer size policy is missing' ); const assets = manifest.assets; invariant(Array.isArray(assets), 'manifest assets must be an array'); invariant(assets.length === expectedFiles.size, 'manifest asset count changed'); const assetsByFile = new Map(); for (const asset of assets) { invariant( typeof asset.filename === 'string' && path.basename(asset.filename) === asset.filename, 'asset filename must be a basename' ); invariant( !assetsByFile.has(asset.filename), `duplicate manifest asset: ${asset.filename}` ); assetsByFile.set(asset.filename, asset); invariant( !aggregateNamePattern.test(asset.filename), `${asset.filename}: aggregate/root/screenshot-style filename is forbidden` ); for (const pattern of manifest.policy?.forbiddenFilenamePatterns || []) { invariant( !asset.filename .toLowerCase() .includes(String(pattern).toLowerCase()), `${asset.filename}: forbidden filename pattern ${pattern}` ); } invariant( /^\d+:\d+$/.test(asset.sourceNode || ''), `${asset.filename}: sourceNode is required` ); invariant( !forbiddenRootNodes.has(asset.sourceNode), `${asset.filename}: canvas/root frame cannot be an asset source` ); invariant( Array.isArray(asset.sourceNodes) && asset.sourceNodes.length > 0 && asset.sourceNodes.includes(asset.sourceNode), `${asset.filename}: complete sourceNodes trace is required` ); for (const node of asset.sourceNodes) { invariant( /^\d+:\d+$/.test(node) && !forbiddenRootNodes.has(node), `${asset.filename}: invalid or aggregate source node ${node}` ); } invariant( asset.sourceType === 'RECTANGLE', `${asset.filename}: source must be an isolated image-fill rectangle` ); invariant( typeof asset.sourceName === 'string' && asset.sourceName.length > 0, `${asset.filename}: sourceName is required` ); invariant( /^[a-f0-9]{40}$/.test(asset.sourceImageHash || ''), `${asset.filename}: exact Figma sourceImageHash is required` ); invariant( typeof asset.use === 'string' && asset.use.length > 0, `${asset.filename}: use is required` ); invariant( String(asset.kind || '').startsWith('single-'), `${asset.filename}: asset is not classified as one visual layer` ); invariant( asset.renderedDesignSize?.width > 0 && asset.renderedDesignSize?.height > 0, `${asset.filename}: rendered design dimensions are required` ); invariant( asset.file?.format === 'webp' && Number.isInteger(asset.file?.pixelWidth) && Number.isInteger(asset.file?.pixelHeight) && Number.isInteger(asset.file?.bytes) && asset.file.pixelWidth > 0 && asset.file.pixelHeight > 0 && asset.file.bytes > 0, `${asset.filename}: actual file dimensions/bytes are required` ); invariant( /^[a-f0-9]{64}$/.test(asset.sha256 || ''), `${asset.filename}: SHA-256 is required` ); for (const flag of [ 'containsText', 'containsControls', 'containsDynamicBusinessMedia', 'mockOnly', ]) { invariant( typeof asset[flag] === 'boolean', `${asset.filename}: ${flag} must be boolean` ); } invariant( !asset.containsText, `${asset.filename}: localized/readable copy must not be baked into assets` ); invariant( !asset.containsControls, `${asset.filename}: controls must remain DOM elements` ); if (asset.scope === 'runtime') { invariant( !asset.containsDynamicBusinessMedia && !asset.mockOnly, `${asset.filename}: production layers cannot contain mock/dynamic business media` ); } else if (asset.scope === 'fixture') { invariant( asset.filename === 'sample-gift.webp' && asset.containsDynamicBusinessMedia && asset.mockOnly, `${asset.filename}: only the explicit mock gift may be a fixture` ); } else { invariant(false, `${asset.filename}: unsupported scope ${asset.scope}`); } invariant( asset.file.pixelWidth <= maxLayer.width && asset.file.pixelHeight <= maxLayer.height && asset.file.pixelWidth * asset.file.pixelHeight <= maxLayer.pixels, `${asset.filename}: file exceeds a reasonable single-layer size` ); invariant( !(asset.file.pixelWidth >= 1000 && asset.file.pixelHeight >= 2200), `${asset.filename}: whole-screen-sized image is forbidden` ); const assetPath = path.join(layerDirectory, asset.filename); const [bytes, fileInfo] = await Promise.all([ readFile(assetPath), stat(assetPath), ]); const digest = createHash('sha256').update(bytes).digest('hex'); const dimensions = webpDimensions(bytes, asset.filename); invariant( fileInfo.size === asset.file.bytes, `${asset.filename}: byte size mismatch` ); invariant(digest === asset.sha256, `${asset.filename}: SHA-256 mismatch`); invariant( dimensions.width === asset.file.pixelWidth && dimensions.height === asset.file.pixelHeight, `${asset.filename}: WebP dimensions mismatch` ); } invariant( sameSet(new Set(assetsByFile.keys()), expectedFiles), 'manifest must describe exactly the expected 14 traced files' ); const diskFiles = new Set( (await readdir(layerDirectory)).filter((file) => file !== 'manifest.json') ); invariant( sameSet(diskFiles, expectedFiles), 'every layer file must have exactly one manifest entry; no source/root screenshots may be stored here' ); const hero = assetsByFile.get('hero-background.webp'); invariant( hero?.kind === 'single-derived-background', 'derived hero kind changed' ); invariant( hero?.sourceImageHash === 'cbc4013cd84d895e3a3ebf260e42b1013066c6ae', 'hero exact-fill source hash changed' ); invariant( hero?.derivedFrom?.kind === 'exact-figma-image-fill' && hero.derivedFrom.sourceImageHash === hero.sourceImageHash && hero.derivedFrom.tool === 'built-in image_gen' && hero.derivedFrom.sourceContainedReadableText === true && /removed only the embedded readable title\/date text/i.test( hero.derivedFrom.operation || '' ), 'hero must be documented as an exact-fill derivative with only embedded title/date text removed' ); invariant( !hero.containsText && !hero.containsControls && !hero.containsDynamicBusinessMedia && !hero.mockOnly, 'derived hero output must be a text-free, control-free, non-business-media background' ); invariant( /decorative hero background only/i.test(hero.use) && /DOM\/i18n\/API/i.test(hero.use), 'hero purpose must keep title/date in live DOM/i18n/API content' ); for (const asset of assets) { if (asset.filename !== hero.filename) { invariant( asset.derivedFrom === undefined, `${asset.filename}: unexpected derived-image declaration` ); } } const [html, css, js, commonTheme, commonAPI, localeEntries] = await Promise.all([ readFile(path.join(entryDirectory, 'huwaa.html'), 'utf8'), readFile(path.join(pageDirectory, 'huwaa.css'), 'utf8'), readFile(path.join(pageDirectory, 'huwaa.js'), 'utf8'), readFile(path.join(repoRoot, 'common', 'theme.css'), 'utf8'), readFile(path.join(repoRoot, 'common', 'api.js'), 'utf8'), Promise.all( ['en', 'ar', 'tr', 'es'].map(async (language) => ({ language, messages: JSON.parse( await readFile( path.join( repoRoot, 'common', 'locales', `${language}.json` ), 'utf8' ) ), })) ), ]); const runtimeSource = `${html}\n${css}\n${js}`; for (const asset of assets) { invariant( runtimeSource.includes(asset.filename), `${asset.filename}: traced asset is not referenced by the page` ); } for (const reference of manifest.policy?.forbiddenRuntimeReferences || []) { invariant( !runtimeSource.toLowerCase().includes(String(reference).toLowerCase()), `forbidden root/source/screenshot runtime reference: ${reference}` ); } invariant( !aggregateNamePattern.test(runtimeSource), 'runtime source contains an aggregate/root/page-screenshot reference' ); // Any literal local bitmap referenced by HTML/CSS/JS must resolve to the traced // layer inventory. Remote API media is dynamic and therefore is not a literal. for (const match of runtimeSource.matchAll( /["'(]\s*([^"')\s]+\.(?:webp|png|jpe?g|avif))(?:\?[^"')\s]*)?/gi )) { const literal = match[1]; if (/^(?:https?:|data:)/i.test(literal)) continue; invariant( expectedFiles.has(path.basename(literal)), `untraced local bitmap reference: ${literal}` ); } invariant( html.includes('../../common/theme.css'), 'common/theme.css must be loaded' ); invariant( commonTheme.includes('--hy-theme-primary') && commonTheme.includes('--hy-theme-bg') && commonTheme.includes('--hy-theme-surface'), 'common theme contract is missing required variables' ); invariant( html.includes('../../common/i18n.js') && html.includes('data-i18n'), 'common i18n runtime and bindings are required' ); invariant( html.includes('../../common/api.js'), 'common API runtime must be loaded' ); invariant( html.includes('data-hy-app-code="huwaa"'), 'Huwaa app identity is required' ); invariant( html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") >= 0 && html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") < html.indexOf('../../common/api.js'), 'Huwaa app_code must be set before common API initialization' ); invariant( commonAPI.includes('var weeklyStarAPI') && commonAPI.includes('weeklyStar: weeklyStarAPI'), 'common weekly-star API namespace is missing' ); for (const call of ['.current()', '.history(1)']) { invariant( js.includes(call), `weekly-star page is missing API call ${call}` ); } invariant( js.includes('HyAppAPI.weeklyStar'), 'page must consume HyAppAPI.weeklyStar' ); invariant( js.includes('data.top_entries') && !js.includes('.leaderboard('), 'page must consume current.top_entries without a duplicate leaderboard request' ); invariant( js.includes("'duration_ms'") && js.includes('/ DAY_MS'), 'reward validity must consume the gateway duration_ms contract' ); invariant( /\.get\(\s*['"]mock['"]\s*\)\s*===\s*['"]1['"]/.test(js), 'visual fixture must require explicit ?mock=1' ); invariant( /if\s*\(\s*useMock\s*\)\s*loadMock\(\)\s*;?\s*else\s*loadReal\(\)/.test(js), 'mock and production loading paths must be explicitly separated' ); invariant( js.includes('sample-gift.webp') && !html.includes('sample-gift.webp') && !css.includes('sample-gift.webp'), 'sample gift must remain a JS-gated mock fixture, not production markup/style' ); invariant( !/catch\s*\([^)]*\)\s*\{[\s\S]{0,1200}(?:loadMock|mockState)\s*\(/.test(js), 'API failures must not silently fall back to mock business data' ); const translationKeys = new Set(); for (const source of [html, js]) { for (const match of source.matchAll(/huwaa_weekly_star\.[a-z0-9_.-]+/gi)) { translationKeys.add(match[0]); } } invariant(translationKeys.size > 0, 'no Huwaa Weekly Star i18n keys found'); for (const { language, messages } of localeEntries) { for (const key of translationKeys) { invariant( Object.prototype.hasOwnProperty.call(messages, key) && typeof messages[key] === 'string' && messages[key].trim().length > 0, `${language}: missing or empty translation ${key}` ); } } console.log( `[week-star-huwaa-asset-audit] passed: ${assets.length} traced layers, hashes/dimensions/runtime references/theme/i18n/API/mock gating verified` );