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
This commit is contained in:
wangjia
2026-07-07 18:24:34 +08:00
parent 8912b8ef7f
commit 21a4a8cab7
3 changed files with 72 additions and 6 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ export PATH="/opt/homebrew/bin:$PATH"
```
- **client**
```bash
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings && flutter test
```
- **site**
```bash
+8 -2
View File
@@ -1,8 +1,9 @@
name: Design Source Checks
# 设计真相源检查(治理规则见 CLAUDE.md「设计真相源分层」):
# PR 与合并 main 时自动跑四道闸,全部纯静态检查node + dart format
# 不跑 golden/fidelity(重型像素闸维持「定期体检」定位,本地手动跑)。
# PR 与合并 main 时自动跑四道静态闸(node + dart format+ flutter test
# (单测 + golden ×3 主题回归自比,2026-07-07 纳入——此前 DoD 写了但无闸)。
# fidelity 重型像素闸维持「定期体检」定位,本地手动跑。
on:
pull_request:
push:
@@ -37,3 +38,8 @@ jobs:
node tool/gen_tokens.mjs
dart format lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
git diff --exit-code -- lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
- name: Flutter 测试(单测 + golden ×3 主题回归自比)
run: |
cd client
flutter test
+63 -3
View File
@@ -6,7 +6,7 @@
// CI.gitea/workflows/checks.ymlPR + main push)与本地均可直接跑:
// node tools/check-l1-sync.mjs
//
// 道检查(任一违规 exit 1):
// 道检查(任一违规 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 值一致
@@ -14,6 +14,11 @@
// ④ 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';
@@ -31,7 +36,7 @@ const problems = [];
problems.push(
'[tokens快照] .superpowers/prototype/tokens.css 与 ' +
'client/lib/core/theme/token_source/tokens.css 不一致 —— ' +
'改原型后需拷贝快照并重跑 client/lib/core/theme/tool/gen_tokens.mjs',
'改原型后需拷贝快照并重跑 client/tool/gen_tokens.mjs',
);
}
}
@@ -103,6 +108,61 @@ const problems = [];
}
}
// ── ⑤ 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) {
@@ -113,5 +173,5 @@ if (problems.length) {
process.exit(1);
}
console.log(line);
console.log('✓ 通过:L1 真相源同源(tokens 快照 / icons / 官网 token / web 无硬编码色');
console.log('✓ 通过:L1 真相源同源(tokens 快照 / icons / 官网 token / web hex / SSR 模板 token / 徽章图标映射');
console.log(line);