#!/usr/bin/env node /** * P0 设计 token 静态漂移守护(只读扫描)。 * * 扫描范围:src/renderer/src 下的 .css / .tsx / .ts。 * 统计:裸 hex、裸 px、!important、--wb-* 定义、var(--yc-*) / var(--wb-*) 使用。 * * 用法: * node scripts/check-design-tokens.mjs 对比 baseline,出现回归退出码 1 * node scripts/check-design-tokens.mjs --json 输出机器可读 JSON(stdout 仅 JSON) * node scripts/check-design-tokens.mjs --update-baseline 全量重写 baseline(唯一写操作) * * 分类(按顺序匹配): * vendor 路径包含 source-code-assets 目录 * recovered 文件名或目录名含 recovered * adapter *-adapter.css / *-adapters.css * source source.css / source-*.css / *-source.css * core 路径包含 styles/ 目录 * tokenSource 命中 TOKEN_SOURCE_PATHS 的精确路径(token 源文件,唯一允许写裸值的位置) * local 其余 * 门禁分类:core / local / adapter;source / recovered / vendor / tokenSource 只记录与摘要,不阻塞。 * 失败条件(仅四项):新增门禁文件含裸 hex / 裸 px / !important / --wb-* 定义; * 存量门禁文件这四项计数高于 baseline。var(--yc-*) / var(--wb-*) 使用数只记录与提示。 */ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(SCRIPT_DIR, '..') const SCAN_ROOT = join(REPO_ROOT, 'src', 'renderer', 'src') const SCAN_ROOT_LABEL = 'src/renderer/src' const BASELINE_PATH = join(SCRIPT_DIR, 'design-token-baseline.json') const BASELINE_LABEL = 'scripts/design-token-baseline.json' const BASELINE_VERSION = 1 const SCAN_EXTENSIONS = new Set(['.css', '.ts', '.tsx']) const CLASS_ORDER = ['vendor', 'recovered', 'adapter', 'source', 'core', 'tokenSource', 'local'] const GATED_CLASSES = new Set(['core', 'local', 'adapter']) // token 源文件:唯一允许写裸 hex/px/!important/--wb-* 定义的位置。 // 按规范化(小写、/ 分隔)后的精确路径豁免,不做文件名通配,避免页面级 tokens.css 逃逸。 const TOKEN_SOURCE_PATHS = new Set([ 'src/renderer/src/styles/tokens.css', 'src/renderer/src/workbuddy/typography/tokens.css', 'src/renderer/src/ui/tokens.css' ]) const METRIC_LABELS = { bareHex: '裸 hex', barePx: '裸 px', important: '!important', wbDefinitions: '--wb-* 定义', ycVarUses: 'var(--yc-*) 使用', wbVarUses: 'var(--wb-*) 使用' } const METRICS = ['bareHex', 'barePx', 'important', 'wbDefinitions', 'ycVarUses', 'wbVarUses'] // 门禁计数:新增与存量门禁文件都只对这四个指标的上升失败;var 使用数只提示。 const GATE_METRICS = ['bareHex', 'barePx', 'important', 'wbDefinitions'] const METRIC_PATTERNS = { bareHex: () => /#[0-9a-fA-F]{3,8}\b/g, barePx: () => /(? /!\s*important/gi, wbDefinitions: () => /--wb-[\w-]+\s*:/g, ycVarUses: () => /var\(\s*--yc-[\w-]*/g, wbVarUses: () => /var\(\s*--wb-[\w-]*/g } const MAX_LISTED = 20 function parseArgs(argv) { const options = { updateBaseline: false, json: false, help: false } for (const arg of argv) { if (arg === '--update-baseline') options.updateBaseline = true else if (arg === '--json') options.json = true else if (arg === '--help' || arg === '-h') options.help = true else throw new Error(`未知参数:${arg}`) } return options } function printUsage() { console.log( [ '用法:node scripts/check-design-tokens.mjs [--json] [--update-baseline]', '', ' --json 输出机器可读 JSON(stdout 仅 JSON)', ' --update-baseline 全量重写 scripts/design-token-baseline.json', ' --help 显示本帮助' ].join('\n') ) } function toPosix(value) { return value.split(sep).join('/') } function zeroCounts() { const counts = {} for (const metric of METRICS) counts[metric] = 0 return counts } function normalizeCounts(value) { const counts = zeroCounts() if (!value || typeof value !== 'object') return counts for (const metric of METRICS) { const raw = value[metric] counts[metric] = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0 } return counts } function isSourceCssName(lowerName) { if (!lowerName.endsWith('.css')) return false return lowerName === 'source.css' || lowerName.startsWith('source-') || lowerName.endsWith('-source.css') } function isAdapterCssName(lowerName) { return lowerName.endsWith('-adapter.css') || lowerName.endsWith('-adapters.css') } function classifyPath(relPath) { if (TOKEN_SOURCE_PATHS.has(relPath.toLowerCase())) return 'tokenSource' const segments = relPath.split('/') const fileName = segments[segments.length - 1] const lowerName = fileName.toLowerCase() const directories = segments.slice(0, -1) if (directories.includes('source-code-assets')) return 'vendor' if (segments.some((segment) => segment.toLowerCase().includes('recovered'))) return 'recovered' if (isAdapterCssName(lowerName)) return 'adapter' if (isSourceCssName(lowerName)) return 'source' if (directories.includes('styles')) return 'core' return 'local' } function listScannableFiles(dir) { const results = [] const stack = [dir] while (stack.length > 0) { const current = stack.pop() let entries try { entries = readdirSync(current, { withFileTypes: true }) } catch { continue } for (const entry of entries) { if (entry.name.startsWith('.') || entry.name === 'node_modules') continue const fullPath = join(current, entry.name) if (entry.isDirectory()) stack.push(fullPath) else if (entry.isFile() && SCAN_EXTENSIONS.has(extname(entry.name).toLowerCase())) results.push(fullPath) } } return results.sort() } function analyzeSource(text) { const counts = zeroCounts() for (const metric of METRICS) { const matches = text.match(METRIC_PATTERNS[metric]()) counts[metric] = matches ? matches.length : 0 } return counts } function scanFiles() { const startedAt = Date.now() const files = [] const unreadable = [] for (const absolutePath of listScannableFiles(SCAN_ROOT)) { const path = toPosix(relative(REPO_ROOT, absolutePath)) let text try { text = readFileSync(absolutePath, 'utf8') } catch (error) { unreadable.push({ path, error: String(error && error.message ? error.message : error) }) continue } files.push({ path, class: classifyPath(path), counts: analyzeSource(text) }) } files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) return { files, unreadable, durationMs: Date.now() - startedAt } } function buildBaseline(files) { const entries = {} for (const file of files) { entries[file.path] = { class: file.class, counts: file.counts } } return { version: BASELINE_VERSION, description: '设计 token 静态计数 baseline,由 scripts/check-design-tokens.mjs --update-baseline 生成。不要手改。', scanRoot: SCAN_ROOT_LABEL, metrics: METRICS, gatedClasses: CLASS_ORDER.filter((name) => GATED_CLASSES.has(name)), files: entries } } function loadBaseline() { if (!existsSync(BASELINE_PATH)) return null let parsed try { parsed = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) } catch (error) { throw new Error(`baseline 不是合法 JSON:${BASELINE_LABEL}(${error.message})`) } if (!parsed || typeof parsed !== 'object' || !parsed.files || typeof parsed.files !== 'object') { throw new Error(`baseline 结构无法识别:${BASELINE_LABEL}`) } return parsed } function summarize(files) { const byClass = {} for (const name of CLASS_ORDER) byClass[name] = { files: 0, counts: zeroCounts() } for (const file of files) { const bucket = byClass[file.class] ?? byClass.local bucket.files += 1 for (const metric of METRICS) bucket.counts[metric] += file.counts[metric] } const gated = { files: 0, counts: zeroCounts() } const nonGated = { files: 0, counts: zeroCounts() } for (const name of CLASS_ORDER) { const target = GATED_CLASSES.has(name) ? gated : nonGated target.files += byClass[name].files for (const metric of METRICS) target.counts[metric] += byClass[name].counts[metric] } return { byClass, gated, nonGated } } function baselineNonGatedTotals(baselineFiles) { const totals = { files: 0, counts: zeroCounts() } for (const entry of Object.values(baselineFiles)) { if (GATED_CLASSES.has(entry && entry.class)) continue totals.files += 1 const counts = normalizeCounts(entry && entry.counts) for (const metric of METRICS) totals.counts[metric] += counts[metric] } return totals } function compareWithBaseline(scan, baselineFiles) { const violations = [] const notices = { deleted: [], newGatedFilesWithoutCounts: [], usageIncreases: [] } const currentByPath = new Set(scan.files.map((file) => file.path)) for (const file of scan.files) { const previous = baselineFiles[file.path] if (!previous) { if (!GATED_CLASSES.has(file.class)) continue const hits = GATE_METRICS.filter((metric) => file.counts[metric] > 0) if (hits.length > 0) { violations.push({ type: 'new-file', path: file.path, class: file.class, metrics: hits.map((metric) => ({ metric, current: file.counts[metric] })) }) } else { notices.newGatedFilesWithoutCounts.push({ path: file.path, class: file.class }) } continue } if (!GATED_CLASSES.has(file.class)) continue const previousCounts = normalizeCounts(previous.counts) const increases = [] const usageIncreases = [] for (const metric of METRICS) { if (file.counts[metric] > previousCounts[metric]) { const increase = { metric, from: previousCounts[metric], to: file.counts[metric] } if (GATE_METRICS.includes(metric)) increases.push(increase) else usageIncreases.push(increase) } } if (increases.length > 0) { violations.push({ type: 'regression', path: file.path, class: file.class, increases }) } if (usageIncreases.length > 0) { notices.usageIncreases.push({ path: file.path, class: file.class, increases: usageIncreases }) } } for (const [path, entry] of Object.entries(baselineFiles)) { if (!currentByPath.has(path)) notices.deleted.push({ path, class: (entry && entry.class) ?? 'unknown' }) } notices.deleted.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) notices.usageIncreases.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) for (const unreadable of scan.unreadable) { violations.push({ type: 'unreadable', path: unreadable.path, error: unreadable.error }) } return { violations, notices } } function formatCounts(counts) { return METRICS.map((metric) => `${METRIC_LABELS[metric]}=${counts[metric]}`).join(' ') } function formatDelta(delta) { return METRICS.map((metric) => `${METRIC_LABELS[metric]} ${delta[metric] >= 0 ? '+' : ''}${delta[metric]}`).join(' ') } function formatList(items, limit = MAX_LISTED) { const shown = items.slice(0, limit) const rest = items.length - shown.length return rest > 0 ? `${shown.join(';')};…(还有 ${rest} 项,详见 --json)` : shown.join(';') } function describeViolation(violation) { if (violation.type === 'regression') { const detail = violation.increases .map((item) => `${METRIC_LABELS[item.metric]} ${item.from} → ${item.to}(+${item.to - item.from})`) .join(';') return `[回归] ${violation.path}(${violation.class}):${detail}` } if (violation.type === 'new-file') { const detail = violation.metrics.map((item) => `${METRIC_LABELS[item.metric]}=${item.current}`).join(',') return `[新增门禁文件] ${violation.path}(${violation.class}):${detail}` } return `[无法读取] ${violation.path}:${violation.error}` } function printHuman(result) { const lines = [] lines.push('设计 token 漂移检查(静态)') const fileCount = result.scan.files.length lines.push(` 扫描:${SCAN_ROOT_LABEL} 共 ${fileCount} 个文件(用时 ${result.scan.durationMs} ms)`) if (result.updated) { lines.push(` baseline:已写入 ${BASELINE_LABEL}(${fileCount} 个文件)`) } else { lines.push(` baseline:${BASELINE_LABEL}(${result.baselineFiles} 个文件)`) } const summary = result.summary lines.push( ` 门禁分类:${CLASS_ORDER.filter((name) => GATED_CLASSES.has(name)) .map((name) => `${name} ${summary.byClass[name].files}`) .join(' | ')}(合计 ${summary.gated.files} 个文件)` ) lines.push(` 合计:${formatCounts(summary.gated.counts)}`) lines.push( ` 非门禁分类:${CLASS_ORDER.filter((name) => !GATED_CLASSES.has(name)) .map((name) => `${name} ${summary.byClass[name].files}`) .join(' | ')}(合计 ${summary.nonGated.files} 个文件,仅记录不阻塞)` ) lines.push(` 合计:${formatCounts(summary.nonGated.counts)}`) if (result.nonGatedDelta) { lines.push(` 非门禁相对 baseline:文件 ${result.nonGatedDelta.files >= 0 ? '+' : ''}${result.nonGatedDelta.files};${formatDelta(result.nonGatedDelta.counts)}`) } lines.push('') if (result.updated) { lines.push('结果:baseline 已更新(退出码 0)。') } else if (result.violations.length === 0) { lines.push('结果:通过,无 token 漂移回归。') } else { lines.push(`结果:失败,共 ${result.violations.length} 项:`) lines.push(...result.violations.map((violation) => ` ${describeViolation(violation)}`)) lines.push('') lines.push('提示:确认变更符合预期后运行 `node scripts/check-design-tokens.mjs --update-baseline` 刷新 baseline;') lines.push(' 否则请改用 --yc-* token、复用现有值,或去掉重复的 --wb-* 定义。') } if (!result.updated) { const notices = result.notices ?? { deleted: [], newGatedFilesWithoutCounts: [], usageIncreases: [] } if (notices.usageIncreases.length > 0) { lines.push(`提示(不失败,仅记录):${notices.usageIncreases.length} 个门禁文件 token 使用数上升:`) lines.push( ...notices.usageIncreases .slice(0, MAX_LISTED) .map( (item) => ` ${item.path}(${item.class}):${item.increases .map((increase) => `${METRIC_LABELS[increase.metric]} ${increase.from} → ${increase.to}(+${increase.to - increase.from})`) .join(';')}` ) ) if (notices.usageIncreases.length > MAX_LISTED) { lines.push(` …(还有 ${notices.usageIncreases.length - MAX_LISTED} 项,详见 --json)`) } } if (notices.deleted.length > 0) { lines.push(`提示(不失败):baseline 中 ${notices.deleted.length} 个文件已不存在:${formatList(notices.deleted.map((item) => item.path))}`) } if (notices.newGatedFilesWithoutCounts.length > 0) { lines.push( `提示:新增门禁文件 ${notices.newGatedFilesWithoutCounts.length} 个且无受控计数(允许):${formatList( notices.newGatedFilesWithoutCounts.map((item) => `${item.path}(${item.class})`) )}` ) } } console.log(lines.join('\n')) } function buildJsonResult(result) { return { ok: result.violations.length === 0, updated: result.updated, scanRoot: SCAN_ROOT_LABEL, baseline: { path: BASELINE_LABEL, files: result.baselineFiles }, metrics: METRICS, gatedClasses: CLASS_ORDER.filter((name) => GATED_CLASSES.has(name)), scan: { files: result.scan.files.length, durationMs: result.scan.durationMs }, summary: result.summary, nonGatedDelta: result.nonGatedDelta, violations: result.violations, notices: result.notices } } function main() { let options try { options = parseArgs(process.argv.slice(2)) } catch (error) { console.error(error.message) printUsage() process.exitCode = 1 return } if (options.help) { printUsage() return } const scan = scanFiles() const summary = summarize(scan.files) if (options.updateBaseline) { const baseline = buildBaseline(scan.files) writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8') const result = { updated: true, scan, summary, baselineFiles: scan.files.length, nonGatedDelta: null, violations: [], notices: { deleted: [], newGatedFilesWithoutCounts: [], usageIncreases: [] } } if (options.json) console.log(JSON.stringify(buildJsonResult(result))) else printHuman(result) process.exitCode = 0 return } let baseline try { baseline = loadBaseline() } catch (error) { if (options.json) { console.log(JSON.stringify({ ok: false, baselineFound: false, baseline: { path: BASELINE_LABEL }, error: error.message })) } else { console.error(`[check:tokens] ${error.message}`) console.error('[check:tokens] 请运行:node scripts/check-design-tokens.mjs --update-baseline') } process.exitCode = 1 return } if (!baseline) { if (options.json) { console.log( JSON.stringify({ ok: false, baselineFound: false, baseline: { path: BASELINE_LABEL }, hint: 'node scripts/check-design-tokens.mjs --update-baseline' }) ) } else { console.error(`[check:tokens] 未找到 baseline:${BASELINE_LABEL}`) console.error('[check:tokens] 请先运行:node scripts/check-design-tokens.mjs --update-baseline') } process.exitCode = 1 return } const baselineFiles = baseline.files const { violations, notices } = compareWithBaseline(scan, baselineFiles) const previousNonGated = baselineNonGatedTotals(baselineFiles) const nonGatedDelta = { files: summary.nonGated.files - previousNonGated.files, counts: zeroCounts() } for (const metric of METRICS) { nonGatedDelta.counts[metric] = summary.nonGated.counts[metric] - previousNonGated.counts[metric] } const result = { updated: false, scan, summary, baselineFiles: Object.keys(baselineFiles).length, nonGatedDelta, violations, notices } if (options.json) console.log(JSON.stringify(buildJsonResult(result))) else printHuman(result) process.exitCode = violations.length > 0 ? 1 : 0 } main()