- DashboardHubModel.svelte.ts: Screen Model for dashboard listing hub with filters, pagination, and selection state. - DashboardRow.svelte: Individual dashboard card with Git/health badges. - ColumnFilterPopover.svelte: Column-based filter dropdown for dashboards. - dashboard-helpers.ts: Shared dashboard utility functions. - EmptyState.svelte: Reusable empty state component with icon and CTA. - validation route scaffold for per-dashboard validation views.
225 lines
9.9 KiB
JavaScript
225 lines
9.9 KiB
JavaScript
#!/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 = /<button\s+(?!.*\bdisabled\b)/; // false positives possible — manual review
|
|
|
|
// ── File scopes ────────────────────────────────────────────────
|
|
|
|
// (populated above via walk())
|
|
// PAGE_FILES, COMPONENT_FILES, LEGACY_COMPONENTS
|
|
|
|
// ── Checks ─────────────────────────────────────────────────────
|
|
|
|
const violations = [];
|
|
const warnings = [];
|
|
|
|
for (const fullPath of [...PAGE_FILES, ...COMPONENT_FILES]) {
|
|
const relPath = relative(ROOT, fullPath);
|
|
const source = readFileSync(fullPath, 'utf-8');
|
|
const lines = source.split('\n');
|
|
|
|
// Check 1: Raw color patterns
|
|
for (const { regex, message } of RAW_COLOR_PATTERNS) {
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (regex.test(line)) {
|
|
violations.push({
|
|
file: relative(ROOT, fullPath),
|
|
line: i + 1,
|
|
rule: 'raw-color',
|
|
message,
|
|
snippet: line.trim().substring(0, 120),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 2: Gradient patterns in page files
|
|
if (relPath.startsWith('src/routes/') && GRADIENT_PATTERN.test(source)) {
|
|
violations.push({
|
|
file: relative(ROOT, fullPath),
|
|
line: 0,
|
|
rule: 'no-gradient',
|
|
message: 'bg-gradient-* → use semantic surface tokens',
|
|
snippet: '[gradient found]',
|
|
});
|
|
}
|
|
|
|
// Check 3: Page too large
|
|
if (relPath.startsWith('src/routes/') && lines.length > 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 <button> in page files (warning only — false positives for step wizards etc.)
|
|
if (relPath.startsWith('src/routes/')) {
|
|
const buttonCount = (source.match(/<button\b/g) || []).length;
|
|
const hasButtonImport = /import\s+\{.*Button.*\}\s+from\s+['"]\$lib\/ui['"]/.test(source);
|
|
if (buttonCount > 0 && !hasButtonImport) {
|
|
warnings.push({
|
|
file: relative(ROOT, fullPath),
|
|
line: 0,
|
|
rule: 'manual-button',
|
|
message: `Page has ${buttonCount} raw <button> elements but no <Button> import from $lib/ui.`,
|
|
snippet: '',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 6: New files in legacy components/ directory (frozen zone)
|
|
if (LEGACY_COMPONENTS.length > 0) {
|
|
console.log(`\n⚠ LEGACY FROZEN: ${LEGACY_COMPONENTS.length} files remain in src/components/. No new files should be added.`);
|
|
}
|
|
|
|
// ── Report ─────────────────────────────────────────────────────
|
|
|
|
const errorCount = violations.filter(v => v.rule !== 'page-too-large' && v.rule !== 'no-gradient').length;
|
|
const oversizedPages = violations.filter(v => v.rule === 'page-too-large');
|
|
const gradientPages = violations.filter(v => v.rule === 'no-gradient');
|
|
|
|
console.log('\n═══ FRONTEND STYLE AUDIT ═══\n');
|
|
|
|
if (violations.length === 0 && warnings.length === 0) {
|
|
console.log('✅ All checks passed. No violations found.\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
const violationFiles = new Set(violations.map(v => v.file));
|
|
const warningFiles = new Set(warnings.map(v => v.file));
|
|
|
|
console.log(`🔴 VIOLATIONS: ${errorCount} color-token + ${oversizedPages.length} oversized-pages`);
|
|
console.log(`🟡 WARNINGS: ${warnings.length} (model-first, manual-button)`);
|
|
console.log(`📁 Files: ${violationFiles.size + warningFiles.size} total\n`);
|
|
|
|
if (VERBOSE) {
|
|
for (const v of violations) {
|
|
console.log(` 🔴 ${v.file}:${v.line} [${v.rule}] ${v.message}`);
|
|
if (v.snippet) console.log(` → ${v.snippet}`);
|
|
}
|
|
for (const w of warnings) {
|
|
console.log(` 🟡 ${w.file} [${w.rule}] ${w.message}`);
|
|
}
|
|
} else {
|
|
// Summary mode
|
|
const byFile = {};
|
|
for (const v of violations) {
|
|
byFile[v.file] = (byFile[v.file] || 0) + 1;
|
|
}
|
|
for (const [file, count] of Object.entries(byFile).sort(([,a], [,b]) => b - a)) {
|
|
console.log(` ${count.toString().padEnd(4)} violations ${file}`);
|
|
}
|
|
}
|
|
|
|
// Oversized page summary
|
|
if (oversizedPages.length > 0) {
|
|
console.log(`\n📄 OVERSIZED PAGES (>400 lines):`);
|
|
for (const p of oversizedPages) {
|
|
console.log(` ${p.file} — ${p.message}`);
|
|
}
|
|
}
|
|
|
|
console.log('');
|
|
if (errorCount > 0) {
|
|
console.log(`❌ ${errorCount} design-token violations found. Run with --verbose for details.`);
|
|
} else {
|
|
console.log('✅ No design-token violations.');
|
|
}
|
|
if (warnings.length > 0) {
|
|
console.log(`⚠ ${warnings.length} warnings (model-first / manual-button).`);
|
|
}
|
|
|
|
// Exit with error code if violations found (for CI)
|
|
process.exit(errorCount > 0 ? 1 : 0);
|
|
// #endregion AuditFrontendStyle
|