#!/usr/bin/env node /** * check-code.mjs — 代码侧颜色/图标单源闸(分端扫描,零依赖,Node >= 18) * * 各端规则(违规 exit 1;行内 `ds-ignore: 理由` 豁免): * desktop/src(jsx/css/js):禁字面色(hex/rgb/hsl),须 var(--*); * 禁内联 (图标须经 @dudu/design 的 Icon/icons.js) * ios(swift) :禁 Color(red:/UIColor(/Color(0x/#colorLiteral,须 DuduTheme; * systemName 用的 SF Symbol 须 ∈ design/icon-map.json 的 ios 列 * android(kt) :禁 Color(0x,须 DuduTheme(生成文件本身豁免) * web(html/css) :禁字面色(令牌由 GENERATED tokens.css 提供,页面只允许 var(--*)) * * 用法:node design-pipeline/check-code.mjs */ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { resolve, dirname, join, relative, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dir = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dir, '..'); const errors = []; const err = (file, line, msg) => errors.push(`${relative(ROOT, file)}:${line} ${msg}`); function walk(dir, exts, out = []) { if (!existsSync(dir)) return out; for (const name of readdirSync(dir)) { if (name === 'node_modules' || name === 'dist' || name === 'target' || name.startsWith('.')) continue; const p = join(dir, name); if (statSync(p).isDirectory()) walk(p, exts, out); else if (exts.some((e) => name.endsWith(e))) out.push(p); } return out; } const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?)\(/g; function scanColors(files, label) { for (const f of files) { readFileSync(f, 'utf8').split('\n').forEach((line, i) => { if (line.includes('ds-ignore:')) return; const stripped = line.replace(/var\(--[\w-]+\)/g, ''); // var() 里不含字面色,先剥掉便于报错定位 const m = stripped.match(COLOR_RE); if (m) err(f, i + 1, `${label} 字面色 ${m[0]}(改用设计令牌,或加 ds-ignore: 理由)`); }); } } // ─── desktop:字面色 + 内联 ─────────────────────────────────────────── const desktopFiles = walk(join(ROOT, 'desktop/src'), ['.jsx', '.js', '.css', '.html']); scanColors(desktopFiles, 'desktop'); for (const f of desktopFiles) { readFileSync(f, 'utf8').split('\n').forEach((line, i) => { if (line.includes('ds-ignore:')) return; if (/]/.test(line)) err(f, i + 1, 'desktop 内联 (图标须登记 design/icons.js 并经 Icon 组件渲染)'); }); } // ─── iOS:字面色 + SF Symbol 同集 ──────────────────────────────────────────── const iconMap = JSON.parse(readFileSync(join(ROOT, 'design/icon-map.json'), 'utf8')); const iosSymbols = new Set( Object.entries(iconMap).filter(([k]) => k !== '$schema').map(([, v]) => v.ios).filter(Boolean) ); for (const f of walk(join(ROOT, 'ios'), ['.swift'])) { const isGenerated = basename(f) === 'DuduTheme.swift'; readFileSync(f, 'utf8').split('\n').forEach((line, i) => { if (line.includes('ds-ignore:')) return; if (!isGenerated && /Color\(red:|UIColor\(red:|Color\(\s*0x|#colorLiteral/.test(line)) err(f, i + 1, 'iOS 字面色(改用 DuduTheme.*,或加 ds-ignore: 理由)'); for (const m of line.matchAll(/systemName:\s*"([^"]+)"/g)) { if (!iosSymbols.has(m[1])) err(f, i + 1, `iOS SF Symbol "${m[1]}" 未登记 design/icon-map.json(ios 列)`); } }); } // ─── Android:字面色 ───────────────────────────────────────────────────────── for (const f of walk(join(ROOT, 'android/app/src'), ['.kt'])) { if (basename(f) === 'DuduTheme.kt') continue; // codegen 产物 readFileSync(f, 'utf8').split('\n').forEach((line, i) => { if (line.includes('ds-ignore:')) return; if (/Color\(\s*0x/.test(line)) err(f, i + 1, 'Android 字面色(改用 DuduTheme / DuduPalette,或加 ds-ignore: 理由)'); }); } // ─── web:字面色(tokens.css 为 GENERATED 豁免)────────────────────────────── scanColors( walk(join(ROOT, 'web'), ['.html', '.css']).filter((f) => basename(f) !== 'tokens.css'), 'web' ); // ─── 汇总 ──────────────────────────────────────────────────────────────────── if (errors.length) { console.error(`❌ check-code:${errors.length} 处违规\n` + errors.map((e) => ' ' + e).join('\n')); process.exit(1); } console.log('✅ check-code:desktop / iOS / Android / web 单源全绿');