#!/usr/bin/env node // #region AuditFrontendStyle [C:3] [TYPE Module] [SEMANTICS audit, design-tokens, guardrail, script, quality-gate] // @BRIEF Audits frontend source files for design token violations (raw Tailwind colors), manual buttons, oversized pages, and Model-first violations. // @LAYER Infra // @RATIONALE Before PR 3-4 page decomposition runs, we need a CI-checkable guardrail that prevents new drift from entering the codebase. This script enforces the semantic-token-only rule established in PR 0+1. // @USAGE node scripts/audit-frontend-style.mjs [--fix] [--verbose] import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; import { resolve, relative, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const VERBOSE = process.argv.includes('--verbose'); // ── File discovery (no external deps) ────────────────────────── function walk(dir, pattern, maxDepth = 10) { if (maxDepth <= 0) return []; const results = []; try { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'build') { results.push(...walk(full, pattern, maxDepth - 1)); } else if (entry.isFile() && pattern.test(entry.name)) { results.push(full); } } } catch { /* skip */ } return results; } const PAGE_FILES = walk(join(ROOT, 'src', 'routes'), /\+page\.svelte$/); const COMPONENT_FILES = walk(join(ROOT, 'src', 'lib', 'components'), /\.svelte$/); const LEGACY_COMPONENTS = walk(join(ROOT, 'src', 'components'), /\.svelte$/); // ── Rules ────────────────────────────────────────────────────── const RAW_COLOR_PATTERNS = [ // Raw tailwind color classes (banned in routes/** and lib/ui/**) { regex: /\bbg-blue-\d+\b/, message: 'bg-blue-* → bg-primary' }, { regex: /\bbg-indigo-\d+\b/, message: 'bg-indigo-* → bg-primary or bg-info' }, { regex: /\bbg-red-\d+\b/, message: 'bg-red-* → bg-destructive or bg-destructive-light' }, { regex: /\bbg-green-\d+\b/, message: 'bg-green-* → bg-success or bg-success-light' }, { regex: /\bbg-amber-\d+\b/, message: 'bg-amber-* → bg-warning or bg-warning-light' }, { regex: /\bbg-emerald-\d+\b/, message: 'bg-emerald-* → bg-success variants' }, { regex: /\bbg-violet-\d+\b/, message: 'bg-violet-* → bg-primary-light or bg-info-light' }, { regex: /\bbg-gray-\d+\b/, message: 'bg-gray-* → bg-surface-muted or bg-surface-card' }, { regex: /\bbg-slate-\d+\b/, message: 'bg-slate-* → bg-surface-* tokens' }, { regex: /\bbg-white\b(?!.*\bbg-surface-card\b)/, message: 'bg-white → bg-surface-card (use semantic token)' }, { regex: /\btext-blue-\d+\b/, message: 'text-blue-* → text-primary' }, { regex: /\btext-indigo-\d+\b/, message: 'text-indigo-* → text-primary or text-info' }, { regex: /\btext-red-\d+\b/, message: 'text-red-* → text-destructive' }, { regex: /\btext-green-\d+\b/, message: 'text-green-* → text-success' }, { regex: /\btext-amber-\d+\b/, message: 'text-amber-* → text-warning' }, { regex: /\btext-emerald-\d+\b/, message: 'text-emerald-* → text-success' }, { regex: /\btext-gray-\d+\b/, message: 'text-gray-* → text-text or text-text-muted' }, { regex: /\btext-slate-\d+\b/, message: 'text-slate-* → text-* tokens' }, { regex: /\bborder-blue-\d+\b/, message: 'border-blue-* → border-primary-ring' }, { regex: /\bborder-indigo-\d+\b/, message: 'border-indigo-* → border-primary-ring or border-info-ring' }, { regex: /\bborder-red-\d+\b/, message: 'border-red-* → border-destructive-ring' }, { regex: /\bborder-green-\d+\b/, message: 'border-green-* → border-success' }, { regex: /\bborder-amber-\d+\b/, message: 'border-amber-* → border-warning' }, { regex: /\bborder-gray-\d+\b/, message: 'border-gray-* → border-border or border-border-strong' }, { regex: /\bborder-slate-\d+\b/, message: 'border-slate-* → border-* tokens' }, { regex: /\bring-(blue|indigo|red|green|amber|emerald|violet|gray|slate)-\d+\b/, message: 'ring-*-* → use semantic token ring (primary-ring, destructive-ring, etc.)' }, ]; const GRADIENT_PATTERN = /\bbg-gradient-to-[brtl]{1,2}\s+from-\S+\s+via-\S+\s+to-\S+\b/; const MANUAL_BUTTON_REGEX = / 400) { violations.push({ file: relative(ROOT, fullPath), line: 0, rule: 'page-too-large', message: `Page has ${lines.length} lines (max 400). Decompose with Model + components.`, snippet: '', }); } // Check 4: Count $state atoms vs Model import if (relPath.startsWith('src/routes/')) { const stateCount = (source.match(/\$\state\(/g) || []).length; const hasModelImport = /import\s+\{.*\}\s+from\s+['"].*Model\.svelte\.ts['"]/.test(source); const hasModelNew = /\bnew\s+\w+Model\(\)/.test(source); if (stateCount > 5 && !hasModelImport && !hasModelNew) { warnings.push({ file: relative(ROOT, fullPath), line: 0, rule: 'no-screen-model', message: `Page has ${stateCount} $state atoms but no Model import. Consider creating a Screen Model.`, snippet: '', }); } } // Check 5: Manual