From ea9f7e72e72147f94aea6ab95eaaa70acab2acfa Mon Sep 17 00:00:00 2001 From: Liam Broza Date: Sat, 4 Jul 2026 14:09:43 -0700 Subject: [PATCH] chore: extend design-canon lint gate (undefined-var + @theme-completeness checks) Co-Authored-By: Claude Opus 4.8 --- scripts/lint-canon.mjs | 198 +++++++++++++++-------------------------- 1 file changed, 74 insertions(+), 124 deletions(-) diff --git a/scripts/lint-canon.mjs b/scripts/lint-canon.mjs index 4838836..5f54b79 100644 --- a/scripts/lint-canon.mjs +++ b/scripts/lint-canon.mjs @@ -1,147 +1,97 @@ #!/usr/bin/env node -// CI design-canon lint gate — fails if any `--primary` CSS custom property is not the Companion -// Intelligence teal (light #0f717a / dark #abd4d8), in ANY notation (hex, rgb, hsl, bare hsl-triplet, -// oklch with % or 0–1 lightness, oklab). Warns (non-fatal) on hardcoded hex in component source. -// Zero dependencies (Node built-ins only). Usage: node scripts/lint-canon.mjs [targetDir] -// -// Canon source of truth: @companionintelligence/tokens (CI-Common) / CI-Hub globals.css. +// CI design-canon lint gate (v3). Zero deps. Usage: node scripts/lint-canon.mjs [targetDir] +// FAIL: any `--primary` custom property that is not CI teal (#0f717a / #abd4d8), any notation. +// WARN (non-blocking): CI-token `var(--X)` used but never defined (partial-swap bug); +// enriched token used as a Tailwind utility but not mapped in `@theme` (v4 fallback-to-default bug); +// hardcoded hex in component source. import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, extname, relative } from 'node:path'; const TARGET = process.argv[2] || '.'; -const CANON = [ - { name: 'teal-light', rgb: [15, 113, 122] }, // #0f717a - { name: 'teal-dark', rgb: [171, 212, 216] }, // #abd4d8 -]; -const TOLERANCE = 14; // euclidean sRGB distance; exact canon = 0, off-brand navy/mint >> 14 -const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.turbo', 'coverage', 'vendor', '.cache', 'target', '.output', '.vercel']); +const CANON = [{ rgb: [15, 113, 122] }, { rgb: [171, 212, 216] }]; // #0f717a / #abd4d8 +const TOL = 14; +const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.turbo', 'coverage', 'vendor', '.cache', 'target', '.output', '.vercel']); +// CI design-token namespace the transition manages — undefined uses of these are real bugs. +const CI_TOKENS = /^--(primary|secondary|muted|accent|destructive|success|warning|info|border|input|ring|card|popover|background|foreground|sidebar|chart-\d|teal-\d{2,3}|aqua|aqua-light|mint|teal-accent|teal-mid|gradient-\w+|shadow-(sm|md|lg)|radius-\w+)(-foreground)?$/; +// standard/framework vars that are legitimately defined elsewhere (never flag as undefined) +const FRAMEWORK_VAR = /^--(tw-|radix-|swiper-|leaflet-|vp-|shiki|nextra|fd-|chakra-|mantine-|ck-)/; -// ---------- color parsing -> sRGB [0..255] or null ---------- const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); -const srgbGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); - -function oklchToRgb(L, C, H) { - const h = (H * Math.PI) / 180; - const a = C * Math.cos(h), b = C * Math.sin(h); - const l_ = L + 0.3963377774 * a + 0.2158037573 * b; - const m_ = L - 0.1055613458 * a - 0.0638541728 * b; - const s_ = L - 0.0894841775 * a - 1.291485548 * b; - const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3; - const rl = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; - const gl = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; - const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; - return [rl, gl, bl].map((v) => Math.round(clamp(srgbGamma(v), 0, 1) * 255)); -} -function oklabToRgb(L, a, b) { return oklchToRgb(L, Math.hypot(a, b), (Math.atan2(b, a) * 180) / Math.PI); } -function hslToRgb(H, S, L) { - H = ((H % 360) + 360) % 360; S /= 100; L /= 100; - const c = (1 - Math.abs(2 * L - 1)) * S, x = c * (1 - Math.abs(((H / 60) % 2) - 1)), m = L - c / 2; - let r = 0, g = 0, b = 0; - if (H < 60) [r, g, b] = [c, x, 0]; else if (H < 120) [r, g, b] = [x, c, 0]; - else if (H < 180) [r, g, b] = [0, c, x]; else if (H < 240) [r, g, b] = [0, x, c]; - else if (H < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; - return [r, g, b].map((v) => Math.round((v + m) * 255)); -} +const srgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); +function oklch(L, C, H) { const h = H * Math.PI / 180, a = C * Math.cos(h), b = C * Math.sin(h); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3, m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3, s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s].map((v) => Math.round(clamp(srgb(v), 0, 1) * 255)); } +function hsl(H, S, L) { H = ((H % 360) + 360) % 360; S /= 100; L /= 100; const c = (1 - Math.abs(2 * L - 1)) * S, x = c * (1 - Math.abs((H / 60) % 2 - 1)), m = L - c / 2; let r, g, b; if (H < 60)[r, g, b] = [c, x, 0]; else if (H < 120)[r, g, b] = [x, c, 0]; else if (H < 180)[r, g, b] = [0, c, x]; else if (H < 240)[r, g, b] = [0, x, c]; else if (H < 300)[r, g, b] = [x, 0, c]; else[r, g, b] = [c, 0, x]; return [r, g, b].map((v) => Math.round((v + m) * 255)); } const num = (s) => parseFloat(s); -// lightness token: "50.2%" -> 50.2 ; "0.502" -> 50.2 ; "50" -> 50 -const lPct = (s) => { s = s.trim(); if (s.endsWith('%')) return num(s); const n = num(s); return n <= 1 ? n * 100 : n; }; -const lFrac = (s) => lPct(s) / 100; - -function parseColor(raw) { - let v = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/!important/i, '').trim(); - if (/var\(/i.test(v)) return null; // unresolvable reference — skip - let m = v.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); - if (m) { - let h = m[1]; - if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join(''); - return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; - } - m = v.match(/^rgba?\(([^)]+)\)$/i); - if (m) { const p = m[1].split(/[,\s/]+/).filter(Boolean); return [num(p[0]), num(p[1]), num(p[2])].map((x) => Math.round(x)); } - m = v.match(/^hsla?\(([^)]+)\)$/i); - if (m) { const p = m[1].split(/[,\s/]+/).filter(Boolean); return hslToRgb(num(p[0]), num(p[1]), num(p[2])); } - m = v.match(/^oklch\(([^)]+)\)$/i); - if (m) { const p = m[1].split(/[\s/]+/).filter(Boolean); return oklchToRgb(lFrac(p[0]), num(p[1]), num(p[2])); } - m = v.match(/^oklab\(([^)]+)\)$/i); - if (m) { const p = m[1].split(/[\s/]+/).filter(Boolean); return oklabToRgb(lFrac(p[0]), num(p[1]), num(p[2])); } - // bare shadcn hsl triplet: "185 78.1% 26.9%" (H S% L%) - m = v.match(/^(-?\d*\.?\d+)\s+(-?\d*\.?\d+)%\s+(-?\d*\.?\d+)%$/); - if (m) return hslToRgb(num(m[1]), num(m[2]), num(m[3])); - return null; -} +const lFrac = (s) => { s = s.trim(); const n = num(s); return (s.endsWith('%') ? n : (n <= 1 ? n * 100 : n)) / 100; }; +function parseColor(raw) { let v = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/!important/i, '').trim(); if (/var\(/i.test(v)) return null; let m; + if ((m = v.match(/^#([0-9a-f]{3,8})$/i))) { let h = m[1]; if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join(''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } + if ((m = v.match(/^rgba?\(([^)]+)\)$/i))) { const p = m[1].split(/[,\s/]+/).filter(Boolean); return [num(p[0]), num(p[1]), num(p[2])].map(Math.round); } + if ((m = v.match(/^hsla?\(([^)]+)\)$/i))) { const p = m[1].split(/[,\s/]+/).filter(Boolean); return hsl(num(p[0]), num(p[1]), num(p[2])); } + if ((m = v.match(/^oklch\(([^)]+)\)$/i))) { const p = m[1].split(/[\s/]+/).filter(Boolean); return oklch(lFrac(p[0]), num(p[1]), num(p[2])); } + if ((m = v.match(/^(-?\d*\.?\d+)\s+(-?\d*\.?\d+)%\s+(-?\d*\.?\d+)%$/))) return hsl(num(m[1]), num(m[2]), num(m[3])); return null; } const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); -const isCanonTeal = (rgb) => CANON.some((c) => dist(rgb, c.rgb) <= TOLERANCE); +const isTeal = (rgb) => CANON.some((c) => dist(rgb, c.rgb) <= TOL); -// ---------- file walking ---------- -function walk(dir, out = []) { - let entries; - try { entries = readdirSync(dir); } catch { return out; } - for (const e of entries) { - const p = join(dir, e); - let st; try { st = statSync(p); } catch { continue; } - if (st.isDirectory()) { if (!IGNORE_DIRS.has(e)) walk(p, out); } - else out.push(p); - } - return out; -} -const cssText = (file, ext) => { - const raw = readFileSync(file, 'utf8'); - if (['.html', '.htm', '.vue', '.svelte', '.astro'].includes(ext)) { - return [...raw.matchAll(/]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join('\n'); - } - return raw; -}; -const lineOf = (text, idx) => text.slice(0, idx).split('\n').length; - -// ---------- scan ---------- +function walk(d, o = []) { let e; try { e = readdirSync(d); } catch { return o; } for (const n of e) { const p = join(d, n); let st; try { st = statSync(p); } catch { continue; } if (st.isDirectory()) { if (!IGNORE.has(n)) walk(p, o); } else o.push(p); } return o; } const CSS_EXT = new Set(['.css', '.scss', '.sass', '.less', '.html', '.htm', '.vue', '.svelte', '.astro']); const COMP_EXT = new Set(['.tsx', '.jsx', '.ts', '.js', '.vue', '.svelte', '.astro', '.html', '.htm']); -const PRIMARY_RE = /(?:^|[^\w-])--primary\s*:\s*([^;}\n]+)/g; -const HARDHEX_RE = /(?:bg-\[|text-\[|border-\[|from-\[|to-\[|fill=["']?|stroke=["']?|(?:background|color|fill|stroke)\s*[:=]\s*["']?)#([0-9a-fA-F]{3,8})\b/g; +const cssText = (f, x) => { const raw = readFileSync(f, 'utf8'); return ['.html', '.htm', '.vue', '.svelte', '.astro'].includes(x) ? [...raw.matchAll(/]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join('\n') : raw; }; +const lineOf = (t, i) => t.slice(0, i).split('\n').length; const files = statSync(TARGET).isDirectory() ? walk(TARGET) : [TARGET]; -const fails = []; -const warns = []; -let cssScanned = 0, compScanned = 0; +const fails = [], warns = []; +const defined = new Set(); const themeMapped = new Set(); const uses = []; // {name,rel,line} +const utilUse = new Map(); // enriched utility token -> [rel:line] +let cssN = 0, compN = 0; + +const PRIMARY = /(?:^|[^\w-])--primary\s*:\s*([^;}\n]+)/g; +const DEFRE = /(?:^|[^\w-])(--[a-z0-9-]+)\s*:/gi; +const VARRE = /var\(\s*(--[a-z0-9-]+)/gi; +const THEME = /@theme[^{]*\{([\s\S]*?)\}/gi; +const HEX = /(?:bg-\[|text-\[|border-\[|from-\[|to-\[|fill=["']?|stroke=["']?|(?:background|color|fill|stroke)\s*[:=]\s*["']?)#([0-9a-fA-F]{3,8})\b/g; +// enriched Tailwind utilities whose token must be in @theme (else v4 falls back to default) +const UTIL = /\b(?:bg|text|border|ring|from|to|fill|stroke)-(teal-(?:50|100|200|300|400|500|600|700|800|900|950)|success|warning|info|aqua|mint)\b|\b(shadow-(?:sm|md|lg))\b/g; for (const f of files) { - const ext = extname(f).toLowerCase(); - const rel = relative(process.cwd(), f) || f; - if (CSS_EXT.has(ext)) { - cssScanned++; - const text = cssText(f, ext); - for (const m of text.matchAll(PRIMARY_RE)) { - const val = m[1].trim(); - const rgb = parseColor(val); - if (rgb === null) continue; - if (!isCanonTeal(rgb)) fails.push({ rel, line: lineOf(text, m.index), val, rgb }); - } + const x = extname(f).toLowerCase(); const rel = relative(process.cwd(), f) || f; + if (CSS_EXT.has(x)) { cssN++; const t = cssText(f, x); + for (const m of t.matchAll(PRIMARY)) { const rgb = parseColor(m[1].trim()); if (rgb && !isTeal(rgb)) fails.push({ rel, line: lineOf(t, m.index), val: m[1].trim(), rgb }); } + for (const m of t.matchAll(DEFRE)) defined.add(m[1].toLowerCase()); + for (const m of t.matchAll(VARRE)) uses.push({ name: m[1].toLowerCase(), rel, line: lineOf(t, m.index) }); + for (const th of t.matchAll(THEME)) for (const dm of th[1].matchAll(DEFRE)) themeMapped.add(dm[1].toLowerCase()); } - if (COMP_EXT.has(ext)) { - compScanned++; - const raw = readFileSync(f, 'utf8'); - for (const m of raw.matchAll(HARDHEX_RE)) warns.push({ rel, line: lineOf(raw, m.index), hex: '#' + m[1] }); + if (COMP_EXT.has(x)) { compN++; const raw = readFileSync(f, 'utf8'); + for (const m of raw.matchAll(HEX)) warns.push({ type: 'hex', rel, line: lineOf(raw, m.index), msg: '#' + m[1] }); + for (const m of raw.matchAll(UTIL)) { const tok = (m[1] || m[2]); if (!utilUse.has(tok)) utilUse.set(tok, `${rel}:${lineOf(raw, m.index)}`); } } } -// ---------- report ---------- -console.log('CI design-canon lint'); -console.log(` target: ${TARGET}`); -console.log(` scanned: ${cssScanned} css/markup file(s), ${compScanned} component file(s)`); -console.log(''); -if (warns.length) { - console.log(`⚠ ${warns.length} hardcoded-hex warning(s) (non-blocking):`); - for (const w of warns.slice(0, 40)) console.log(` warn ${w.rel}:${w.line} ${w.hex}`); - if (warns.length > 40) console.log(` … and ${warns.length - 40} more`); - console.log(''); +// undefined CI-token var uses (partial-swap bug) +const seenUndef = new Set(); +for (const u of uses) { if (!CI_TOKENS.test(u.name) || defined.has(u.name) || FRAMEWORK_VAR.test(u.name)) continue; if (seenUndef.has(u.name)) continue; seenUndef.add(u.name); + warns.push({ type: 'undef', rel: u.rel, line: u.line, msg: `var(${u.name}) used but --${u.name.slice(2)} is never defined` }); } + +// @theme completeness (v4 fallback-to-default bug) — only if the repo uses @theme +const hasTheme = themeMapped.size > 0; +if (hasTheme) for (const [tok, at] of utilUse) { + const cssName = '--' + tok; const colorName = '--color-' + tok; const isShadow = tok.startsWith('shadow-'); + const defd = defined.has(cssName); const mapped = isShadow ? themeMapped.has(cssName) : (themeMapped.has(colorName) || themeMapped.has(cssName)); + if (defd && !mapped) warns.push({ type: 'theme', rel: at.split(':')[0], line: Number(at.split(':')[1]), msg: `utility uses ${tok} but ${isShadow ? cssName : colorName} is not in @theme (Tailwind v4 will use its default, not the CI token)` }); } -if (fails.length) { - console.log(`✖ ${fails.length} non-canon --primary value(s):`); - for (const x of fails) console.log(` FAIL ${x.rel}:${x.line} --primary: ${x.val} -> rgb(${x.rgb.join(',')})`); - console.log(''); - console.log(' --primary must be CI teal: #0f717a (light) / #abd4d8 (dark), in any notation.'); - console.log('lint-canon: FAILED'); - process.exit(1); + +// report +console.log('CI design-canon lint (v3)'); +console.log(` target: ${TARGET}\n scanned: ${cssN} css/markup, ${compN} component file(s)\n`); +const grp = (t) => warns.filter((w) => w.type === t); +for (const [t, label] of [['undef', 'undefined CI-token var (likely a partial token swap)'], ['theme', 'missing @theme mapping (Tailwind v4 falls back to its default)'], ['hex', 'hardcoded hex in component source']]) { + const g = grp(t); if (!g.length) continue; + console.log(`⚠ ${g.length} ${label}:`); + for (const w of g.slice(0, 30)) console.log(` warn ${w.rel}:${w.line} ${w.msg}`); + if (g.length > 30) console.log(` … and ${g.length - 30} more`); console.log(''); } -console.log('✔ lint-canon: canon OK (all resolvable --primary values are CI teal)'); +if (fails.length) { console.log(`✖ ${fails.length} non-canon --primary value(s):`); + for (const x of fails) console.log(` FAIL ${x.rel}:${x.line} --primary: ${x.val} -> rgb(${x.rgb.join(',')})`); + console.log('\n --primary must be CI teal #0f717a / #abd4d8 (any notation).\nlint-canon: FAILED'); process.exit(1); } +console.log('✔ lint-canon: canon OK (all --primary are CI teal)' + (warns.length ? ` — ${warns.length} non-blocking warning(s) above` : '')); process.exit(0);