feat(08-01):添加工作区颜色保护脚本
- 新增 color-guard.mjs,用于进行十六进制颜色值、任意值及令牌一致性检查 - 暴露 audit:colors 与 guard:colors 两个 npm 脚本
This commit is contained in:
parent
730a06f391
commit
21dfa71e00
|
|
@ -12,6 +12,8 @@
|
||||||
"format:write": "prettier --write .",
|
"format:write": "prettier --write .",
|
||||||
"lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**",
|
"lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**",
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --ignore-pattern imports/** --fix",
|
"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": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
Loading…
Reference in New Issue