la-rose/scripts/verify-images.ts

99 lines
4.3 KiB
TypeScript

import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { imagePath, readImages } from '../src/db/images';
/**
* File-side acceptance for the product gallery (docs/PLAN.md §11, card #64):
* • db/seed/images.tsv covers 93 products, each with a position = 1 (main photo)
* • every derived url exists on disk as a non-empty file, and file count = row count
* • generated db/seed/0001_seed.guardsafe.sql carries every product_image url
* and no `ON CONFLICT` (the /db-forms guard rejects it)
*
* Wired into `pnpm test` via scripts/verify.ts; can also be run directly.
*/
const PRODUCTS_DIR = fileURLToPath(new URL('../public/images/products', import.meta.url));
const GUARDSAFE_SQL = fileURLToPath(new URL('../db/seed/0001_seed.guardsafe.sql', import.meta.url));
export interface ImageArtifactReport {
rows: number;
products: number;
productsWithMain: number;
files: number;
bytes: number;
missing: string[];
extra: string[];
guardsafeHasAllRows: boolean;
guardsafeHasOnConflict: boolean;
}
function listJpgFiles(dir: string, prefix = ''): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...listJpgFiles(join(dir, entry.name), relative));
else if (entry.name.endsWith('.jpg')) out.push(relative);
}
return out;
}
export function verifyImageArtifacts(): ImageArtifactReport {
const rows = readImages();
const products = new Set(rows.map((row) => row.slug));
const productsWithMain = new Set(rows.filter((row) => row.position === 1).map((row) => row.slug));
const expected = new Set(rows.map((row) => imagePath(row.slug, row.position)));
const missing: string[] = [];
let bytes = 0;
for (const row of rows) {
const relative = `${row.slug}/${row.position}.jpg`;
const info = statSync(join(PRODUCTS_DIR, relative), { throwIfNoEntry: false });
if (!info || info.size === 0) missing.push(imagePath(row.slug, row.position));
else bytes += info.size;
}
const files = listJpgFiles(PRODUCTS_DIR);
const extra = files
.map((file) => `/images/products/${file}`)
.filter((url) => !expected.has(url))
.sort();
const guardsafe = readFileSync(GUARDSAFE_SQL, 'utf8');
return {
rows: rows.length,
products: products.size,
productsWithMain: productsWithMain.size,
files: files.length,
bytes,
missing,
extra,
guardsafeHasAllRows: [...expected].every((url) => guardsafe.includes(`'${url}'`)),
guardsafeHasOnConflict: /ON\s+CONFLICT/i.test(guardsafe),
};
}
if ((process.argv[1] ?? '').endsWith('verify-images.ts')) {
const report = verifyImageArtifacts();
const problems: string[] = [];
if (report.products !== 93) problems.push(`products = ${report.products}, expected 93`);
if (report.productsWithMain !== 93) problems.push(`products with position 1 = ${report.productsWithMain}, expected 93`);
if (report.missing.length > 0) problems.push(`missing files = ${report.missing.length} (${report.missing.slice(0, 5).join(', ')})`);
if (report.rows !== report.files) problems.push(`db/seed/images.tsv rows = ${report.rows}, files = ${report.files}`);
if (report.extra.length > 0) problems.push(`orphan files = ${report.extra.length} (${report.extra.slice(0, 5).join(', ')})`);
if (!report.guardsafeHasAllRows) problems.push('0001_seed.guardsafe.sql is missing product_image rows');
if (report.guardsafeHasOnConflict) problems.push('0001_seed.guardsafe.sql contains ON CONFLICT');
console.log('verify-images: OK');
console.log(` products = ${report.products}/93 (position 1: ${report.productsWithMain}/93)`);
console.log(` image rows = ${report.rows}`);
console.log(` files on disk = ${report.files} (${(report.bytes / (1024 * 1024)).toFixed(1)} MiB)`);
console.log(` missing files = ${report.missing.length}`);
console.log(` orphan files = ${report.extra.length}`);
console.log(` guard-safe: rows = ${report.guardsafeHasAllRows}, on-conflict = ${report.guardsafeHasOnConflict}`);
if (problems.length > 0) {
console.error(`verify-images: FAILED — ${problems.join('; ')}`);
process.exitCode = 1;
}
}