Files
lislgosms/tools/quality/verify-css-governance.mjs
T
hectorzhao 458ddd97df
CSS quality / css-quality (push) Has been cancelled
refactor: 完成全局CSS模块化治理
2026-09-05 00:45:05 +08:00

158 lines
6.1 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import postcss from 'postcss';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export function analyzeCss(css) {
const ast = postcss.parse(css);
const result = { rules: 0, selectors: 0, declarations: 0, important: 0, selectorList: [] };
ast.walkRules((rule) => {
result.rules += 1;
const selectors = rule.selectors ?? [rule.selector];
result.selectors += selectors.length;
result.selectorList.push(...selectors);
});
ast.walkDecls((declaration) => {
result.declarations += 1;
if (declaration.important) result.important += 1;
});
return result;
}
export function broadBusinessSelectors(css) {
const findings = [];
postcss.parse(css).walkRules((rule) => {
for (const selector of rule.selectors ?? [rule.selector]) {
if (/^(?:div|section|article|button|table|input|select|textarea)(?:\b|[ >+~:[.#])/.test(selector.trim())) {
findings.push(selector);
}
}
});
return findings;
}
export function verifyGlobalCssAbsent(repoRoot) {
const legacy = path.join(repoRoot, 'src/styles/global.css');
if (fs.existsSync(legacy)) throw new Error('src/styles/global.css 已完成迁移,禁止重新创建');
}
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
function currentCssFiles(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? currentCssFiles(target) : entry.name.endsWith('.css') ? [target] : [];
});
}
function changedCssFiles() {
const configuredBase = process.env.QUALITY_BASE_REF;
let base = configuredBase || 'HEAD';
if (!configuredBase) {
try {
base =
execFileSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() || 'HEAD';
} catch {
base = 'HEAD';
}
}
const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', base, '--', '*.css'], {
cwd: root,
encoding: 'utf8',
});
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--', '*.css'], {
cwd: root,
encoding: 'utf8',
});
return { base, files: [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))] };
}
function baseCss(base, file) {
try {
return execFileSync('git', ['show', `${base}:${file}`], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return '';
}
}
function verify() {
verifyGlobalCssAbsent(root);
const baseline = JSON.parse(read('tools/quality/css-governance-baseline.json'));
const entry = read(baseline.entry);
const imports = [...entry.matchAll(/@import\s+['"](.+?)['"]/g)].map((match) =>
path.posix.join(path.posix.dirname(baseline.entry), match[1]).replace(/\\/g, '/'),
);
if (JSON.stringify(imports) !== JSON.stringify(baseline.importOrder)) {
throw new Error('业务样式入口顺序与治理基线不一致,可能改变级联结果');
}
const totals = { rules: 0, selectors: 0, declarations: 0, important: 0 };
const selectorList = [];
for (const module of baseline.modules) {
const css = read(module.file);
const metrics = analyzeCss(css);
for (const key of Object.keys(totals)) totals[key] += metrics[key];
selectorList.push(...metrics.selectorList);
if (metrics.important > module.important) {
throw new Error(`${module.file} 新增了未登记的 !important`);
}
}
for (const key of Object.keys(totals)) {
if (totals[key] !== baseline.legacySource[key]) {
throw new Error(`迁移后 CSS ${key}=${totals[key]},与原 global.css 基线 ${baseline.legacySource[key]} 不一致`);
}
}
if (JSON.stringify(selectorList) !== JSON.stringify(baseline.legacySource.selectorList)) {
throw new Error('迁移后选择器及顺序与原 global.css 基线不一致');
}
const changes = changedCssFiles();
const migratedFiles = new Set(baseline.modules.map((item) => item.file));
for (const file of changes.files) {
if (migratedFiles.has(file) || !fs.existsSync(path.join(root, file))) continue;
const current = read(file);
const previous = baseCss(changes.base, file);
const oldBroad = new Set(broadBusinessSelectors(previous));
const newBroad = broadBusinessSelectors(current).filter((selector) => !oldBroad.has(selector));
if (newBroad.length) throw new Error(`${file} 新增宽泛业务标签选择器:${newBroad.join(', ')}`);
if (analyzeCss(current).important > analyzeCss(previous).important) {
throw new Error(`${file} 新增了未登记的 !important`);
}
}
const sourceText = currentCssFiles(path.join(root, 'src'))
.map((file) => fs.readFileSync(file, 'utf8'))
.join('\n');
const codeText = fs
.readdirSync(path.join(root, 'src'), { recursive: true })
.filter((name) => /\.(?:ts|tsx)$/.test(String(name)))
.map((name) => fs.readFileSync(path.join(root, 'src', String(name)), 'utf8'))
.join('\n');
for (const file of currentCssFiles(path.join(root, 'src'))) {
const relative = path.relative(path.join(root, 'src'), file).replace(/\\/g, '/');
if (relative === 'styles/tokens.css' || relative === 'styles/reset.css') continue;
if (!sourceText.includes(path.basename(file)) && !codeText.includes(path.basename(file))) {
throw new Error(`${relative} 没有明确 import 所有者`);
}
}
const duplicateCount = selectorList.length - new Set(selectorList).size;
const digest = crypto.createHash('sha256').update(selectorList.join('\n')).digest('hex').slice(0, 12);
console.log(
`CSS governance verified: ${totals.rules} rules, ${totals.selectors} selectors, ${totals.declarations} declarations, ${duplicateCount} duplicate selector occurrences, order ${digest}; ${changes.files.length} changed CSS files checked from ${changes.base}.`,
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) verify();