#!/usr/bin/env node import { execSync } from "node:child_process"; import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import url from "node:url"; const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."); const SRC_ROOT = path.join(ROOT, "src"); const GLOBALS_PATH = path.join(SRC_ROOT, "styles", "globals.css"); const TOKENS_PATH = path.join(SRC_ROOT, "styles", "workspace-color-tokens.ts"); const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g; const ARBITRARY_COLOR_RE = /\b(?:bg|text|border|ring|from|to|via|fill|stroke)-\[[^\]]+\]/g; const NAMED_COLOR_RE = /\b(?:bg|text|border|ring|from|to|via|fill|stroke)-(?:white|black)(?:\/\d+)?\b/g; const EXCLUDED_HEX_FILES = new Set([GLOBALS_PATH, TOKENS_PATH]); const MODE = process.argv.includes("--mode=guard") ? "guard" : "audit"; function walkFiles(dir) { const result = []; const queue = [dir]; while (queue.length > 0) { const current = queue.pop(); if (!current) continue; for (const entry of readdirSync(current)) { const fullPath = path.join(current, entry); const stats = statSync(fullPath); if (stats.isDirectory()) { queue.push(fullPath); } else if (stats.isFile()) { result.push(fullPath); } } } return result; } function collectMatchesInContent(content, regex, includeLine) { const findings = []; const lines = content.split(/\r?\n/); lines.forEach((line, index) => { if (!includeLine(index + 1, line)) return; regex.lastIndex = 0; for (const match of line.matchAll(regex)) { findings.push({ line: index + 1, match: match[0] }); } }); return findings; } function scanFullSource() { const files = walkFiles(SRC_ROOT); const report = { hex: [], arbitrary: [], named: [], }; for (const file of files) { if (!/\.(cjs|mjs|js|jsx|ts|tsx|css|scss|sass|less|mdx?)$/.test(file)) { continue; } const content = readFileSync(file, "utf8"); if (!EXCLUDED_HEX_FILES.has(file)) { const hexFindings = collectMatchesInContent(content, HEX_RE, () => true); for (const finding of hexFindings) { report.hex.push({ file, ...finding }); } } const arbitraryFindings = collectMatchesInContent( content, ARBITRARY_COLOR_RE, () => true, ); for (const finding of arbitraryFindings) { report.arbitrary.push({ file, ...finding }); } const namedFindings = collectMatchesInContent(content, NAMED_COLOR_RE, () => true); for (const finding of namedFindings) { report.named.push({ file, ...finding }); } } return report; } function parseDiffAddedLines() { const addedLines = new Map(); const addLine = (file, lineNo, content) => { if (!addedLines.has(file)) addedLines.set(file, []); addedLines.get(file).push({ line: lineNo, content }); }; let diffText = ""; try { diffText = execSync("git diff --no-color --unified=0 -- frontend/src", { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); } catch { diffText = ""; } let currentFile = null; let newLineNo = 0; for (const line of diffText.split(/\r?\n/)) { if (line.startsWith("+++ b/")) { currentFile = path.join(ROOT, line.slice(6)); continue; } if (line.startsWith("@@")) { const match = line.match(/\+(\d+)(?:,(\d+))?/); if (!match) continue; newLineNo = Number(match[1]); continue; } if (!currentFile) continue; if (line.startsWith("+") && !line.startsWith("+++")) { addLine(currentFile, newLineNo, line.slice(1)); newLineNo += 1; continue; } if (!line.startsWith("-")) { newLineNo += 1; } } let untracked = ""; try { untracked = execSync("git ls-files --others --exclude-standard frontend/src", { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); } catch { untracked = ""; } for (const relativeFile of untracked.split(/\r?\n/).filter(Boolean)) { const fullFile = path.join(ROOT, relativeFile); if (!/\.(cjs|mjs|js|jsx|ts|tsx|css|scss|sass|less|mdx?)$/.test(fullFile)) { continue; } const lines = readFileSync(fullFile, "utf8").split(/\r?\n/); lines.forEach((content, idx) => addLine(fullFile, idx + 1, content)); } return addedLines; } function scanAddedViolations() { const addedLines = parseDiffAddedLines(); const report = { hex: [], arbitrary: [], named: [], }; for (const [file, lines] of addedLines.entries()) { for (const { line, content } of lines) { if (!EXCLUDED_HEX_FILES.has(file)) { HEX_RE.lastIndex = 0; for (const match of content.matchAll(HEX_RE)) { report.hex.push({ file, line, match: match[0] }); } } ARBITRARY_COLOR_RE.lastIndex = 0; for (const match of content.matchAll(ARBITRARY_COLOR_RE)) { report.arbitrary.push({ file, line, match: match[0] }); } NAMED_COLOR_RE.lastIndex = 0; for (const match of content.matchAll(NAMED_COLOR_RE)) { report.named.push({ file, line, match: match[0] }); } } } return report; } async function validateTokenRegistry() { const moduleUrl = url.pathToFileURL(TOKENS_PATH).href; const tokenModule = await import(moduleUrl); const tokens = tokenModule.WORKSPACE_COLOR_TOKENS ?? {}; const entries = Object.entries(tokens); const errors = []; const lightSeen = new Map(); const darkSeen = new Map(); for (const [name, value] of entries) { if (!/^ws-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { errors.push(`invalid token name "${name}"`); } const light = String(value.light ?? "").toLowerCase(); const dark = String(value.dark ?? "").toLowerCase(); if (!/^#[0-9a-f]{6,8}$/.test(light)) { errors.push(`invalid light color for ${name}: ${value.light}`); } if (!/^#[0-9a-f]{6,8}$/.test(dark)) { errors.push(`invalid dark color for ${name}: ${value.dark}`); } if (lightSeen.has(light)) { errors.push( `duplicate light color mapping: ${light} used by ${lightSeen.get(light)} and ${name}`, ); } else { lightSeen.set(light, name); } if (darkSeen.has(dark)) { errors.push( `duplicate dark color mapping: ${dark} used by ${darkSeen.get(dark)} and ${name}`, ); } else { darkSeen.set(dark, name); } } return { entries, errors, }; } function collectWsVarsFromBlocks(css, selectorPattern) { const vars = new Set(); const blockRegex = /([^{}]+)\{([^{}]*)\}/g; for (const block of css.matchAll(blockRegex)) { const selector = block[1]?.trim() ?? ""; const body = block[2] ?? ""; if (!selectorPattern.test(selector)) continue; for (const match of body.matchAll(/--ws-color-([0-9a-z-]+)\s*:/g)) { vars.add(`ws-${match[1]}`); } } return vars; } function validateGlobalsCoverage(tokenEntries) { const css = readFileSync(GLOBALS_PATH, "utf8"); const rootVars = collectWsVarsFromBlocks(css, /(^|,)\s*:root(\s|,|$)/); const darkVars = collectWsVarsFromBlocks(css, /(^|,)\s*\.dark(\s|,|$)/); const inlineVars = new Set( [...css.matchAll(/--color-ws-([0-9a-z-]+)\s*:/g)].map((match) => `ws-${match[1]}`), ); const tokenNames = new Set(tokenEntries.map(([name]) => name)); const errors = []; for (const tokenName of tokenNames) { if (!rootVars.has(tokenName)) { errors.push(`missing :root ws variable for ${tokenName}`); } if (!darkVars.has(tokenName)) { errors.push(`missing .dark ws variable for ${tokenName}`); } if (!inlineVars.has(tokenName)) { errors.push(`missing @theme inline mapping for ${tokenName}`); } } return { rootCount: rootVars.size, darkCount: darkVars.size, inlineCount: inlineVars.size, errors, }; } function printFindings(label, findings) { if (findings.length === 0) return; console.log(label); for (const finding of findings) { const relativePath = path.relative(ROOT, finding.file); console.log(` - ${relativePath}:${finding.line} ${finding.match}`); } } async function main() { const fullScan = scanFullSource(); const addedViolations = scanAddedViolations(); const tokenValidation = await validateTokenRegistry(); const globalsValidation = validateGlobalsCoverage(tokenValidation.entries); console.log(`[color-guard] mode=${MODE}`); console.log( `[summary] full-scan hex=${fullScan.hex.length} arbitrary=${fullScan.arbitrary.length} named=${fullScan.named.length}`, ); console.log( `[summary] added-violations hex=${addedViolations.hex.length} arbitrary=${addedViolations.arbitrary.length} named=${addedViolations.named.length}`, ); console.log( `[summary] ws-vars root=${globalsValidation.rootCount} dark=${globalsValidation.darkCount} inline=${globalsValidation.inlineCount}`, ); printFindings("[added] hex violations", addedViolations.hex); printFindings("[added] arbitrary color violations", addedViolations.arbitrary); printFindings("[added] named color violations", addedViolations.named); const semanticErrors = [...tokenValidation.errors, ...globalsValidation.errors]; if (semanticErrors.length > 0) { console.log("[semantic] token/globals errors"); for (const error of semanticErrors) { console.log(` - ${error}`); } } const hasViolations = addedViolations.hex.length > 0 || addedViolations.arbitrary.length > 0 || addedViolations.named.length > 0 || semanticErrors.length > 0; if (MODE === "guard" && hasViolations) { console.error("[color-guard] guard failed"); process.exit(1); } console.log("[color-guard] done"); } await main();