eigent/scripts/check-css-writing-standard.mjs
Tong Chen 59f43afcb1
Feat/workspace bundle product (#1842)
Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com>
Co-authored-by: Douglas <douglas.ym.lai@gmail.com>
2026-08-28 19:16:22 +08:00

543 lines
15 KiB
JavaScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* Regression check for the Electron CSS writing standard:
* - Any Tailwind border-width utility must cover all four sides.
* - Unused axes are written as 0 (`border-x-0`, `border-t-0`, …).
* - A bare `border` / `border-0` still needs explicit `border-x` + `border-y`.
* - Retired component-color aliases (`text-text-*`, `bg-surface-*`, …) fail.
*
* Usage:
* node scripts/check-css-writing-standard.mjs
* node scripts/check-css-writing-standard.mjs --fix
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const SRC_ROOT = join(REPO_ROOT, 'src');
const FIX_MODE = process.argv.includes('--fix');
const SKIP_PREFIXES = [
'src/lib/themeTokens/',
'src/style/generated/',
'src/style/tokens/',
];
const SKIP_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
const LINE_STRING_RE = /(['"])((?:\\.|[^\\\n])*?)\1/g;
const TEMPLATE_RE = /`((?:\\.|[^\\`$])*?)`/g;
const WIDTH_UTILITY_RE =
/^(!)?border(?:-(x|y|t|r|b|l))?(?:-(0|\d+|\[[^\]]+\]))?$/;
const ALIAS_PATTERNS = [
{
id: 'text-text',
regex: /(?<![\w-])(?:!)?text-text-[a-z0-9-]+/g,
},
{
id: 'text-icon',
regex: /(?<![\w-])(?:!)?text-icon-[a-z0-9-]+/g,
},
{
id: 'bg-surface',
regex: /(?<![\w-])(?:!)?bg-surface-[a-z0-9-]+/g,
},
{
id: 'shadcn-surface',
regex:
/(?<![\w-])(?:bg-background|text-foreground|bg-card|text-muted-foreground|border-border|bg-popover|text-popover-foreground)(?![\w-])/g,
},
{
id: 'shadcn-primary',
regex:
/(?<![\w-])(?:bg-primary|text-primary-foreground|border-primary|focus:border-primary)(?![\w-])/g,
},
{
id: 'shadcn-destructive',
regex:
/(?<![\w-])(?:bg-destructive|text-destructive|border-destructive)(?:\/[\w.]+)?(?![\w-])/g,
},
{
id: 'ring-ring',
regex: /(?<![\w-])ring-ring(?![\w-])/g,
},
{
id: 'legacy-warning',
regex: /(?<![\w-])(?:bg-warning\/[\d.]+|text-warning)(?![\w-])/g,
},
{
id: 'input-bg-alias',
regex: /(?<![\w-])bg-input-bg-[a-z0-9-]+/g,
},
{
id: 'code-surface',
regex: /(?<![\w-])bg-code-surface(?![\w-])/g,
},
{
id: 'radix-primary-scale',
regex: /(?<![\w-])(?:bg|text)-primary-\d+(?![\w-])/g,
},
{
id: 'text-red',
regex: /(?<![\w-])text-red-\d+(?![\w-])/g,
},
];
function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
continue;
}
if (!/\.(tsx|ts|css)$/.test(entry.name)) continue;
yield full;
}
}
function splitVariant(token) {
let depth = 0;
let lastColon = -1;
for (let i = 0; i < token.length; i += 1) {
const ch = token[i];
if (ch === '[') depth += 1;
else if (ch === ']') depth -= 1;
else if (ch === ':' && depth === 0) lastColon = i;
}
if (lastColon === -1) return { variant: '', utility: token };
return {
variant: token.slice(0, lastColon + 1),
utility: token.slice(lastColon + 1),
};
}
function parseWidth(utility) {
const match = WIDTH_UTILITY_RE.exec(utility);
if (!match) return null;
return {
important: Boolean(match[1]),
axis: match[2] ?? '',
size: match[3] ?? '',
};
}
function sizeSuffix(size) {
return size ? `-${size}` : '';
}
function applyWidth(covered, axis, size) {
const value = size || '1';
if (!axis) {
covered.t = value;
covered.r = value;
covered.b = value;
covered.l = value;
covered.shorthand = value;
return;
}
if (axis === 'x') {
covered.l = value;
covered.r = value;
covered.axisX = value;
return;
}
if (axis === 'y') {
covered.t = value;
covered.b = value;
covered.axisY = value;
return;
}
covered[axis] = value;
covered[`named${axis}`] = true;
}
function emptyCoverage() {
return {
t: undefined,
r: undefined,
b: undefined,
l: undefined,
namedt: undefined,
namedr: undefined,
namedb: undefined,
namedl: undefined,
axisX: undefined,
axisY: undefined,
shorthand: undefined,
important: false,
};
}
function analyzeBag(widthTokens) {
const covered = emptyCoverage();
for (const token of widthTokens) {
applyWidth(covered, token.axis, token.size);
if (token.important) covered.important = true;
}
const sidesDefined = ['t', 'r', 'b', 'l'].every(
(side) => covered[side] !== undefined
);
const namedFour =
covered.t !== undefined &&
covered.r !== undefined &&
covered.b !== undefined &&
covered.l !== undefined &&
covered.axisX === undefined &&
covered.axisY === undefined &&
covered.shorthand === undefined;
const hasExplicitAxes =
covered.axisX !== undefined && covered.axisY !== undefined;
const hasAxisAndNamedY =
covered.axisX !== undefined &&
covered.t !== undefined &&
covered.b !== undefined;
const hasAxisAndNamedX =
covered.axisY !== undefined &&
covered.l !== undefined &&
covered.r !== undefined;
const writingOk =
hasExplicitAxes || hasAxisAndNamedY || hasAxisAndNamedX || namedFour;
return { covered, sidesDefined, writingOk };
}
function axisClass(prefix, axis, value) {
return `${prefix}border-${axis}${sizeSuffix(value === '1' ? '' : value)}`;
}
function missingClasses(covered, variant) {
const bang = covered.important ? '!' : '';
const prefix = `${variant}${bang}`;
const classes = [];
const left = covered.l !== undefined;
const right = covered.r !== undefined;
const top = covered.t !== undefined;
const bottom = covered.b !== undefined;
if (!left && !right) classes.push(`${prefix}border-x-0`);
else {
if (!left) classes.push(`${prefix}border-l-0`);
if (!right) classes.push(`${prefix}border-r-0`);
}
if (!top && !bottom) classes.push(`${prefix}border-y-0`);
else {
if (!top) classes.push(`${prefix}border-t-0`);
if (!bottom) classes.push(`${prefix}border-b-0`);
}
if (left && right && top && bottom) {
if (covered.axisX === undefined) {
if (covered.l === covered.r) {
classes.push(axisClass(prefix, 'x', covered.l));
} else {
if (!covered.namedl) classes.push(axisClass(prefix, 'l', covered.l));
if (!covered.namedr) classes.push(axisClass(prefix, 'r', covered.r));
}
}
if (covered.axisY === undefined) {
if (covered.t === covered.b) {
classes.push(axisClass(prefix, 'y', covered.t));
} else {
if (!covered.namedt) classes.push(axisClass(prefix, 't', covered.t));
if (!covered.namedb) classes.push(axisClass(prefix, 'b', covered.b));
}
}
}
return [...new Set(classes)];
}
function tokenizeClassString(value) {
return value.split(/\s+/).filter(Boolean);
}
function looksLikeClassList(value) {
const tokens = tokenizeClassString(value);
if (tokens.length === 0) return false;
// Variant/enum names such as TabsAppearance "border" are not class lists.
if (tokens.length === 1 && tokens[0] === 'border') return false;
if (/[;{}=]/.test(value) && !/\[[^\]]*\]/.test(value)) return false;
let classlike = 0;
for (const token of tokens) {
if (
/^!?[a-zA-Z@][\w-]*(?:\[[^\]]+\])?(?::[^\s]+)*$/.test(token) ||
token.startsWith('[') ||
token.includes(':')
) {
classlike += 1;
}
}
return classlike / tokens.length >= 0.75;
}
function analyzeClassString(value) {
if (!looksLikeClassList(value)) {
return { ok: true, bags: [], replacements: null };
}
if (!/(?:^|[\s:])!?border(?:-|$)/.test(value)) {
return { ok: true, bags: [], replacements: null };
}
const tokens = tokenizeClassString(value);
const bags = new Map();
tokens.forEach((token, index) => {
const { variant, utility } = splitVariant(token);
const width = parseWidth(utility);
if (!width) return;
const key = variant;
if (!bags.has(key)) {
bags.set(key, { variant, widths: [], indexes: [] });
}
const bag = bags.get(key);
bag.widths.push(width);
bag.indexes.push(index);
});
if (bags.size === 0) return { ok: true, bags: [], replacements: null };
let ok = true;
const insertAt = [];
for (const bag of bags.values()) {
const result = analyzeBag(bag.widths);
if (result.sidesDefined && result.writingOk) continue;
ok = false;
const extras = missingClasses(result.covered, bag.variant);
if (extras.length > 0) {
insertAt.push({ index: Math.min(...bag.indexes), extras });
}
}
if (ok) return { ok: true, bags: [...bags.keys()], replacements: null };
const next = [...tokens];
insertAt
.sort((a, b) => b.index - a.index)
.forEach(({ index, extras }) => {
const existing = new Set(next);
const unique = extras.filter((cls) => !existing.has(cls));
if (unique.length === 0) return;
next.splice(index, 0, ...unique);
});
return {
ok: false,
bags: [...bags.keys()],
replacements: next.join(' '),
};
}
function unescapeStringLiteral(raw, quote) {
if (quote === '`') return raw;
return raw.replace(/\\([\\'"`])/g, '$1');
}
function escapeStringLiteral(value, quote) {
if (quote === '`') return value;
const slash = quote === '"' ? '"' : "'";
return value.replaceAll('\\', '\\\\').replaceAll(slash, `\\${slash}`);
}
function selfTest() {
const cases = [
['border-b', false, 'border-x-0 border-t-0 border-b'],
['border-x-0 border-t-0 border-b', true, 'border-x-0 border-t-0 border-b'],
['border border-solid', false, 'border-x border-y border border-solid'],
[
'border border-solid border-x border-y',
true,
'border border-solid border-x border-y',
],
['border-0', false, 'border-x-0 border-y-0 border-0'],
['border-0 border-b', false, 'border-x-0 border-t-0 border-0 border-b'],
['border-x-0 border-y-0', true, 'border-x-0 border-y-0'],
[
'!border !border-solid !border-x !border-y',
true,
'!border !border-solid !border-x !border-y',
],
[
'hover:border-b',
false,
'hover:border-x-0 hover:border-t-0 hover:border-b',
],
['file:border-0', false, 'file:border-x-0 file:border-y-0 file:border-0'],
[
'border-ds-hairline-default-default',
true,
'border-ds-hairline-default-default',
],
['border', true, 'border'],
[
'border-b-[1px] border-solid',
false,
'border-x-0 border-t-0 border-b-[1px] border-solid',
],
[
'border-x-0 border-t-0 border-b-1',
true,
'border-x-0 border-t-0 border-b-1',
],
];
const failures = [];
for (const [input, expectOk, expectOut] of cases) {
const result = analyzeClassString(input);
if (result.ok !== expectOk) {
failures.push(`${input}: ok=${result.ok} expected ${expectOk}`);
continue;
}
const out = result.replacements ?? input;
if (out !== expectOut) {
failures.push(`${input}: got "${out}" expected "${expectOut}"`);
}
}
if (failures.length > 0) {
process.stderr.write(
`FAIL CSS writing-standard self-test:\n${failures.map((line) => ` ${line}`).join('\n')}\n`
);
process.exit(1);
}
}
selfTest();
const hits = [];
const aliasHits = [];
let filesFixed = 0;
for (const file of walk(SRC_ROOT)) {
const rel = relative(REPO_ROOT, file).replaceAll('\\', '/');
if (SKIP_PREFIXES.some((prefix) => rel.startsWith(prefix))) continue;
if (SKIP_FILE_RE.test(rel)) continue;
const original = readFileSync(file, 'utf8');
let text = original;
for (const { id, regex } of ALIAS_PATTERNS) {
regex.lastIndex = 0;
const match = text.match(regex);
if (!match) continue;
aliasHits.push({
file: rel,
id,
samples: [...new Set(match)].slice(0, 5),
});
}
const stringHits = [];
LINE_STRING_RE.lastIndex = 0;
let stringMatch = LINE_STRING_RE.exec(text);
while (stringMatch) {
stringHits.push({
quote: stringMatch[1],
raw: stringMatch[2],
index: stringMatch.index,
length: stringMatch[0].length,
});
stringMatch = LINE_STRING_RE.exec(text);
}
TEMPLATE_RE.lastIndex = 0;
let templateMatch = TEMPLATE_RE.exec(text);
while (templateMatch) {
stringHits.push({
quote: '`',
raw: templateMatch[1],
index: templateMatch.index,
length: templateMatch[0].length,
});
templateMatch = TEMPLATE_RE.exec(text);
}
stringHits.sort((a, b) => a.index - b.index);
const replacements = [];
for (const stringHit of stringHits) {
const value = unescapeStringLiteral(stringHit.raw, stringHit.quote);
if (!value.includes('border')) continue;
const result = analyzeClassString(value);
if (result.ok) continue;
hits.push({
file: rel,
sample: value.length > 120 ? `${value.slice(0, 117)}...` : value,
fixed: result.replacements,
});
if (FIX_MODE && result.replacements && result.replacements !== value) {
replacements.push({
start: stringHit.index,
end: stringHit.index + stringHit.length,
next: `${stringHit.quote}${escapeStringLiteral(result.replacements, stringHit.quote)}${stringHit.quote}`,
});
}
}
if (FIX_MODE && replacements.length > 0) {
replacements.sort((a, b) => b.start - a.start);
for (const { start, end, next } of replacements) {
text = `${text.slice(0, start)}${next}${text.slice(end)}`;
}
if (text !== original) {
writeFileSync(file, text);
filesFixed += 1;
}
}
}
if (FIX_MODE && filesFixed > 0) {
process.stdout.write(
`Updated ${filesFixed} file(s) with complete border-x / border-y axes.\n`
);
}
if (aliasHits.length > 0 || (hits.length > 0 && !FIX_MODE)) {
const details = [];
if (hits.length > 0 && !FIX_MODE) {
details.push(
'Incomplete border axes (every direct border needs border-x and border-y, unused = 0):'
);
for (const hit of hits.slice(0, 80)) {
details.push(` ${hit.file}`);
details.push(` ${hit.sample}`);
}
if (hits.length > 80) {
details.push(` … ${hits.length - 80} more`);
}
details.push(
` (${hits.length} class string(s). Re-run with --fix to apply zeros / explicit axes.)`
);
}
if (aliasHits.length > 0) {
details.push('Retired component-color aliases:');
for (const hit of aliasHits) {
details.push(` ${hit.file} [${hit.id}] ${hit.samples.join(', ')}`);
}
}
process.stderr.write(`FAIL CSS writing standard:\n${details.join('\n')}\n`);
process.exit(1);
}
if (FIX_MODE && hits.length > 0 && aliasHits.length === 0) {
process.stdout.write(
`PASS Applied border-axis completions (${hits.length} class string(s)). Re-run without --fix to verify.\n`
);
process.exit(0);
}
process.stdout.write(
'PASS CSS writing standard: complete border axes and no retired color aliases.\n'
);