Files
jiu/tools/check-l1-sync.mjs
T
wangjia 21a4a8cab7 ci: 补三道缺口闸——CI 强制 flutter test + SSR 模板 token 同源 + 徽章图标映射同集
- checks.yml 加 flutter test(DoD 写了但此前无闸,golden 回归全靠自觉;
  本地 /release client 门禁同步加了 flutter test,该文件 gitignore 不入库)
- check-l1-sync ⑤:backend 公开页模板内联 token 值须与原型 tokens.css
  对应主题块一致(模板自包含手拷值,此前仅注释约定零校验;已注入漂移自证)
- check-l1-sync ⑥:BADGE_ICON ↔ kStatusIcons 状态词同集(此前两端手工转录)
- 顺修 gen_tokens 旧路径注释(client/lib/core/theme/tool/ → client/tool/)

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

178 lines
8.7 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/。
// ⑤ SSR 模板 token 同源 backend 公开页模板(product/shop)内联的 --token 值
// 须与原型 tokens.css 对应主题块一致(模板自包含无法引
// tokens.css,靠此闸拦静默漂移;派生变量白名单豁免)。
// ⑥ 徽章图标映射同集 原型 icons.js BADGE_ICON 与 client status_icon_map.dart
// kStatusIcons 状态词同集(此前两端手工转录无闸)。
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/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 注释`);
}
});
}
}
// ── ⑤ SSR 模板 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 = vars(proto.match(/:root\s*{([^}]*)}/s)?.[1] ?? '');
const theme = (t) => vars(proto.match(new RegExp(`\\[data-theme="${t}"\\]\\s*{([^}]*)}`, 's'))?.[1] ?? '');
// 模板私有派生变量(主色低透明度/表面高透明度,模板头注释声明来源)
const DERIVED = new Set(['--glow1', '--glow2', '--seal-bg']);
const TEMPLATES = [
'backend/internal/handler/templates/public_product.html',
'backend/internal/handler/templates/public_shop.html',
];
for (const f of TEMPLATES) {
const html = read(f);
const blocks = [
[':root(主题A)', /:root\s*{([^}]*)}/s, { ...root, ...theme('a') }],
['主题B', /\[data-theme="b"\]\s*{([^}]*)}/s, { ...root, ...theme('b') }],
['主题C', /\[data-theme="c"\]\s*{([^}]*)}/s, { ...root, ...theme('c') }],
];
for (const [label, re, pv] of blocks) {
const body = html.match(re)?.[1];
if (!body) continue;
for (const [k, v] of Object.entries(vars(body))) {
if (DERIVED.has(k)) continue;
if (!(k in pv)) {
problems.push(`[ssr-token] ${f} ${label} 私造 token ${k} —— 先登记原型 tokens.css(或列入 DERIVED 白名单)`);
} else if (pv[k] !== v) {
problems.push(`[ssr-token] ${f} ${label} ${k} 值漂移:原型=${pv[k]} 模板=${v} —— 改配色先改原型再同步模板`);
}
}
}
}
}
// ── ⑥ 徽章图标映射同集(BADGE_ICON ↔ kStatusIcons)─────────
{
const keysOf = (body) => new Set([...body.matchAll(/'([^']+)'\s*:/g)].map((m) => m[1]));
const js = read('.superpowers/prototype/screens/icons.js');
const protoKeys = keysOf(js.match(/BADGE_ICON\s*=\s*{([\s\S]*?)}/)?.[1] ?? '');
const dart = read('client/lib/widgets/ds/status_icon_map.dart');
const dartKeys = keysOf(dart.match(/kStatusIcons\s*=[\s\S]*?{([\s\S]*?)};/)?.[1] ?? '');
if (!protoKeys.size || !dartKeys.size) {
problems.push('[badge-icon] BADGE_ICON 或 kStatusIcons 解析为空 —— 检查文件结构是否变更');
}
for (const k of protoKeys) {
if (!dartKeys.has(k)) problems.push(`[badge-icon] 状态词「${k}」仅原型 BADGE_ICON 有 —— 同步 client/lib/widgets/ds/status_icon_map.dart`);
}
for (const k of dartKeys) {
if (!protoKeys.has(k)) problems.push(`[badge-icon] 状态词「${k}」仅 Flutter kStatusIcons 有 —— 新状态先登记原型 icons.js`);
}
}
// ── 汇总 ───────────────────────────────────────────────────
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 hex / SSR 模板 token / 徽章图标映射)');
console.log(line);