#!/usr/bin/env node // tools/check-l1-sync.mjs — L1 设计真相源同源闸(四道,纯 Node 零依赖) // // 治理依据:CLAUDE.md「设计真相源分层」L1 层——tokens/atoms/icons 以 // .superpowers/prototype/ 为单一真源,client 与 web 只能是它的同步副本。 // CI(.gitea/workflows/checks.yml,PR + main push)与本地均可直接跑: // node tools/check-l1-sync.mjs // // 四道检查(任一违规 exit 1): // ① tokens 快照同源 原型 tokens.css ≡ client token_source/tokens.css(逐字节) // ② icons 同源 原型 icons.js 与 web icons.njk 的 symbol 同集且同内容 // ③ 官网 token 值同源 web/assets/tokens.css 与原型(:root+主题A) 同名 token 值一致 // (官网独有 --mk-*/兼容别名层、App 外壳专属 token 不算违规) // ④ web 硬编码色扫描 web/**/*.{njk,css,js} 禁裸 hex;白名单 #fff/#ffffff/#1677ff // (对齐原型 check-ds 豁免口径);行内 `ds-allow` 注释可豁免; // 排除 web/assets/tokens.css(token 定义位)与 web/dist/。 import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const read = (p) => readFileSync(join(ROOT, p), 'utf8'); const problems = []; // ── ① tokens 快照同源 ────────────────────────────────────── { const proto = read('.superpowers/prototype/tokens.css'); const snap = read('client/lib/core/theme/token_source/tokens.css'); if (proto !== snap) { problems.push( '[tokens快照] .superpowers/prototype/tokens.css 与 ' + 'client/lib/core/theme/token_source/tokens.css 不一致 —— ' + '改原型后需拷贝快照并重跑 client/lib/core/theme/tool/gen_tokens.mjs', ); } } // ── ② icons 同源 ─────────────────────────────────────────── { const symbols = (s) => { const map = {}; for (const [, id, body] of s.matchAll(/]*>(.*?)<\/symbol>/gs)) { map[id] = body.trim(); } return map; }; const a = symbols(read('.superpowers/prototype/screens/icons.js')); const b = symbols(read('web/_includes/icons.njk')); for (const id of Object.keys(a)) { if (!(id in b)) problems.push(`[icons] symbol "${id}" 仅原型有,web/_includes/icons.njk 缺 —— 改原型后同步官网`); } for (const id of Object.keys(b)) { if (!(id in a)) problems.push(`[icons] symbol "${id}" 仅官网有 —— 图标必须先登记原型 icons.js 再同步`); } for (const id of Object.keys(a)) { if (id in b && a[id] !== b[id]) problems.push(`[icons] symbol "${id}" 两端内容不一致`); } } // ── ③ 官网 token 值同源 ──────────────────────────────────── { const vars = (css) => Object.fromEntries( [...css.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)[;}]/g)].map(([, k, v]) => [k, v.replace(/\s+/g, '')]), ); const proto = read('.superpowers/prototype/tokens.css'); const root = proto.match(/:root\s*{([^}]*)}/s)?.[1] ?? ''; const themeA = proto.match(/\[data-theme="a"\]\s*{([^}]*)}/s)?.[1] ?? ''; const pv = { ...vars(root), ...vars(themeA) }; const wv = vars(read('web/assets/tokens.css')); for (const [k, v] of Object.entries(pv)) { if (k in wv && wv[k] !== v) { problems.push(`[web-token] ${k} 值漂移:原型=${v} 官网=${wv[k]} —— web/assets/tokens.css 需同步原型主题 A`); } } } // ── ④ web 硬编码色扫描 ───────────────────────────────────── { const ALLOW_HEX = new Set(['fff', 'ffffff', '1677ff']); // 白 + 支付宝品牌蓝(同原型 check-ds 口径) const SKIP = new Set(['web/assets/tokens.css']); // token 定义位 const files = []; const walk = (dir) => { for (const name of readdirSync(join(ROOT, dir))) { const rel = `${dir}/${name}`; if (rel === 'web/dist' || name === 'node_modules') continue; const st = statSync(join(ROOT, rel)); if (st.isDirectory()) walk(rel); else if (/\.(njk|css|js)$/.test(name) && !SKIP.has(rel)) files.push(rel); } }; walk('web'); for (const f of files) { const lines = read(f).split('\n'); lines.forEach((line, i) => { if (line.includes('ds-allow')) return; for (const [hex] of line.matchAll(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) { const v = hex.slice(1).toLowerCase(); if (!ALLOW_HEX.has(v)) problems.push(`[web-hex] ${f}:${i + 1} 硬编码颜色 ${hex} —— 改用 var(--token)(web/assets/tokens.css),确需保留加 ds-allow 注释`); } }); } } // ── 汇总 ─────────────────────────────────────────────────── const line = '='.repeat(60); if (problems.length) { console.error(line); console.error(`✗ L1 真相源同源闸未过(${problems.length} 处):`); for (const p of problems) console.error(' · ' + p); console.error(line); process.exit(1); } console.log(line); console.log('✓ 通过:L1 真相源同源(tokens 快照 / icons / 官网 token 值 / web 无硬编码色)'); console.log(line);