#!/usr/bin/env node // tasteskill / lint — static scan of source files for the tells this skill bans. // Catches them before a render, plus the ones a render cannot see (copy habits, motion values). // // node lint.mjs [files or directories ...] [--app] [--json] // // Exit code 1 when any FAIL is found. Zero dependencies. import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, extname, relative, resolve } from 'node:path'; const args = process.argv.slice(2); const asJson = args.includes('--json'); // App screens (references/app-screens.md) keep quiet table and panel lines, faint shadows, data labels // and their own typefaces; a sheet dealt with draw.mjs --app switches this on by itself. let appMode = args.includes('--app'); try { if (JSON.parse(readFileSync('.taste-skill/direction.json', 'utf8')).kind === 'app') appMode = true; } catch {} const APP_LEVEL = { hairline: null, 'outlined-box': null, 'default-font': null, numbering: 'WARN', mono: 'WARN', shadow: 'WARN', eyebrow: 'WARN', 'cta-copy': null, 'h-screen': null }; const roots = args.filter((a) => !a.startsWith('--')); if (!roots.length) roots.push('.'); const EXT = new Set(['.html', '.htm', '.css', '.scss', '.less', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.astro', '.mdx']); const SKIP = new Set(['node_modules', '.git', '.next', '.nuxt', '.svelte-kit', 'dist', 'build', 'out', '.vercel', '.taste-skill', 'coverage', '.turbo', 'vendor']); const files = []; const tooBig = []; const walk = (p) => { let st; try { st = statSync(p); } catch { return; } if (st.isDirectory()) { for (const f of readdirSync(p)) if (!SKIP.has(f) && !f.startsWith('.')) walk(join(p, f)); } else if (EXT.has(extname(p)) && !/\.min\.|\.d\.ts$|(^|[\\/])(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|[^\\/]+\.lock)$/.test(p)) { if (st.size < 4000000) files.push(p); else tooBig.push(p); } }; for (const r of roots) { try { statSync(resolve(r)); } catch { console.error(`Not found: ${r}`); process.exit(2); } walk(resolve(r)); } if (!files.length) { console.error(`No source files (${[...EXT].join(' ')}) under: ${roots.join(', ')}`); process.exit(2); } if (tooBig.length) console.error(`Skipped (over 4 MB, check by hand): ${tooBig.join(', ')}`); // Each rule: [level, id, regex, message]. Tested per line. `code` lines (inside
/) skip the mono rule.
const R = [
  ['FAIL', 'mono', /\bfont-mono\b|font-family\s*:[^;{}]*(mono\b|monospace|Courier|Consolas|Menlo|Monaco)|(JetBrains|Geist|IBM\+?Plex|Space|DM|Roboto|Fragment|Azeret|Red\+?Hat|Martian|Commit|Departure|Fira)[+_ ]?Mono|Geist_Mono|GeistMono|Fira[+ ]Code|ui-monospace/i, 'Monospace type. Only literal code may be monospace.'],
  ['FAIL', 'gradient', /(linear|radial|conic)-gradient\(|\bbg-(gradient|linear|radial|conic)-|\b(from|via)-(?!\[?0)[a-z]+-\d{2,3}\b/, 'Gradient fill. Use a flat colour or a real image (a neutral scrim over a photo is the one exception).'],
  ['FAIL', 'gradient-text', /bg-clip-text|background-clip\s*:\s*text|text-transparent/, 'Gradient / clipped text. Use solid colour.'],
  ['FAIL', 'shadow', /box-shadow\s*:\s*(?!none|0 0 #0000|var\(--tw)|\bshadow(-(sm|md|lg|xl|2xl|inner|\[[^\]]+\]))?(?=["'\s`])(?\s*[(\[]?0[1-9][)\]]?\.?\s*<|["'`]\s*0[1-9]\s*["'`]|decimal-leading-zero|>\s*(Step|Phase|No\.?|Chapter)\s*0?\d\s*\s*(Learn more|Get started|Click here|Read more|Discover more|Explore now|Submit)\s* {
    // track the CSS selector we are inside, so rules scoped to [data-ui] (interface fragments) keep their own lines
    if (line.includes('{')) { selector = pending + line.slice(0, line.indexOf('{')); pending = ''; } else if (!line.includes('}')) pending = (pending + ' ' + line).slice(-300);
    const inUi = /data-ui/.test(selector) || /data-ui/.test(line);
    if (line.includes('}') && !line.includes('{')) { selector = ''; pending = ''; }
    if (/<(pre|code)\b/i.test(line)) inCode = true;
    const codeLine = inCode || /<(pre|code|kbd|samp)\b/i.test(line);
    if (/<\/(pre|code)>/i.test(line)) inCode = false;
    if (line.length > 2000) return; // minified or data
    if (/taste-skill-ignore/.test(line)) return;
    if (!/^\s*(\/\/|\/\*|\*|/g, '').match(/—/g) || []).length; // comments are not copy
    for (const [level, id, re, message] of R) {
      if (!level) continue;
      if (id === 'mono' && (codeLine || /(^|[\s,>+~(])(pre|code|kbd|samp)\b|code/i.test(selector))) continue; // real code may be monospace
      if ((id === 'hairline' || id === 'outlined-box') && /(^|[\s,>+~(])(table|thead|tbody|tfoot|tr|td|th|input|textarea|select|fieldset)\b/i.test(selector)) continue; // real tables and form fields keep their lines
      if (inUi && (id === 'hairline' || id === 'outlined-box' || id === 'shadow')) continue;
      if (id === 'hairline' && /sr-only|visually-hidden|clip(-path)?\s*:|outline/i.test(line + selector)) continue; // screen-reader helpers and focus outlines are not rules
      if ((id === 'copy' || id === 'cta-copy' || id === 'emoji') && /^\s*(\/\/|\/\*|\*|import |@import|