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:
wangjia
2026-06-25 11:24:55 +08:00
parent deec108c52
commit c3756925b1
62 changed files with 8302 additions and 2 deletions
+123
View File
@@ -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);