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 = { 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};`) } }) })