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.
This commit is contained in:
xiaohan.yuan committed 2026-10-18 18:36:43 +08:00
1 parent 98cf32b6e2
commit 3ceae1e983
4 files changed
+4663

No files matched your search

+501
View File
@@ -0,0 +1,501 @@
#!/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()