fix: 收紧CSS所有权与历史兼容门禁

This commit is contained in:
hectorzhao
2026-09-05 09:33:34 +08:00
parent 64bfb9ad3d
commit ca1fc2847f
11 changed files with 670 additions and 41 deletions
+244
View File
@@ -0,0 +1,244 @@
{
"schemaVersion": 1,
"mainImportOrder": [
"src/styles/tokens.css",
"src/styles/reset.css",
"src/styles/shell.css",
"src/styles/domains/index.css",
"src/styles/admin.css",
"src/styles/client.css",
"src/styles/components.css"
],
"files": [
{
"file": "src/apps/admin/ReportMaterialImportModal.css",
"owners": ["src/apps/admin/ReportMaterialImportModal.tsx"],
"stylelintLegacy": false,
"roots": ["report-material-import-modal"]
},
{
"file": "src/apps/admin/channels/AdminChannelsPage.css",
"owners": ["src/apps/admin/AdminChannelsPage.tsx"],
"legacyFingerprint": "fc2d168035c9adac5b330a7526424831ac18725da92a8c2392600e08a0891c69",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
"owners": ["src/apps/admin/AdminEnterpriseApplicationsPage.tsx"],
"legacyFingerprint": "ac5abed49be7edb910ef8746c36f72e953f199b6073d13617594f1158fe4c69b",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
"owners": ["src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx"],
"legacyFingerprint": "ba5bc7ea3d09615685fee210c625d85ddf1c368d587922fb4953b24591675e06",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/admin/sms-records/AdminSmsRecordsPage.css",
"owners": ["src/apps/admin/AdminSmsRecordsPage.tsx"],
"legacyFingerprint": "a7cdbded6c765662dee0769c9c02159bb01045af08e2fa1842a11f79699fad02",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
"owners": ["src/apps/admin/AdminSmsTaskProgressPage.tsx"],
"legacyFingerprint": "a222961a94f15a388892a272e42bf64e122ea55f067bf618126e67ed107014b2",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
"owners": ["src/apps/admin/system-monitoring/AdminSystemMonitoringPage.tsx"],
"legacyFingerprint": "59d09cdce53ef804773d46b1e5807e0e273f2c3ba7e2aa21b864d0d718f75db2",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/client/ClientUsersPage.css",
"owners": ["src/apps/client/ClientUsersPage.tsx"],
"legacyFingerprint": "af9cdb6f0229056437dab22fc0533fa9b0df2e3b013b69088a9815c4468518db",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/admin.css",
"owners": ["src/main.tsx"],
"legacyFingerprint": "b31d1360ca34c0bd5bc81d3687f5db649d5eb775db41faaec58c1ffd358891a1",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/client.css",
"owners": ["src/main.tsx"],
"legacyFingerprint": "b496831577a2296cca8bd1e3ba5a5c9574068f73f8f4c8176fe023957f803219",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/components.css",
"owners": ["src/main.tsx"],
"legacyFingerprint": "8adee9fa7adcc5df32137c6428b944d9a79fd7aab0ceb8ae699aca6686294702",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/01-operations-dashboard.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "a9b28b65f14fb518f16620c4ae0fe337fe7807202818154c134b612384f9167d",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/02-client-sending.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "b84920adb1891089df78af28066fe0bc1c0d2452057a294a798554312be57b41",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/03-client-records.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "1e3eb6377c5436bc008f77cf1721575f5ace209cf0296d635002249272dcf794",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/04-signatures.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "0ff9be0753940b7d3312042a93b6a97d1cca92d3e045bfa3f13d064467377874",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/05-templates.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "79d3e6a2c2e1cef9dcecaac985344cdc36ce30a31ccfae6d5c29f5801152bd76",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/06-auth-enterprise.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "f391234e8afe2085bc3d64c4e86d2f89e9f4aa2554d84abf9ea9f6228a35fbc7",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/07-admin-operations.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "e8414200ee4b8354130880f1e3c7b71ea3972d278b1b72414897fb14d554958b",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/08-reporting.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "db762c6fef81aac2b4a76746b5a78a607e9711aaeb4e509e2800b86b17eee6a5",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/09-channels.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "a32eab767f85b49324e17721b70b8dd0c8f0e5a14d6ef2d3502a56c69f9c0e7a",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/10-signature-quality.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "9d79afed6c1550e747dcf6c5ab554a4977b62a93d66421a0a01add9f875e2c30",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/11-deliveries-reporting.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "9792e6aedb4c74dcd6aeb26925391315e7df662c038a32be5ffcf3eee282374d",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/12-admin-configuration.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "60ac29279c18da425f52efe88b2de9a686a20c4e54ec54d6a424d027490c8e81",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/13-client-signatures.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "c90bb9ea3bf4ab278379a806b94187a559a46557de344c934be55968fef5287b",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/14-responsive-requeue.css",
"owners": ["src/styles/domains/index.css"],
"legacyFingerprint": "3d3daaca99d4efbdaaea4dd1ec6a0239274ed52f6de658ed9f889a6d565ec567",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/domains/index.css",
"owners": ["src/main.tsx"],
"legacyFingerprint": "3f287af46d6c7e7f921c43913b52a9a727a8e343a80ad46c45d25baaed073177",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/reset.css",
"owners": ["src/main.tsx"],
"legacyFingerprint": "4be47c806f9a6981a8b73f3844d707fd5cdaaf0d343323de07c6e39089224df1",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/shell.css",
"owners": ["src/main.tsx"],
"legacyFingerprint": "93e26e9dcaa88447e054aeff8e20d4902cdaea54f0ec5a5b5659c84d9c4b9bd8",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/styles/tokens.css",
"owners": ["src/main.tsx"],
"legacyFingerprint": "754135f86b0828fa004270a4be6e7a087cb794eefa3860e8d45f89a6eb1f7223",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
}
]
}
+213
View File
@@ -0,0 +1,213 @@
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 };
}
+8 -35
View File
@@ -5,6 +5,8 @@ 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)), '../..');
@@ -24,18 +26,6 @@ export function analyzeCss(css) {
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 已完成迁移,禁止重新创建');
@@ -45,13 +35,6 @@ 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;
@@ -89,6 +72,12 @@ function baseCss(base, file) {
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) =>
@@ -132,22 +121,6 @@ function verify() {
}
}
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(
@@ -4,6 +4,8 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs';
import { cssFingerprint, localImports, unscopedSelectors, verifyOwnership } from './css-policy.mjs';
import stylelint from 'stylelint';
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; }'), {
@@ -31,3 +33,132 @@ test('global.css cannot be recreated after migration', () => {
assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/);
fs.rmSync(directory, { recursive: true, force: true });
});
test('unrooted tags, universal selectors, standalone states and sibling escapes are rejected', () => {
const selectors = ['body button', '* button', 'h1', '.selected', ':not(.page) button', '.page + button'];
assert.deepEqual(
broadBusinessSelectors(selectors.map((selector) => `${selector} { color: red; }`).join('\n')),
selectors,
);
assert.deepEqual(broadBusinessSelectors('@keyframes fade { from { opacity: 0; } to { opacity: 1; } }'), []);
});
test('page ownership rejects unrelated classes and pseudo-class ownership tricks', () => {
assert.deepEqual(
unscopedSelectors('.page > button, .page.is-active, .page .row + .row { color: red; }', ['page']),
[],
);
for (const selector of ['.other', '.page + .other', ':has(.page)', ':is(.page, body)', '.page ~ button']) {
assert.deepEqual(unscopedSelectors(`${selector} { color: red; }`, ['page']), [selector]);
}
});
test('fingerprint detects declaration values, media conditions, declaration order and important changes', () => {
const original = '@media (max-width: 780px) { .page { color: red; display: grid; } }';
for (const modified of [
original.replace('red', 'blue'),
original.replace('780px', '781px'),
original.replace('color: red; display: grid;', 'display: grid; color: red;'),
original.replace('red;', 'red !important;'),
]) {
assert.notEqual(cssFingerprint(original), cssFingerprint(modified));
}
assert.equal(cssFingerprint('.page{color:red}'), cssFingerprint('/* note */ .page {\n color: red;\n}'));
const multiline = '.page,\n.other { transition: color 1s,\n background 2s; }';
assert.equal(cssFingerprint(multiline), cssFingerprint(multiline.replaceAll('\n', '\r\n')));
});
test('import parser ignores comments and strings and recognizes static, dynamic and CSS url imports', () => {
assert.deepEqual(
localImports(
'page.tsx',
`// import './fake.css';\nconst note = "real.css"; import './real.css'; const Page = import('./Page');`,
),
['./real.css', './Page'],
);
assert.deepEqual(localImports('entry.css', `/* @import './fake.css'; */ @import url('./real.css');`), ['./real.css']);
});
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cmpp-css-policy-'));
// Only this test-created, absolute temporary directory is removed.
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
function write(file, text) {
fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true });
fs.writeFileSync(path.join(directory, file), text);
}
write('src/main.tsx', `import { Page } from './apps/Page';`);
write('src/apps/Page.tsx', `import './Page.css'; export const Page = () => <div className="page" />;`);
write('src/apps/Page.css', '.page { color: red; }');
write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: [] }] }));
const policy = { files: [{ file: 'src/apps/Page.css', owners: ['src/apps/Page.tsx'], roots: ['page'] }] };
return { directory, write, policy };
}
test('new page with real reachable owner and matching roots passes without .git', (t) => {
const { directory, policy } = fixture(t);
assert.doesNotThrow(() => verifyOwnership(directory, policy));
});
test('comment-only imports, disconnected imports and wrong same-name paths fail', (t) => {
const { directory, policy, write } = fixture(t);
write('src/apps/Page.tsx', `// import './Page.css';\nexport const Page = () => <div className="page" />;`);
assert.throws(() => verifyOwnership(directory, policy), /不可达/);
write('src/apps/Page.tsx', `import './Page.css'; export const Page = () => <div className="page" />;`);
write('src/main.tsx', 'const note = "Page.tsx";');
assert.throws(() => verifyOwnership(directory, policy), /不可达/);
write('src/main.tsx', `import './apps/Page';`);
write('src/apps/Page.tsx', `import './missing/Page.css';`);
assert.throws(() => verifyOwnership(directory, policy), /import 不存在/);
});
test('ownership registration must match exact importers and rendered class names', (t) => {
const { directory, policy, write } = fixture(t);
policy.files[0].owners = ['src/main.tsx'];
assert.throws(() => verifyOwnership(directory, policy), /所有者与登记不一致/);
policy.files[0].owners = ['src/apps/Page.tsx'];
write(
'src/apps/Page.tsx',
`import './Page.css'; const note = 'page'; export const Page = () => <div className="different" />;`,
);
assert.throws(() => verifyOwnership(directory, policy), /className/);
});
test('unregistered files and new important fail in archive mode too', (t) => {
const { directory, policy, write } = fixture(t);
write('src/apps/Page.css', '.page { color: red !important; }');
assert.throws(() => verifyOwnership(directory, policy), /important/);
write('src/apps/Page.css', '.page { color: red; }');
write('src/apps/Unknown.css', '.unknown { color: red; }');
assert.throws(() => verifyOwnership(directory, policy), /必须登记/);
});
test('historical declaration or media change fails even when AST counts match', (t) => {
const { directory, policy, write } = fixture(t);
Object.assign(policy.files[0], {
legacyFingerprint: cssFingerprint('.page { color: red; }'),
reason: 'legacy',
removalCondition: 'review',
});
assert.doesNotThrow(() => verifyOwnership(directory, policy));
write('src/apps/Page.css', '.page { color: blue; }');
assert.throws(() => verifyOwnership(directory, policy), /历史 CSS 内容/);
});
test('Stylelint wildcard compatibility expansion is rejected', (t) => {
const { directory, policy, write } = fixture(t);
write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: ['src/styles/*.css'] }] }));
assert.throws(() => verifyOwnership(directory, policy), /精确历史文件清单/);
});
test('new stylesheet under legacy directories actually receives strict Stylelint rules', async () => {
for (const file of ['src/styles/new-page.css', 'src/styles/domains/15-new-page.css', 'src/apps/NewPage.css']) {
const config = await stylelint.resolveConfig(file);
assert.deepEqual(config.rules['no-duplicate-selectors'], [true]);
const result = await stylelint.lint({
code: '.page { color: red; }\n.page { color: blue; }',
codeFilename: path.resolve(file),
});
assert.ok(result.results[0].warnings.some((warning) => warning.rule === 'no-duplicate-selectors'));
}
});