refactor: 完成全局CSS模块化治理
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-05 00:45:05 +08:00
parent 1a7a7245a6
commit 458ddd97df
31 changed files with 17265 additions and 9857 deletions
File diff suppressed because it is too large Load Diff
+12 -9
View File
@@ -12,7 +12,7 @@ const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR'
});
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
const files = [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))].filter((file) =>
/^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?)$/.test(file),
/^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?|css)$/.test(file),
);
if (!files.length) {
@@ -20,12 +20,15 @@ if (!files.length) {
process.exit(0);
}
const executable =
process.platform === 'win32'
? `${mode === 'lint' ? 'eslint' : 'prettier'}.cmd`
: mode === 'lint'
? 'eslint'
: 'prettier';
const args = mode === 'lint' ? files : ['--check', ...files];
const result = spawnSync(resolve(root, 'node_modules/.bin', executable), args, { cwd: root, stdio: 'inherit' });
const args = mode === 'lint' ? files.filter((file) => !file.endsWith('.css')) : ['--check', ...files];
if (mode === 'lint' && !args.length) {
console.log('No changed code files require lint.');
process.exit(0);
}
const executable = process.execPath;
const moduleEntry = resolve(
root,
mode === 'lint' ? 'node_modules/eslint/bin/eslint.js' : 'node_modules/prettier/bin/prettier.cjs',
);
const result = spawnSync(executable, [moduleEntry, ...args], { cwd: root, stdio: 'inherit' });
process.exit(result.status ?? 1);
+157
View File
@@ -0,0 +1,157 @@
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();
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs';
test('AST metrics ignore formatting and count selectors and important declarations', () => {
assert.deepEqual(analyzeCss('.page { color: red !important; }\n.page a, .page button { display: block; }'), {
rules: 2,
selectors: 3,
declarations: 2,
important: 1,
selectorList: ['.page', '.page a', '.page button'],
});
});
test('broad business tag selectors are rejected while rooted selectors are allowed', () => {
assert.deepEqual(broadBusinessSelectors('button { color: red; } .report-page button { color: blue; }'), ['button']);
});
test('formatting changes do not affect AST growth metrics', () => {
assert.deepEqual(analyzeCss('.page{color:red}'), analyzeCss('.page {\n color: red;\n}\n'));
});
test('global.css cannot be recreated after migration', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cmpp-css-gate-'));
fs.mkdirSync(path.join(directory, 'src/styles'), { recursive: true });
assert.doesNotThrow(() => verifyGlobalCssAbsent(directory));
fs.writeFileSync(path.join(directory, 'src/styles/global.css'), '.new-page {}');
assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/);
fs.rmSync(directory, { recursive: true, force: true });
});