132 lines
5.3 KiB
JavaScript
132 lines
5.3 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';
|
|
import { broadBusinessSelectors, verifyOwnership } from './css-policy.mjs';
|
|
export { broadBusinessSelectors } from './css-policy.mjs';
|
|
|
|
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 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 changedCssFiles() {
|
|
if (!fs.existsSync(path.join(root, '.git'))) return { base: 'archive', files: [] };
|
|
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 policy = JSON.parse(read('tools/quality/css-ownership.json'));
|
|
const { graph } = verifyOwnership(root, policy);
|
|
const mainStyles = graph.get('src/main.tsx').filter((file) => file.endsWith('.css'));
|
|
if (JSON.stringify(mainStyles) !== JSON.stringify(policy.mainImportOrder)) {
|
|
throw new Error('应用入口 CSS 层级与已评审基线不一致');
|
|
}
|
|
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 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();
|