Files
ycode/scripts/check-design-tokens.mjs
xiaohan.yuan 3ceae1e983 chore(design-system): add extraction artifact and token drift gate
Record the design decisions actually in use and add a static gate that blocks new bare colors, bare sizes, important overrides, and wb token definitions outside token sources.
2026-10-18 18:36:43 +08:00

502 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: () => /(?<![\w.])\d+(?:\.\d+)?px\b/gi,
important: () => /!\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()