Files
lislgosms/tools/quality/css-policy.mjs
T

214 lines
8.5 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import postcss from 'postcss';
import selectorParser from 'postcss-selector-parser';
import ts from 'typescript';
// Retain declaration values, order and at-rule conditions; ignore comments and raw formatting only.
export function cssFingerprint(css) {
function nodeValue(node) {
if (node.type === 'comment') return null;
const value = { type: node.type };
for (const key of ['selector', 'name', 'params', 'prop', 'value', 'important']) {
if (node[key] !== undefined) value[key] = node[key];
}
if (node.nodes) value.nodes = node.nodes.map(nodeValue).filter(Boolean);
return value;
}
return crypto
.createHash('sha256')
.update(JSON.stringify(nodeValue(postcss.parse(css.replaceAll('\r\n', '\n')))))
.digest('hex');
}
function isKeyframe(rule) {
for (let parent = rule.parent; parent; parent = parent.parent) {
if (parent.type === 'atrule' && /(?:^|-)keyframes$/i.test(parent.name)) return true;
}
return false;
}
function anchored(selector, roots) {
// A positive class in the first compound must constrain the target itself or an ancestor.
// :not/:has and sibling combinators cannot establish ownership.
const nodes = selector.nodes.filter((node) => node.type !== 'comment');
const boundary = nodes.findIndex((node) => node.type === 'combinator');
const compound = boundary === -1 ? nodes : nodes.slice(0, boundary);
return (
compound.some((node) => node.type === 'class' && roots.includes(node.value)) &&
(boundary === -1 || [' ', '>'].includes(nodes[boundary].value))
);
}
export function unscopedSelectors(css, roots) {
const findings = [];
postcss.parse(css).walkRules((rule) => {
if (isKeyframe(rule)) return;
selectorParser((selectors) => {
selectors.each((selector) => {
if (!anchored(selector, roots)) findings.push(selector.toString());
});
}).processSync(rule.selector);
});
return findings;
}
export function broadBusinessSelectors(css) {
const findings = [];
postcss.parse(css).walkRules((rule) => {
if (isKeyframe(rule)) return;
selectorParser((selectors) => {
selectors.each((selector) => {
const roots = selector.nodes
.filter(
(node) =>
node.type === 'class' && !/^(?:selected|active|disabled|loading|item|title|card|is-.+)$/.test(node.value),
)
.map((node) => node.value);
if (!anchored(selector, roots)) findings.push(selector.toString());
});
}).processSync(rule.selector);
});
return findings;
}
export function localImports(file, code) {
if (file.endsWith('.css')) {
const imports = [];
postcss.parse(code).walkAtRules('import', (rule) => {
const match = rule.params.match(/^(?:url\(\s*)?['"]([^'"]+)['"]/);
if (!match) throw new Error(`${file}: CSS @import 必须使用明确的引号路径`);
imports.push(match[1]);
});
return imports;
}
const imports = [];
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
function visit(node) {
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
if (ts.isStringLiteral(node.moduleSpecifier) && !node.importClause?.isTypeOnly && !node.isTypeOnly) {
imports.push(node.moduleSpecifier.text);
}
}
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
if (node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) imports.push(node.arguments[0].text);
}
ts.forEachChild(node, visit);
}
visit(source);
return imports;
}
function sourceFiles(root) {
return fs
.readdirSync(path.join(root, 'src'), { recursive: true })
.map((file) => `src/${String(file).replaceAll('\\', '/')}`)
.filter((file) => /\.(?:tsx?|css)$/.test(file));
}
function declaredRootClasses(file, code) {
const classes = new Set();
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
function visit(node) {
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer) {
function collect(value) {
if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) {
for (const token of value.text.split(/\s+/)) classes.add(token);
}
ts.forEachChild(value, collect);
}
collect(node.initializer);
}
ts.forEachChild(node, visit);
}
visit(source);
return classes;
}
export function importGraph(root) {
const files = sourceFiles(root);
const available = new Set(files);
const graph = new Map();
for (const file of files) {
const edges = [];
for (const specifier of localImports(file, fs.readFileSync(path.join(root, file), 'utf8'))) {
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) continue;
const target = specifier.startsWith('@/')
? `src/${specifier.slice(2)}`
: path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier));
const resolved = [target, `${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`].find(
(candidate) => available.has(candidate),
);
if (resolved) edges.push(resolved);
else if (/\.css$/.test(target)) throw new Error(`${file}: CSS import 不存在 ${specifier}`);
}
graph.set(file, edges);
}
return graph;
}
export function verifyOwnership(root, policy) {
const graph = importGraph(root);
const reachable = new Set();
function visit(file) {
if (reachable.has(file)) return;
reachable.add(file);
for (const dependency of graph.get(file) ?? []) visit(dependency);
}
visit('src/main.tsx');
const cssFiles = [...graph.keys()].filter((file) => file.endsWith('.css'));
const records = new Map(policy.files.map((record) => [record.file, record]));
if (records.size !== policy.files.length) throw new Error('CSS 所有权清单包含重复文件');
for (const file of records.keys()) {
if (!cssFiles.includes(file)) throw new Error(`CSS 所有权登记文件不存在:${file}`);
}
for (const file of cssFiles) {
const record = records.get(file);
if (!record) throw new Error(`${file}: 必须登记 CSS 所有者和根类名`);
if (!reachable.has(file)) throw new Error(`${file}: 从 src/main.tsx 不可达`);
const owners = [...graph]
.filter(([, edges]) => edges.includes(file))
.map(([owner]) => owner)
.sort();
if (!owners.length || JSON.stringify(owners) !== JSON.stringify([...record.owners].sort())) {
throw new Error(`${file}: 实际 import 所有者与登记不一致:${owners.join(', ')}`);
}
const css = fs.readFileSync(path.join(root, file), 'utf8');
if (record.legacyFingerprint) {
if (!record.reason || !record.removalCondition) throw new Error(`${file}: 历史兼容必须记录原因及清理条件`);
if (cssFingerprint(css) !== record.legacyFingerprint) throw new Error(`${file}: 历史 CSS 内容与已评审基线不一致`);
} else {
if (!record.roots?.length) throw new Error(`${file}: 新 CSS 必须登记非空根类名`);
const declared = new Set(
owners
.filter((owner) => owner.endsWith('.tsx'))
.flatMap((owner) => [...declaredRootClasses(owner, fs.readFileSync(path.join(root, owner), 'utf8'))]),
);
if (record.roots.some((rootClass) => !declared.has(rootClass))) {
throw new Error(`${file}: 根类名必须出现在直接 TSX 所有者的 className 中`);
}
const findings = unscopedSelectors(css, record.roots);
if (findings.length) throw new Error(`${file}: 选择器未限定在所有者根节点:${findings.join(', ')}`);
if (file.startsWith('src/apps/') && owners.some((owner) => !owner.startsWith(`${path.posix.dirname(file)}/`))) {
throw new Error(`${file}: 新页面 CSS 必须由同目录所有者 import`);
}
postcss.parse(css).walkDecls((declaration) => {
if (declaration.important) throw new Error(`${file}: 新 CSS 禁止未登记的 !important`);
});
}
}
const config = JSON.parse(fs.readFileSync(path.join(root, '.stylelintrc.json'), 'utf8'));
const compatible = policy.files
.filter((record) => record.stylelintLegacy)
.map((record) => record.file)
.sort();
if (
JSON.stringify(config.overrides?.[0]?.files?.slice().sort()) !== JSON.stringify(compatible) ||
config.overrides.length !== 1
) {
throw new Error('Stylelint 兼容范围必须与精确历史文件清单一致,禁止目录通配符和额外覆盖');
}
return { graph, cssFiles };
}