deerflow2/frontend/scripts/color-guard.mjs

313 lines
8.9 KiB
JavaScript

#!/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();