This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user