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.
502 lines
18 KiB
JavaScript
502 lines
18 KiB
JavaScript
#!/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()
|