diff --git a/client/tool/check_ds_code.mjs b/client/tool/check_ds_code.mjs new file mode 100644 index 0000000..62bd916 --- /dev/null +++ b/client/tool/check_ds_code.mjs @@ -0,0 +1,92 @@ +// client/tool/check_ds_code.mjs +// Flutter 代码端「设计真相源」闸 —— 与原型 check-ds.mjs 对应,把颜色单源纪律延伸到 +// Flutter 业务代码。红线:颜色只走语义 token(PangolinScheme via context / PangolinColors +// / PangolinShadow 等,均从 design/prototype/tokens.css codegen 单源),禁硬编码 hex +// (Color(0x..))与具名 Material 色(Colors.red 等)。合理特例须显式 `// ds-ignore: 理由`。 +// +// 用法(在 client/ 下): +// node tool/check_ds_code.mjs # 全量扫描,仅报告(不 fail) +// node tool/check_ds_code.mjs --strict # 全量,有违规即 exit 1(CI) +// node tool/check_ds_code.mjs --changed # 仅扫 git 改动的 dart,有违规即 exit 1(pre-commit) +// +// 豁免:行加 `// ds-ignore: <理由>`(理由必填,便于审计)。 +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // client/ +const LIB = path.join(ROOT, 'lib'); +const args = new Set(process.argv.slice(2)); +const CHANGED = args.has('--changed'); +const STRICT = args.has('--strict') || CHANGED; + +// 违规模式:硬编码 hex 颜色;具名 Material 色(Colors.x,transparent 除外=无色合理)。 +// 注:PangolinColors.x 不会命中(\bColors 前有词字符 n,无词边界)。 +const HEX = /Color\(0x[0-9A-Fa-f]{6,8}\)/; +const NAMED = /\bColors\.(?!transparent\b)[a-zA-Z]\w*/; +const IGNORE = /\/\/\s*ds-ignore/; + +// 文件级豁免:token 生成层 + 主题实现层——它们**定义** token 的原始色值(合法用 +// Color(0x..)),是颜色单源的落地位,不该被自己的闸拦。 +const EXEMPT_FILES = [ + 'lib/pangolin_tokens.gen.dart', // codegen 产物(色阶原始 hex 定义位) + 'lib/pangolin_theme.dart', // 实现层(PangolinScheme 语义色映射) +]; + +function listDartFiles() { + if (CHANGED) { + let out = ''; + try { + out = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACM', 'HEAD'], { + cwd: ROOT, + encoding: 'utf8', + }); + } catch { + out = ''; + } + return out + .split('\n') + .map((s) => s.trim()) + .filter((f) => f.endsWith('.dart') && !f.endsWith('.gen.dart') && !f.endsWith('.g.dart')) + .map((f) => path.resolve(ROOT, '..', f)) // git 路径相对仓库根 + .filter((f) => f.startsWith(LIB) && fs.existsSync(f)); + } + const out = []; + (function walk(d) { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith('.dart') && !e.name.endsWith('.gen.dart') && !e.name.endsWith('.g.dart')) out.push(p); + } + })(LIB); + return out; +} + +const isExempt = (f) => EXEMPT_FILES.some((e) => f.replaceAll(path.sep, '/').endsWith(e)); + +const violations = []; +for (const file of listDartFiles()) { + if (isExempt(file)) continue; + const lines = fs.readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + if (IGNORE.test(line)) return; // 显式豁免 + // 只扫代码部分:剥掉行尾 `//` 注释(注释里提及 Colors.x 作说明不算违规)。 + const code = line.split('//')[0]; + const m = HEX.exec(code) || NAMED.exec(code); + if (m) { + violations.push({ file: path.relative(ROOT, file), line: i + 1, hit: m[0], text: line.trim().slice(0, 90) }); + } + }); +} + +const banner = (s) => `\n${'─'.repeat(60)}\n${s}\n${'─'.repeat(60)}`; +if (violations.length) { + console.log(banner(`✗ Flutter 硬编码颜色 ${violations.length} 处(应走 token,或显式 // ds-ignore: 理由)`)); + for (const v of violations) console.log(` ${v.file}:${v.line} [${v.hit}] ${v.text}`); +} else { + console.log(banner('✓ 通过:Flutter 颜色全部走 token / 已显式豁免')); +} + +if (STRICT && violations.length) process.exit(1); +console.log(`\n范围:${CHANGED ? 'git 改动文件' : '全量 lib/'} 违规:${violations.length}${STRICT ? '(strict)' : '(仅报告)'}`); diff --git a/design/prototype/tools/check-ds.mjs b/design/prototype/tools/check-ds.mjs new file mode 100644 index 0000000..fbf5d7a --- /dev/null +++ b/design/prototype/tools/check-ds.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// design/prototype/tools/check-ds.mjs — 原型单源守门检查(纯 Node 零依赖)。 +// 扫 atoms.css + index.html,强制颜色/字体/圆角走 tokens.css 的 var(--token); +// 图标走 icons.js sprite;每个原子类在 index.html 登记。违规 exit 1。 +// node design/prototype/tools/check-ds.mjs +// 行内 `ds-allow` 豁免;SVG 内 fill/stroke 豁免。 +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // design/prototype/ +const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8'); + +// token 定义(tokens.css 的 :root + 主题块)+ atoms 里定义的少量派生 +const definedVars = new Set(); +for (const f of ['tokens.css', 'atoms.css']) for (const m of read(f).matchAll(/--([a-z0-9-]+)\s*:/gi)) definedVars.add('--' + m[1]); +// atoms 原子类 +const atomClasses = new Set(); +for (const m of read('atoms.css').matchAll(/\.([a-zA-Z][\w-]*)/g)) atomClasses.add(m[1]); +// icons.js sprite id 集(ICONS 键 → i-) +const iconIds = new Set(); +for (const m of read('icons.js').matchAll(/'([a-z0-9-]+)'\s*:\s*' —— 避免注释里 +// 的示例(如 header 注释里的 var(--token))被误判。 +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) + .replace(//g, (m) => m.replace(/[^\n]/g, ' ')); +} + +// ── 值扫描。strictColor=true 时严格查硬编码色(仅 atoms.css:原子必须走 token); +// index.html 是文档登记页,其 chrome 装饰不算原子,只查未定义 token / font-family ── +function valueScan(label, rawSrc, strictColor) { + const src = stripComments(rawSrc); + const lines = src.split('\n'); + let svgDepth = 0; + lines.forEach((line, i) => { + const depthBefore = svgDepth; + if (!line.includes('ds-allow')) { + if (strictColor) { + for (const m of line.matchAll(COLOR_RE)) { + const c = m[0]; + if (COLOR_ALLOW.has(c.toLowerCase())) continue; + const before = line.slice(0, m.index); + if (depthBefore > 0 || /(?:fill|stroke)\s*=\s*["']?$/.test(before)) continue; // SVG fill/stroke 豁免 + colorViol.push(`${label}:${i + 1} ${c}`); + } + } + for (const m of line.matchAll(VAR_RE)) if (!definedVars.has(m[1])) varViol.push(`${label}:${i + 1} var(${m[1]}) 未定义`); + for (const m of line.matchAll(/font-family:\s*([^;}\n]+)/g)) if (!/var\(--font/.test(m[1]) && !/inherit|monospace|sans-serif|system-ui/.test(m[1])) ffViol.push(`${label}:${i + 1} font-family 未走 var(--font*)`); + } + svgDepth += (line.match(//g) || []).length; + if (svgDepth < 0) svgDepth = 0; + }); +} +valueScan('atoms.css', read('atoms.css'), true); +valueScan('index.html', indexSrc, false); + +// ── 图标必须走 sprite:index.html 的 id 须在 icons.js 登记 ── +for (const m of indexSrc.matchAll(/]*href="#(i-[\w-]+)"/g)) { + if (!iconIds.has(m[1])) iconViol.push(`index.html 未在 icons.js 登记`); +} + +// ── 组件原子登记:atoms.css 每个 class 须在 index.html 出现(活文档完整性)── +// 跳过明显的修饰/状态子类(-primary/-ghost 等变体随基类展示即可),只查基类原子。 +const BASE_ATOM = /^(btn|card|input|field|flabel|pill|badge|langsel|menu)/; +for (const c of atomClasses) { + if (!BASE_ATOM.test(c)) continue; + const re = new RegExp(`class="[^"]*\\b${c}\\b|\\.${c}[\\s{:.,]`); + if (!re.test(indexSrc)) regViol.push(`.${c} ← 未在 index.html 登记展示`); +} + +const banner = (s) => `\n${'='.repeat(60)}\n${s}\n${'='.repeat(60)}`; +const sec = (n, arr) => { console.log(banner(`${n} 共 ${arr.length}`)); console.log(arr.length ? arr.join('\n') : '(无)✓'); }; +console.log(banner('原型单源守门检查')); +console.log(`token: ${definedVars.size} · 原子类: ${atomClasses.size} · 图标: ${iconIds.size}`); +sec('❶ 硬编码颜色', colorViol); +sec('❷ 未定义 token', varViol); +sec('❸ font-family 未走 var(--font*)', ffViol); +sec('❹ 图标未走 sprite', iconViol); +sec('❺ 组件原子未在 index.html 登记', regViol); + +const fail = [colorViol, varViol, ffViol, iconViol, regViol].reduce((s, a) => s + a.length, 0); +console.log(banner(fail ? `✗ 未通过:${fail} 处违规` : '✓ 通过:原型颜色/字体/图标走单一来源,组件已登记')); +process.exit(fail ? 1 : 0); diff --git a/docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md b/docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md index 30144e1..3e95a42 100644 --- a/docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md +++ b/docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md @@ -101,10 +101,10 @@ Flutter 已很干净(UI 层零裸 hex),只需收尾。 ## Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检 -- [ ] 5.1 硬编码色扫描闸: +- [x] 5.1 硬编码色扫描闸: - Flutter `client/tool/check_ds_code.mjs`(照搬 jiu,含 `--changed` 供 pre-commit):禁 `Color(0x..)`/裸 `Colors.x` - Web hex 扫描并入 `check-l1-sync.mjs` ④ -- [ ] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪) +- [x] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪) - [ ] 5.3 CI 串起来(`.gitea/workflows/ci.yml` 增补): 原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(已有,补 mobile+主题) - [ ] 5.4 启用 pre-commit:`ci/install-hooks.sh` 纳入 onboarding 文档 + CLAUDE.md,