#!/usr/bin/env node // tools/scripts/lint-asset-pipeline.mjs // [doc->REQ-AST-01] [impl->REQ-AST-01] // Source: 06-CONTEXT.md D-13/D-14/D-16 — drift guard for tools/asset-pipeline outputs. // Validates tools/asset-pipeline/output/pipeline-manifest.json schema + // cross-checks the on-disk atlas-mvp.png sha256 matches the manifest's // sha256_png field. Mirrors the S-06 lint contract: must() helper, // errors.length gate, ": OK" stdout marker. // // Env overrides (used by tests + CI): // PIPELINE_MANIFEST_PATH — defaults to tools/asset-pipeline/output/pipeline-manifest.json // STATIC_BUNDLE_DIR — defaults to apps/server/public // // Usage: node tools/scripts/lint-asset-pipeline.mjs // Exit: 0 clean, 1 violation. import { createHash } from 'node:crypto'; import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; const MANIFEST_PATH = process.env.PIPELINE_MANIFEST_PATH ?? resolve(process.cwd(), 'tools/asset-pipeline/output/pipeline-manifest.json'); const STATIC_DIR = process.env.STATIC_BUNDLE_DIR ?? resolve(process.cwd(), 'apps/server/public'); const errors = []; function must(cond, msg) { if (!cond) errors.push(msg); } if (!existsSync(MANIFEST_PATH)) { console.error(`lint-asset-pipeline: pipeline-manifest.json not found at ${MANIFEST_PATH}`); process.exit(1); } let manifest; try { manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')); } catch (e) { console.error(`lint-asset-pipeline: pipeline-manifest.json parse failed: ${e.message}`); process.exit(1); } // Schema: schema_version === 1 (locked Wave-1; promoted only via deliberate ADR) must( manifest.schema_version === 1, `schema_version must be 1; got ${JSON.stringify(manifest.schema_version)}`, ); must( manifest.sprites && typeof manifest.sprites === 'object' && !Array.isArray(manifest.sprites), 'sprites map required', ); must( manifest.atlases && typeof manifest.atlases === 'object' && !Array.isArray(manifest.atlases), 'atlases map required', ); must( manifest.atlases && manifest.atlases['atlas-mvp'], 'atlases.atlas-mvp required (MVP scope)', ); // Sprite keys lex-sorted (D-15 determinism) if (manifest.sprites) { const keys = Object.keys(manifest.sprites); const sorted = [...keys].sort(); must( JSON.stringify(keys) === JSON.stringify(sorted), 'pipeline-manifest.sprites keys must be lex-sorted', ); } // Atlas integrity — cross-check sha256_png against on-disk PNG when STATIC_DIR exists. if (manifest.atlases && manifest.atlases['atlas-mvp']) { const atlas = manifest.atlases['atlas-mvp']; must( typeof atlas.sha256_png === 'string' && /^[0-9a-f]{64}$/i.test(atlas.sha256_png), 'atlases.atlas-mvp.sha256_png must be 64-char lowercase hex', ); if (existsSync(STATIC_DIR)) { const pngRel = (atlas.png ?? '/atlas-mvp.png').replace(/^\//, ''); const pngPath = resolve(STATIC_DIR, pngRel); if (existsSync(pngPath)) { const bytes = readFileSync(pngPath); const sha = createHash('sha256').update(bytes).digest('hex'); must( sha === atlas.sha256_png, `atlas integrity: sha256 mismatch — manifest=${atlas.sha256_png}, on-disk=${sha}`, ); } else { // First-cold-build path: STATIC_DIR exists but PNG hasn't been emitted yet. // Lint stays soft here; CI re-runs after asset-pipeline:build. console.warn(`lint-asset-pipeline: atlas png not yet on disk at ${pngPath}; skipping integrity cross-check`); } } } if (errors.length) { for (const e of errors) console.error('lint-asset-pipeline:', e); process.exit(1); } console.log('lint-asset-pipeline: OK');