05203a9b5b
Design Source Checks / design-source (push) Failing after 14m23s
- git mv 全目录(历史保留);.gitignore 收敛为整个 .superpowers/ 忽略 - 全仓 16 处引用同步(CI checks.yml / l1-sync / screens.mjs / hooks / CLAUDE.md / CONTRACT / SSR 模板注释 / docs / web 注释) - 修 pre-commit 路径正则残留;5180 评审服务已切新路径 - 验证:check-ds 12 道 / l1-sync 6 道 / fidelity 抽查全过 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
178 lines
8.6 KiB
JavaScript
178 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
||
// tools/check-l1-sync.mjs — L1 设计真相源同源闸(四道,纯 Node 零依赖)
|
||
//
|
||
// 治理依据:CLAUDE.md「设计真相源分层」L1 层——tokens/atoms/icons 以
|
||
// design/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/。
|
||
// ⑤ 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('design/prototype/tokens.css');
|
||
const snap = read('client/lib/core/theme/token_source/tokens.css');
|
||
if (proto !== snap) {
|
||
problems.push(
|
||
'[tokens快照] design/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('design/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('design/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('design/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('design/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);
|