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 }; }