Files
jiu/tools/check-l1-sync.mjs
T
wangjia 9b37568fdb
Design Source Checks / design-source (push) Successful in 16s
ci: 设计真相源检查挂 CI(PR + main push 自动跑四道闸)
- 新增 tools/check-l1-sync.mjs:L1 同源四道——tokens 快照逐字节、icons
  两端同集同内容、官网 token 值对齐原型主题 A、web 硬编码 hex 扫描
  (白名单 #fff/#1677ff,ds-allow 可豁免);注入测试验证红绿行为
- 新增 .gitea/workflows/checks.yml:pull_request + push(main) 触发,
  串跑 check-ds 12 道 / check-l1-sync / check_ds_code / codegen 新鲜度
  (regen→dart format→零 diff,消除重跑假 diff 问题)
- 修复 web/checkout.njk 对公转账图标底色硬编码 #8A94A6 → var(--faint)
- CLAUDE.md「设计真相源分层」登记 CI 闸与本地等价命令

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-04 07:14:49 +08:00

118 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.ymlPR + 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.csstoken 定义位)与 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 id="([^"]+)"[^>]*>(.*?)<\/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);