ci: 原型设计系统守门闸入库 + 挂 CI(check-ds.mjs · 12 道)
.superpowers/prototype/(设计像素真源 + check-ds.mjs)移出 gitignore 入库 (brainstorm/sdd 仍忽略);新增 .gitea/workflows/ds-gate.yml:改原型即跑 node check-ds.mjs(颜色/排版/层级/圆角/字体/图标走单一真源、组件登记未 fork、 断点规范,12 道全过才放行)。runs-on mac(已有 node)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
// 设计系统守门检查器(单一真源强制)—— 扫描 screens/* + atoms.css + 共享 JS,强制走 tokens.css。
|
||||
// 用法:node tools/check-ds.mjs 违规则 exit 1
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const screens = fs.readdirSync(path.join(ROOT, 'screens')).filter(f => f.endsWith('.html'));
|
||||
// 共享 JS 模块(运行时生成 UI,也纳入颜色/数值扫描;tokens.css 是令牌之源,不扫)
|
||||
const SHARED_JS = ['shell.js', 'mobile-shell.js', 'statusbar.js', 'notify.js', 'fab.js', 'datewheel.js'].filter(f => fs.existsSync(path.join(ROOT, 'screens', f)));
|
||||
|
||||
const COLOR_ALLOW = new Set(['#fff', '#ffffff', '#1677ff', '#07c160']);
|
||||
const definedVars = new Set();
|
||||
for (const f of ['tokens.css', 'atoms.css']) for (const m of read(f).matchAll(/--([a-z0-9-]+)\s*:/gi)) definedVars.add('--' + m[1]);
|
||||
const ATOM_CSS = ['atoms.css', 'mobile-atoms.css'].filter(f => fs.existsSync(path.join(ROOT, f)));
|
||||
const atomClasses = new Set();
|
||||
for (const f of ATOM_CSS) for (const m of read(f).matchAll(/\.([a-zA-Z][\w-]*)/g)) atomClasses.add(m[1]);
|
||||
const iconIds = new Set();
|
||||
for (const m of read('screens/icons.js').matchAll(/<symbol id="(i-[\w-]+)"/g)) iconIds.add(m[1]);
|
||||
const indexSrc = read('index.html');
|
||||
|
||||
const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
|
||||
const VAR_RE = /var\(\s*(--[a-z0-9-]+)/gi;
|
||||
const FS_NAME = { 28: '--fs-display', 21: '--fs-h1', 17: '--fs-h2', 15: '--fs-title', 13: '--fs-body', 12: '--fs-sm', 11: '--fs-xs' };
|
||||
const R_NAME = { 4: '--r-sm', 6: '--r-md', 10: '--r-lg', 14: '--r-xl', 999: '--r-pill' };
|
||||
const BREAKPOINTS = new Set([600, 760, 1080]); // 规范断点;其余视为魔法数
|
||||
const COMPONENT_ATOMS = new Set(['btn', 'input', 'ctrl', 'searchbox', 'datefield', 'wheel-pop', 'chip', 'badge', 'kpi', 'combo', 'combo-pop', 'table', 'lines', 'drawer', 'drawer-mask', 'overlay', 'modal', 'menu', 'toast', 'seg', 'grid', 'mcard', 'notice', 'pager', 'dactions', 'statusbar', 'notify-pop', 'nt-item', 'nt-ic', 'datefield']);
|
||||
const CLASS_WHITELIST = new Set(['hl']);
|
||||
|
||||
let colorViol = [], varViol = [], classViol = [], fsViol = [], zViol = [], forkViol = [], importViol = [], iconViol = [], radiusViol = [], ffViol = [], bpViol = [], regViol = [];
|
||||
|
||||
// ---- 颜色/变量/font-size/z-index/圆角/字体族(screens + atoms.css + 共享 JS)----
|
||||
function valueScan(label, src) {
|
||||
const lines = src.split('\n');
|
||||
let svgDepth = 0;
|
||||
lines.forEach((line, i) => {
|
||||
const depthBefore = svgDepth;
|
||||
if (!line.includes('ds-allow')) {
|
||||
for (const m of line.matchAll(COLOR_RE)) {
|
||||
const c = m[0];
|
||||
if (COLOR_ALLOW.has(c.toLowerCase())) continue;
|
||||
const before = line.slice(0, m.index);
|
||||
if (depthBefore > 0 || /(?:fill|stroke)\s*=\s*["']?$/.test(before)) continue;
|
||||
colorViol.push(`${label}:${i + 1} ${c}`);
|
||||
}
|
||||
for (const m of line.matchAll(VAR_RE)) if (!definedVars.has(m[1])) varViol.push(`${label}:${i + 1} var(${m[1]})`);
|
||||
for (const m of line.matchAll(/font-size:\s*(\d+)px/g)) if (FS_NAME[+m[1]]) fsViol.push(`${label}:${i + 1} font-size:${m[1]}px → var(${FS_NAME[+m[1]]})`);
|
||||
for (const m of line.matchAll(/z-index:\s*(\d+)\b/g)) if (+m[1] >= 10) zViol.push(`${label}:${i + 1} z-index:${m[1]} → var(--z-*)`);
|
||||
for (const m of line.matchAll(/border-radius:\s*(\d+)px/g)) if (R_NAME[+m[1]]) radiusViol.push(`${label}:${i + 1} border-radius:${m[1]}px → var(${R_NAME[+m[1]]})`);
|
||||
for (const m of line.matchAll(/font-family:\s*([^;}\n]+)/g)) if (!/var\(--font/.test(m[1])) ffViol.push(`${label}:${i + 1} font-family 未走 var(--font*)`);
|
||||
}
|
||||
svgDepth += (line.match(/<svg/g) || []).length - (line.match(/<\/svg>/g) || []).length;
|
||||
if (svgDepth < 0) svgDepth = 0;
|
||||
});
|
||||
}
|
||||
for (const f of screens) valueScan('screens/' + f, read('screens/' + f));
|
||||
for (const f of ATOM_CSS) valueScan(f, read(f));
|
||||
for (const f of SHARED_JS) valueScan('screens/' + f, read('screens/' + f));
|
||||
|
||||
// ---- HTML 专项检查(screens)----
|
||||
function localClassesOf(src) { const set = new Set(); for (const sm of src.matchAll(/<style[\s\S]*?<\/style>/gi)) for (const m of sm[0].matchAll(/\.([a-zA-Z][\w-]*)/g)) set.add(m[1]); return set; }
|
||||
function usedClassTokens(src) {
|
||||
const found = [];
|
||||
for (const m of src.matchAll(/class\s*=\s*"([^"]*)"/g)) for (const t of m[1].split(/\s+/)) found.push(t);
|
||||
for (const m of src.matchAll(/className\s*=\s*['"]([^'"]*)['"]/g)) for (const t of m[1].split(/\s+/)) found.push(t);
|
||||
for (const m of src.matchAll(/classList\.(?:add|toggle|remove)\(([^)]*)\)/g)) for (const sm of m[1].matchAll(/['"]([\w-]+)['"]/g)) found.push(sm[1]);
|
||||
return found.filter(t => t && !t.includes('${') && !t.includes('{') && /^[a-zA-Z][\w-]*$/.test(t));
|
||||
}
|
||||
function forkAtoms(src) {
|
||||
const out = new Set();
|
||||
for (const sm of src.matchAll(/<style[\s\S]*?<\/style>/gi)) for (const rm of sm[0].matchAll(/([^{}]+)\{/g)) for (let sel of rm[1].split(','))
|
||||
{ const m = sel.trim().match(/^\.([a-zA-Z][\w-]*)$/); if (m && COMPONENT_ATOMS.has(m[1])) out.add(m[1]); }
|
||||
return out;
|
||||
}
|
||||
for (const file of screens) {
|
||||
const src = read('screens/' + file);
|
||||
const isStub = /location\.replace/.test(src) && !/<body>[\s\S]*\S[\s\S]*<\/body>/.test(src.replace(/<body><\/body>/, ''));
|
||||
const local = localClassesOf(src);
|
||||
for (const t of new Set(usedClassTokens(src))) if (!atomClasses.has(t) && !local.has(t) && !CLASS_WHITELIST.has(t)) classViol.push(`screens/${file} .${t}`);
|
||||
for (const c of forkAtoms(src)) forkViol.push(`screens/${file} .${c}{}`);
|
||||
if (!isStub && (!src.includes('tokens.css') || !src.includes('atoms.css'))) importViol.push(`screens/${file}`);
|
||||
for (const m of src.matchAll(/<svg([^>]*)>([\s\S]*?)<\/svg>/g)) {
|
||||
const inner = m[2];
|
||||
if (/rect width="64"/.test(inner) || /viewBox="0 0 64 64"/.test(m[1])) continue;
|
||||
const u = inner.match(/<use[^>]*href="#(i-[\w-]+)"/);
|
||||
if (u) { if (!iconIds.has(u[1])) iconViol.push(`screens/${file} <use #${u[1]}> 未登记`); continue; }
|
||||
if (/<(path|circle|rect|line|polyline|ellipse)\b/.test(inner)) iconViol.push(`screens/${file} 内联图标`);
|
||||
}
|
||||
// 响应式断点魔法数(仅 @media max-width)
|
||||
for (const m of src.matchAll(/@media[^{]*max-width:\s*(\d+)px/g)) if (!BREAKPOINTS.has(+m[1])) bpViol.push(`screens/${file} @media max-width:${m[1]}px(规范断点:${[...BREAKPOINTS].join('/')})`);
|
||||
}
|
||||
// 同样扫 atoms.css / mobile-atoms.css 的断点
|
||||
for (const f of ATOM_CSS) for (const m of read(f).matchAll(/@media[^{]*max-width:\s*(\d+)px/g)) if (!BREAKPOINTS.has(+m[1])) bpViol.push(`${f} @media max-width:${m[1]}px`);
|
||||
|
||||
// ---- 新原子登记:组件原子必须在 index.html 出现(活文档)----
|
||||
for (const c of COMPONENT_ATOMS) {
|
||||
const re = new RegExp(`class="[^"]*\\b${c}\\b|\\.${c}[\\s{:.,]`);
|
||||
if (!re.test(indexSrc)) regViol.push(`.${c} ← 未在 index.html 登记展示`);
|
||||
}
|
||||
|
||||
const banner = s => `\n${'='.repeat(60)}\n${s}\n${'='.repeat(60)}`;
|
||||
const sec = (n, arr) => { console.log(banner(`${n} 共 ${arr.length}`)); console.log(arr.length ? arr.join('\n') : '(无)✓'); };
|
||||
console.log(banner('设计系统守门检查(单一真源)'));
|
||||
console.log(`token: ${definedVars.size} · 原子类: ${atomClasses.size} · 图标: ${iconIds.size} · 扫描: screens(${screens.length}) + atoms.css + 共享JS(${SHARED_JS.length})`);
|
||||
sec('❶ 硬编码颜色', colorViol);
|
||||
sec('❷ 未定义 token', varViol);
|
||||
sec('❸ 未登记组件类', classViol);
|
||||
sec('❹ font-size 未走 --fs-*', fsViol);
|
||||
sec('❺ z-index 未走阶梯', zViol);
|
||||
sec('❻ fork 了 atoms 组件', forkViol);
|
||||
sec('❼ 缺 tokens/atoms 引入', importViol);
|
||||
sec('❽ 图标未走 sprite', iconViol);
|
||||
sec('❾ border-radius 未走 --r-*', radiusViol);
|
||||
sec('❿ font-family 未走 --font', ffViol);
|
||||
sec('⓫ 响应式断点魔法数', bpViol);
|
||||
sec('⓬ 组件原子未在 index.html 登记', regViol);
|
||||
|
||||
const all = [colorViol, varViol, classViol, fsViol, zViol, forkViol, importViol, iconViol, radiusViol, ffViol, bpViol, regViol];
|
||||
const fail = all.reduce((s, a) => s + a.length, 0);
|
||||
console.log(banner(fail ? `✗ 未通过:${fail} 处违规` : '✓ 通过:颜色/排版/层级/圆角/字体/图标走单一来源,组件已登记未 fork,断点规范'));
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user