diff --git a/frontend/package.json b/frontend/package.json index 527188b0..e3ff01e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "format:write": "prettier --write .", "lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**", "lint:fix": "eslint . --ext .ts,.tsx --ignore-pattern imports/** --fix", + "audit:colors": "node scripts/color-guard.mjs --mode=audit", + "guard:colors": "node scripts/color-guard.mjs --mode=guard", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", diff --git a/frontend/scripts/color-guard.mjs b/frontend/scripts/color-guard.mjs new file mode 100644 index 00000000..79b491c4 --- /dev/null +++ b/frontend/scripts/color-guard.mjs @@ -0,0 +1,312 @@ +#!/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 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: [], + }; + + 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 }); + } + } + + 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: [], + }; + + 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] }); + } + } + } + + 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-[0-9a-f]{6,8}$/.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}`, + ); + console.log( + `[summary] added-violations hex=${addedViolations.hex.length} arbitrary=${addedViolations.arbitrary.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); + + 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 || + semanticErrors.length > 0; + + if (MODE === "guard" && hasViolations) { + console.error("[color-guard] guard failed"); + process.exit(1); + } + + console.log("[color-guard] done"); +} + +await main();