/** * ============================================================ * SVGarden — https://www.svgarden.dev * Author : Simon-Pierre Boucher * Contact: contact@spboucher.ai * File : scripts/validate-snippets.mjs * Desc : CI gate — validates headers, frontmatter, scoping and self-containment * ============================================================ */ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { SNIPPETS_DIR, CATEGORIES, DIFFICULTIES, parseSnippetSource, } from '../src/lib/snippets.mjs'; // Raised from 120 → 150 for the phase-2 "next-level" batch (user directive // 2026-08-10, overrides CLAUDE.md §5's ~120 — conflict flagged in that session). const MAX_LINES = 150; const errors = []; const fail = (msg) => errors.push(msg); const files = []; for (const dirent of await readdir(SNIPPETS_DIR, { withFileTypes: true })) { if (!dirent.isDirectory()) continue; for (const f of await readdir(path.join(SNIPPETS_DIR, dirent.name))) { if (f.endsWith('.html')) files.push(path.join(dirent.name, f)); } } files.sort(); if (files.length === 0) { console.error('validate-snippets: no snippet files found under snippets/'); process.exit(1); } const slugs = new Set(); for (const rel of files) { const relPath = path.posix.join('snippets', ...rel.split(path.sep)); const source = await readFile(path.join(SNIPPETS_DIR, rel), 'utf8'); let s; try { s = parseSnippetSource(source, relPath); } catch (err) { fail(err.message); continue; } // Slug ↔ filename ↔ directory coherence const base = path.basename(rel, '.html'); const dir = rel.split(path.sep)[0]; if (s.slug !== base) fail(`${relPath}: slug "${s.slug}" ≠ filename "${base}"`); if (s.category !== dir) fail(`${relPath}: category "${s.category}" ≠ directory "${dir}"`); if (!CATEGORIES.includes(s.category)) fail(`${relPath}: unknown category "${s.category}"`); if (!DIFFICULTIES.includes(s.difficulty)) fail(`${relPath}: difficulty must be one of ${DIFFICULTIES.join('/')}`); if (slugs.has(s.slug)) fail(`${relPath}: duplicate slug "${s.slug}"`); slugs.add(s.slug); // Header must reference its own path const headerFile = source.match(/File {3}: (.+)/)?.[1]?.trim(); if (headerFile !== relPath) fail(`${relPath}: header File line says "${headerFile}"`); // Size limit — elegance over bloat const codeLines = s.code.split('\n').length; if (codeLines > MAX_LINES) fail(`${relPath}: snippet code is ${codeLines} lines (max ${MAX_LINES})`); // Self-containment: no external assets of any kind if (/\b(?:src|href)\s*=\s*["']https?:/i.test(s.code)) fail(`${relPath}: external src/href — snippets must be self-contained`); if (/url\(\s*["']?https?:/i.test(s.code)) fail(`${relPath}: external url() — snippets must be self-contained`); if (/@import/i.test(s.code)) fail(`${relPath}: @import is forbidden`); if (/ is forbidden inside snippets`); // Scoping: every class used in markup or CSS must be sg- prefixed for (const m of s.code.matchAll(/class\s*=\s*["']([^"']+)["']/g)) { for (const cls of m[1].split(/\s+/).filter(Boolean)) { if (!cls.startsWith('sg-')) fail(`${relPath}: class "${cls}" is not sg- prefixed`); } } const styleBlocks = [...s.code.matchAll(/