706 lines
22 KiB
JavaScript
706 lines
22 KiB
JavaScript
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`
|
|
);
|