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() { 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 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();