197 lines
8.7 KiB
TypeScript
197 lines
8.7 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from 'fs'
|
||
import { join } from 'path'
|
||
import { describe, expect, it } from 'vitest'
|
||
|
||
/**
|
||
* CYBER 主题的裸色值护栏(docs/cyber-theme-plan.md §P4)。
|
||
*
|
||
* 全项目只允许一处写死颜色:图表色板(docs/ui-baseline.md §7)。
|
||
* 其余任何地方出现裸 hex / 具名颜色 / 字面量 rgba(0,0,0,...) 都算回退到旧主题,
|
||
* 一律拦在测试里,而不是等到肉眼看出色差。
|
||
*
|
||
* 豁免名单按「为什么能写死」逐条列在 EXEMPT,新增豁免必须同时补理由。
|
||
*/
|
||
|
||
const SRC_ROOT = 'packages/client/src'
|
||
|
||
/** 豁免:路径 → 理由。路径用正则匹配,新增必须写明为什么不能走令牌。 */
|
||
const EXEMPT: Array<{ pattern: RegExp; reason: string }> = [
|
||
// docs/ui-baseline.md §7 唯一授权的硬编码处
|
||
{ pattern: /styles[/\\]chart-colors\.scss$/, reason: '图表色板单一来源(§7 豁免)' },
|
||
{ pattern: /styles[/\\]chart-palette\.ts$/, reason: '图表色板单一来源(§7 豁免)' },
|
||
// xterm 的 theme 是 JS 对象,必须给字面量;令牌值已按 OKLCH 同源反算并逐行注释
|
||
{ pattern: /utils[/\\]hermes[/\\]terminal-theme\.ts$/, reason: 'xterm 主题预设需字面量,值与令牌同源' },
|
||
// Arco ConfigProvider 吃的是十六进制字符串,拿不到 CSS 变量
|
||
{ pattern: /styles[/\\]arco-theme\.ts$/, reason: 'Arco ConfigProvider 只接受十六进制' },
|
||
// 红线:vendor 只做装饰位面,pointer-events: none,不承载信息
|
||
{ pattern: /vendor[/\\]/, reason: 'arkscore vendor 红线:装饰位面,只读不改' },
|
||
// 语言徽标是品牌识别色(JS 黄 / Python 蓝 / HTML 橙…),换色会认不出语言
|
||
{ pattern: /chat[/\\]MessageItem\.vue$/, reason: '语言徽标为品牌识别色' },
|
||
]
|
||
|
||
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/
|
||
const NAMED_COLOR_RE =
|
||
/(?:^|[\s;{(,])(?:background(?:-color)?|color|border(?:-color)?|fill|stroke|outline|box-shadow|text-decoration-color|accent-color)\s*:\s*(?:!important\s*)?(white|black|red|blue|green|yellow|orange|purple|(?:dark|light)?(?:gray|grey)|pink|cyan|magenta|lime|navy|teal|olive|maroon|aqua|fuchsia|silver|gold|brown|coral|salmon|khaki|violet|indigo|crimson|tomato|wheat|tan|beige|ivory|linen|plum|orchid|skyblue|steelblue|slateblue|royalblue|dodgerblue|seagreen|forestgreen|darkgreen|darkblue|darkred|darkorange|darkkhaki|lightblue|lightgreen|lightyellow|lightpink|whitesmoke|ghostwhite|floralwhite|antiquewhite|blanchedalmond|blueviolet|mediumblue|mediumseagreen|mediumslateblue|mediumvioletred|palegreen|paleturquoise|peachpuff|powderblue|rosybrown|sandybrown|thistle|turquoise|azure|ivory|khaki|lavender|linen|mistyrose|moccasin|navajowhite|oldlace|papayawhip|peru|pink|plum|powderblue|red|salmon|sandybrown|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen)\s*[;}!]/i
|
||
const LITERAL_BLACK_RGBA_RE = /rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,/i
|
||
|
||
/**
|
||
* 扫描前先剥掉注释与 HTML 实体:
|
||
* - 注释里写「之前是 #b42318」是历史说明,不是样式;
|
||
* - `▾` 是 HTML 实体(▽),不是颜色。
|
||
* 只做这两类预处理,不做其它"聪明"过滤,否则护栏会漏掉真问题。
|
||
*
|
||
* 逐行处理并保留行号,报错时能直接定位到行。
|
||
*/
|
||
function stripNonCode(source: string): string {
|
||
let inBlockComment = false
|
||
|
||
return source
|
||
.split(/\r?\n/)
|
||
.map(raw => {
|
||
let line = raw
|
||
|
||
// 块注释可能跨行:先判断这一行是否在注释里
|
||
if (inBlockComment) {
|
||
const end = line.indexOf('*/')
|
||
if (end === -1) return ' '.repeat(line.length)
|
||
line = ' '.repeat(end + 2) + line.slice(end + 2)
|
||
}
|
||
const blockStart = line.indexOf('/*')
|
||
if (blockStart !== -1) {
|
||
const end = line.indexOf('*/', blockStart + 2)
|
||
if (end === -1) {
|
||
line = line.slice(0, blockStart)
|
||
inBlockComment = true
|
||
} else {
|
||
line = line.slice(0, blockStart) + ' '.repeat(end - blockStart + 2) + line.slice(end + 2)
|
||
}
|
||
}
|
||
|
||
// 行注释:// 到行尾。但 http:// 这类 URL 不算,:// 前面不是行首空白时保留。
|
||
const lineComment = line.search(/(?:^|\s)\/\//)
|
||
if (lineComment !== -1) {
|
||
const keep = lineComment + (line[lineComment] === ' ' ? 1 : 0)
|
||
line = line.slice(0, keep)
|
||
}
|
||
|
||
// HTML 实体:▾ 是 ▽ 不是颜色
|
||
line = line.replace(/&#[0-9a-fA-F]+;/g, ' ')
|
||
|
||
return line
|
||
})
|
||
.join('\n')
|
||
}
|
||
|
||
function walk(dir: string, out: string[] = []): string[] {
|
||
for (const name of readdirSync(dir)) {
|
||
const full = join(dir, name)
|
||
if (statSync(full).isDirectory()) {
|
||
walk(full, out)
|
||
} else if (/\.(vue|scss|css|ts)$/.test(name)) {
|
||
out.push(full)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
const isExempt = (path: string) => EXEMPT.some(({ pattern }) => pattern.test(path))
|
||
|
||
describe('CYBER 裸色值护栏', () => {
|
||
const files = walk(SRC_ROOT)
|
||
.map(p => p.replace(/\\/g, '/'))
|
||
.filter(p => !isExempt(p))
|
||
|
||
/** 找出所有命中正则的行,统一格式化成「路径 + 行号 + 内容」。 */
|
||
const scan = (re: RegExp) =>
|
||
files.flatMap(path => {
|
||
const source = stripNonCode(readFileSync(path, 'utf8'))
|
||
const hits = source
|
||
.split('\n')
|
||
.map((line, i) => ({ line: line.trim(), no: i + 1 }))
|
||
.filter(({ line }) => re.test(line))
|
||
return hits.length ? [`${path}\n ${hits.map(h => `${h.no}: ${h.line}`).join('\n ')}`] : []
|
||
})
|
||
|
||
it('扫得到源文件(护栏本身没失效)', () => {
|
||
expect(files.length).toBeGreaterThan(100)
|
||
})
|
||
|
||
it('除图表色板外没有裸 hex', () => {
|
||
expect(scan(HEX_RE), '裸 hex 必须换成令牌(variables.scss)').toEqual([])
|
||
})
|
||
|
||
it('没有具名颜色关键字', () => {
|
||
expect(scan(NAMED_COLOR_RE), '具名颜色必须换成令牌').toEqual([])
|
||
})
|
||
|
||
it('没有字面量 rgba(0,0,0) 阴影', () => {
|
||
expect(scan(LITERAL_BLACK_RGBA_RE), '黑色阴影要走 --shadow-* 令牌').toEqual([])
|
||
})
|
||
|
||
it('半透明一律用 RGB 三元组,不写死数值', () => {
|
||
// rgba(15, 23, 42, ...) 这类:数字三元组是旧主题残留,必须换成
|
||
// rgba(var(--*-rgb), α)。豁免名单里的文件已在上面几条单独覆盖。
|
||
const literalRgba = /rgba?\(\s*\d{1,3}\s*,/
|
||
expect(scan(literalRgba), 'rgba 数字三元组要换成 var(--*-rgb) 三元组').toEqual([])
|
||
})
|
||
|
||
it('护栏本身能抓到真问题(自检)', () => {
|
||
// 剥注释这一步最容易悄悄失效:改坏了护栏还会一直"通过"。
|
||
// 这里用已知样本验证检测逻辑本身。
|
||
const dirty = [
|
||
'color: #ff0000;', // 裸 hex
|
||
'background: rgba(1, 2, 3, 0.5);', // 字面量三元组
|
||
'box-shadow: rgba(0, 0, 0, 0.4);', // 字面量黑影
|
||
'color: red;', // 具名颜色
|
||
'background: #0f766e;', // 裸 hex
|
||
].join('\n')
|
||
|
||
const cleaned = stripNonCode(dirty)
|
||
expect(HEX_RE.test(cleaned)).toBe(true)
|
||
expect(NAMED_COLOR_RE.test(cleaned)).toBe(true)
|
||
expect(LITERAL_BLACK_RGBA_RE.test(cleaned)).toBe(true)
|
||
expect(/rgba?\(\s*\d{1,3}\s*,/.test(cleaned)).toBe(true)
|
||
})
|
||
|
||
it('剥注释不会把真问题也剥掉', () => {
|
||
const dirty = [
|
||
'// 注释里的 #ff0000 是历史说明',
|
||
'/* 块注释里的 #00ff00 也是 */',
|
||
'color: #0000ff;',
|
||
'background: rgba(9, 9, 9, 0.3);',
|
||
].join('\n')
|
||
|
||
const cleaned = stripNonCode(dirty)
|
||
expect(cleaned).not.toContain('#ff0000')
|
||
expect(cleaned).not.toContain('#00ff00')
|
||
expect(cleaned).toContain('#0000ff')
|
||
expect(cleaned).toContain('rgba(9, 9, 9, 0.3)')
|
||
})
|
||
|
||
it('语言徽标品牌色被锁死(豁免也要有断言)', () => {
|
||
// MessageItem.vue 整体豁免(语言徽标是品牌识别色),但豁免范围不能悄悄扩大:
|
||
// 这里把徽标色板逐个锁住,改色必须同时改这里并说明理由。
|
||
const source = readFileSync('packages/client/src/components/hermes/chat/MessageItem.vue', 'utf8')
|
||
|
||
const expected: Record<string, string> = {
|
||
script: '#f7df1e', // JS
|
||
dynamic: '#3776ab', // Python
|
||
jvm: '#f0642f', // Kotlin
|
||
systems: '#5e63b6', // C
|
||
markup: '#e34f26', // HTML
|
||
style: '#8b5cf6', // CSS
|
||
data: '#64748b', // SQL
|
||
doc: '#0f766e', // YAML
|
||
shell: '#111827', // Shell
|
||
default: '#6b7280', // 其它
|
||
}
|
||
|
||
for (const [lang, color] of Object.entries(expected)) {
|
||
expect(
|
||
source,
|
||
`语言徽标 ${lang} 的品牌色必须是 ${color}(语言识别色,不参与主题令牌)`,
|
||
).toContain(`background: ${color};`)
|
||
}
|
||
})
|
||
})
|